Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ build-legacy:
# Run the application (new CLI)
run: docker-up
@echo "Waiting for Neo4j to be ready..."
@sleep 15
@sleep 30
@echo "Starting application..."
go run cmd/sql-graph-visualizer/main.go serve

Expand Down
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ func main() {
performanceServices.GraphMapper,
performanceServices.RealtimeMonitor,
performanceServices.PSAdapter,
neo4jRepo,
)
performanceHandlers.RegisterRoutes(router)
logrus.Info("Performance API routes registered")
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ toolchain go1.26.4

require (
github.com/99designs/gqlgen v0.17.91
github.com/go-sql-driver/mysql v1.9.3
github.com/go-sql-driver/mysql v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
Expand All @@ -19,15 +19,15 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/vektah/gqlparser/v2 v2.5.34
golang.org/x/text v0.38.0
golang.org/x/text v0.39.0
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
)

require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
Expand All @@ -42,9 +42,9 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/urfave/cli/v3 v3.10.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.46.0 // indirect
golang.org/x/tools v0.47.0 // indirect
)
20 changes: 10 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KO
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -31,8 +31,8 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
Expand Down Expand Up @@ -130,8 +130,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
Expand Down Expand Up @@ -168,13 +168,13 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
178 changes: 137 additions & 41 deletions internal/application/bootstrap/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"

"sql-graph-visualizer/internal/application/ports"
graphqlserver "sql-graph-visualizer/internal/application/services/graphql"
"sql-graph-visualizer/internal/application/services/performance"
"sql-graph-visualizer/internal/domain/aggregates/graph"
Expand Down Expand Up @@ -179,6 +180,7 @@ func (r *Resources) startAPIServer() (*http.Server, error) {
r.PerformanceServices.GraphMapper,
r.PerformanceServices.RealtimeMonitor,
r.PerformanceServices.PSAdapter,
r.Neo4jRepo,
)
performanceHandlers.RegisterRoutes(router)
logrus.Info("Performance API routes registered")
Expand Down Expand Up @@ -361,10 +363,17 @@ func initPerformanceServices(cfg *models.Config, db *sql.DB) *PerformanceService
benchmarkConfig := createBenchmarkConfig(cfg)
// The source/graph repositories are intentionally nil here: the current
// benchmark execution path runs sysbench, which connects to the database
// directly via the configured DatabaseURL. Repositories will be wired in when
// benchmark-result persistence is added.

// directly via the configured DatabaseURL.
benchmarkService := performance.NewBenchmarkService(nil, nil, nil, performanceAnalyzer, logger, benchmarkConfig)
registerBenchmarkTools(benchmarkService, cfg, logger)
registerBenchmarkTools(benchmarkService, cfg, db, logger)

if store, err := performance.NewFileBenchmarkResultStore(logger, benchmarkResultsDir(cfg)); err != nil {
logrus.Warnf("Benchmark result persistence unavailable, falling back to in-memory results only: %v", err)
} else {
benchmarkService.SetResultStore(store)
}


if cfg.Performance.Realtime != nil && cfg.Performance.Realtime.Enabled {
ctx := context.Background()
Expand All @@ -385,50 +394,70 @@ func initPerformanceServices(cfg *models.Config, db *sql.DB) *PerformanceService
}

func createGraphMapperConfig(cfg *models.Config) *performance.GraphPerformanceMapperConfig {
config := &performance.GraphPerformanceMapperConfig{}
// Start from sane defaults so an invalid or empty duration in the user
// configuration falls back to a working value instead of silently
// zeroing the interval (which would otherwise busy-loop or never update).
config := performance.DefaultGraphPerformanceMapperConfig()
if cfg.Performance.Visualization != nil {
updateInterval, _ := time.ParseDuration(cfg.Performance.Visualization.UpdateInterval)
historyRetention, _ := time.ParseDuration(cfg.Performance.Visualization.HistoryRetention)
config.UpdateInterval = updateInterval
config.HistoryRetention = historyRetention
config.MaxConcurrentUpdates = cfg.Performance.Visualization.MaxConcurrentUpdates
if cfg.Performance.Visualization.EdgeThickness != nil {
v := cfg.Performance.Visualization
if d, err := time.ParseDuration(v.UpdateInterval); err == nil && d > 0 {
config.UpdateInterval = d
}
if d, err := time.ParseDuration(v.HistoryRetention); err == nil && d > 0 {
config.HistoryRetention = d
}
if v.MaxConcurrentUpdates > 0 {
config.MaxConcurrentUpdates = v.MaxConcurrentUpdates
}
if v.EdgeThickness != nil {
config.EdgeThickness = performance.EdgeThicknessConfig{
Metric: cfg.Performance.Visualization.EdgeThickness.Metric,
Scale: cfg.Performance.Visualization.EdgeThickness.Scale,
MinThickness: cfg.Performance.Visualization.EdgeThickness.MinThickness,
MaxThickness: cfg.Performance.Visualization.EdgeThickness.MaxThickness,
Multiplier: cfg.Performance.Visualization.EdgeThickness.Multiplier,
Metric: v.EdgeThickness.Metric,
Scale: v.EdgeThickness.Scale,
MinThickness: v.EdgeThickness.MinThickness,
MaxThickness: v.EdgeThickness.MaxThickness,
Multiplier: v.EdgeThickness.Multiplier,
}
}
}
return config
}

func createRealtimeConfig(cfg *models.Config) *performance.RealtimeMonitorConfig {
config := &performance.RealtimeMonitorConfig{}
// Start from sane defaults; only override fields the user actually set so
// an invalid/empty duration doesn't silently zero the interval.
config := performance.DefaultRealtimeMonitorConfig()
if cfg.Performance.Realtime != nil {
updateInterval, _ := time.ParseDuration(cfg.Performance.Realtime.UpdateInterval)
heartbeatInterval, _ := time.ParseDuration(cfg.Performance.Realtime.HeartbeatInterval)
writeTimeout, _ := time.ParseDuration(cfg.Performance.Realtime.WriteTimeout)
readTimeout, _ := time.ParseDuration(cfg.Performance.Realtime.ReadTimeout)
pingTimeout, _ := time.ParseDuration(cfg.Performance.Realtime.PingTimeout)
config.DataUpdateInterval = updateInterval
config.HeartbeatInterval = heartbeatInterval
config.MaxConnections = cfg.Performance.Realtime.MaxConnections
config.WriteTimeout = writeTimeout
config.ReadTimeout = readTimeout
config.PingTimeout = pingTimeout
config.MaxMessageSize = cfg.Performance.Realtime.MaxMessageSize
config.CompressionEnabled = cfg.Performance.Realtime.CompressionEnabled
if cfg.Performance.Realtime.Alerts != nil {
r := cfg.Performance.Realtime
if d, err := time.ParseDuration(r.UpdateInterval); err == nil && d > 0 {
config.DataUpdateInterval = d
}
if d, err := time.ParseDuration(r.HeartbeatInterval); err == nil && d > 0 {
config.HeartbeatInterval = d
}
if d, err := time.ParseDuration(r.WriteTimeout); err == nil && d > 0 {
config.WriteTimeout = d
}
if d, err := time.ParseDuration(r.ReadTimeout); err == nil && d > 0 {
config.ReadTimeout = d
}
if d, err := time.ParseDuration(r.PingTimeout); err == nil && d > 0 {
config.PingTimeout = d
}
if r.MaxConnections > 0 {
config.MaxConnections = r.MaxConnections
}
if r.MaxMessageSize > 0 {
config.MaxMessageSize = r.MaxMessageSize
}
config.CompressionEnabled = r.CompressionEnabled
if r.Alerts != nil {
config.AlertThresholds = performance.AlertThresholds{
HighLatency: cfg.Performance.Realtime.Alerts.HighLatency,
HighErrorRate: cfg.Performance.Realtime.Alerts.HighErrorRate,
HighCPUUsage: cfg.Performance.Realtime.Alerts.HighCPUUsage,
HighMemoryUsage: cfg.Performance.Realtime.Alerts.HighMemoryUsage,
SlowQueryThreshold: cfg.Performance.Realtime.Alerts.SlowQueryThreshold,
DeadlockThreshold: cfg.Performance.Realtime.Alerts.DeadlockThreshold,
HighLatency: r.Alerts.HighLatency,
HighErrorRate: r.Alerts.HighErrorRate,
HighCPUUsage: r.Alerts.HighCPUUsage,
HighMemoryUsage: r.Alerts.HighMemoryUsage,
SlowQueryThreshold: r.Alerts.SlowQueryThreshold,
DeadlockThreshold: r.Alerts.DeadlockThreshold,
}
}
}
Expand Down Expand Up @@ -489,10 +518,11 @@ func createBenchmarkConfig(cfg *models.Config) *performance.BenchmarkServiceConf
return config
}

// registerBenchmarkTools wires the available benchmark tools (currently
// sysbench) into the benchmark service. Unavailable tools are logged and
// skipped so the application keeps running without benchmarking support.
func registerBenchmarkTools(svc *performance.BenchmarkService, cfg *models.Config, logger *logrus.Logger) {
// registerBenchmarkTools wires the available benchmark tools (sysbench and,
// when configured, custom query sets) into the benchmark service.
// Unavailable tools are logged and skipped so the application keeps running
// without benchmarking support.
func registerBenchmarkTools(svc *performance.BenchmarkService, cfg *models.Config, db *sql.DB, logger *logrus.Logger) {
if b := cfg.Performance.Benchmarks; b != nil && !b.Enabled {
logrus.Info("Benchmarking disabled in configuration; skipping benchmark tool registration")
return
Expand All @@ -501,9 +531,66 @@ func registerBenchmarkTools(svc *performance.BenchmarkService, cfg *models.Confi
sysbenchAdapter := performance.NewSysbenchAdapter(logger, createSysbenchConfig(cfg))
if err := svc.RegisterBenchmarkTool("sysbench", sysbenchAdapter); err != nil {
logrus.Warnf("Sysbench benchmark tool unavailable; sysbench benchmarks disabled: %v", err)
} else {
logrus.Info("Registered sysbench benchmark tool")
}

querySets := createCustomQuerySets(cfg)
if len(querySets) == 0 {
return
}
customAdapter := performance.NewCustomQueryAdapter(logger, db, querySets)
if err := svc.RegisterBenchmarkTool("custom", customAdapter); err != nil {
logrus.Warnf("Custom query benchmark tool unavailable: %v", err)
return
}
logrus.Info("Registered sysbench benchmark tool")
logrus.Infof("Registered custom query benchmark tool with %d query set(s)", len(querySets))
}

// createCustomQuerySets converts the user-configured custom query benchmark
// sets into the ports representation consumed by CustomQueryAdapter.
func createCustomQuerySets(cfg *models.Config) map[string]ports.CustomBenchmarkConfig {
result := make(map[string]ports.CustomBenchmarkConfig)
b := cfg.Performance.Benchmarks
if b == nil {
return result
}

for _, set := range b.CustomQueries {
if set.Name == "" || len(set.Queries) == 0 {
continue
}

duration, _ := time.ParseDuration(set.Duration)

queries := make([]ports.CustomQueryDefinition, 0, len(set.Queries))
for _, q := range set.Queries {
if q.Query == "" {
continue
}
expectedLatency, _ := time.ParseDuration(q.ExpectedLatency)
queries = append(queries, ports.CustomQueryDefinition{
Query: q.Query,
Weight: q.Weight,
Parameters: q.Parameters,
Description: q.Description,
ExpectedLatency: expectedLatency,
TargetQPS: q.TargetQPS,
})
}
if len(queries) == 0 {
continue
}

result[set.Name] = ports.CustomBenchmarkConfig{
Name: set.Name,
Description: set.Description,
Duration: duration,
Threads: set.Threads,
Queries: queries,
}
}
return result
}

// createSysbenchConfig maps user configuration onto the sysbench adapter config,
Expand All @@ -523,6 +610,15 @@ func createSysbenchConfig(cfg *models.Config) *performance.SysbenchConfig {
return sbConfig
}

// benchmarkResultsDir returns the configured directory for persisted
// benchmark results, falling back to a sane default when unset.
func benchmarkResultsDir(cfg *models.Config) string {
if b := cfg.Performance.Benchmarks; b != nil && b.ResultsDir != "" {
return b.ResultsDir
}
return "data/performance/benchmarks"
}

// benchmarkDatabaseURL builds a sysbench-compatible database URL and driver type
// from the active source database configuration.
func benchmarkDatabaseURL(cfg *models.Config) (string, string) {
Expand Down
39 changes: 39 additions & 0 deletions internal/application/ports/benchmark_result_store_port.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Package ports defines interfaces for application layer dependencies.
package ports

import (
"context"
"time"
)

// BenchmarkResultStorePort defines persistence for historical benchmark
// results, allowing trend analysis and regression detection to survive
// process restarts.
type BenchmarkResultStorePort interface {
// Save persists a completed (or failed/cancelled) benchmark result.
Save(ctx context.Context, result *BenchmarkResult) error

// Get retrieves a single stored result by its execution ID.
Get(ctx context.Context, id string) (*BenchmarkResult, error)

// List returns stored results matching the given filter, ordered by
// start time ascending.
List(ctx context.Context, filter BenchmarkResultFilter) ([]*BenchmarkResult, error)

// DeleteOlderThan removes stored results whose start time is before the
// given cutoff. It returns the number of deleted results.
DeleteOlderThan(ctx context.Context, cutoff time.Time) (int, error)
}

// BenchmarkResultFilter narrows down which stored benchmark results to
// return from List. Zero-valued fields are treated as "no filter" for that
// dimension.
type BenchmarkResultFilter struct {
ToolName string
TestType string
Since time.Time
Until time.Time
// Limit caps the number of results returned (most recent first). Zero
// or negative means no limit.
Limit int
}
Loading
Loading