diff --git a/Makefile b/Makefile index 7b4c0a9..e272843 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,9 @@ build-legacy: @echo "Legacy build completed" # Run the application (new CLI) -run: +run: docker-up + @echo "Waiting for Neo4j to be ready..." + @sleep 15 @echo "Starting application..." go run cmd/sql-graph-visualizer/main.go serve @@ -98,7 +100,7 @@ clean: # Start Docker services docker-up: @echo "Starting Docker services..." - docker-compose up -d neo4j-test + docker-compose up -d neo4j-test mysql-test @echo "Docker services started" # Stop Docker services diff --git a/internal/application/bootstrap/servers.go b/internal/application/bootstrap/servers.go index 3ba7ddd..60c0773 100644 --- a/internal/application/bootstrap/servers.go +++ b/internal/application/bootstrap/servers.go @@ -359,7 +359,12 @@ func initPerformanceServices(cfg *models.Config, db *sql.DB) *PerformanceService realtimeMonitor := performance.NewRealtimePerformanceMonitor(logger, realtimeConfig, psAdapter, performanceAnalyzer, graphMapper) 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. benchmarkService := performance.NewBenchmarkService(nil, nil, nil, performanceAnalyzer, logger, benchmarkConfig) + registerBenchmarkTools(benchmarkService, cfg, logger) if cfg.Performance.Realtime != nil && cfg.Performance.Realtime.Enabled { ctx := context.Background() @@ -431,19 +436,108 @@ func createRealtimeConfig(cfg *models.Config) *performance.RealtimeMonitorConfig } func createBenchmarkConfig(cfg *models.Config) *performance.BenchmarkServiceConfig { - config := &performance.BenchmarkServiceConfig{} - if cfg.Performance.Benchmarks != nil { - defaultDuration, _ := time.ParseDuration(cfg.Performance.Benchmarks.DefaultDuration) - maxDuration, _ := time.ParseDuration(cfg.Performance.Benchmarks.MaxDuration) - resultsRetention, _ := time.ParseDuration(cfg.Performance.Benchmarks.ResultsRetention) - config.DefaultTimeout = defaultDuration - config.MaxDuration = maxDuration - config.RetainResults = resultsRetention - config.CleanupInterval = 15 * time.Minute - if cfg.Performance.Benchmarks.Limits != nil { - config.MaxConcurrentRuns = cfg.Performance.Benchmarks.Limits.MaxConcurrentBenchmarks - config.MaxResultsInMemory = cfg.Performance.Benchmarks.Limits.MemoryLimitMB + // Start from sane defaults so safety limits and execution defaults are always + // populated, then override with user configuration where provided. + config := performance.DefaultBenchmarkServiceConfig() + + // Default the benchmark target to the configured source database so that + // benchmark requests work without explicitly specifying a connection. + if url, dbType := benchmarkDatabaseURL(cfg); url != "" { + config.DefaultDatabaseURL = url + config.DefaultDatabaseType = dbType + } + + if b := cfg.Performance.Benchmarks; b != nil { + if d, err := time.ParseDuration(b.DefaultDuration); err == nil && d > 0 { + config.DefaultTestDuration = d + } + if d, err := time.ParseDuration(b.MaxDuration); err == nil && d > 0 { + config.MaxDuration = d + } + if d, err := time.ParseDuration(b.ResultsRetention); err == nil && d > 0 { + config.RetainResults = d + } + if b.Limits != nil { + if b.Limits.MaxConcurrentBenchmarks > 0 { + config.MaxConcurrentRuns = b.Limits.MaxConcurrentBenchmarks + } + if b.Limits.MemoryLimitMB > 0 { + config.MaxResultsInMemory = b.Limits.MemoryLimitMB + } + } + if b.Sysbench != nil && b.Sysbench.Defaults != nil { + if b.Sysbench.Defaults.Threads > 0 { + config.DefaultThreads = b.Sysbench.Defaults.Threads + } + if b.Sysbench.Defaults.TableSize > 0 { + config.DefaultTableSize = b.Sysbench.Defaults.TableSize + } + if b.Sysbench.Defaults.Time > 0 { + config.DefaultTestDuration = time.Duration(b.Sysbench.Defaults.Time) * time.Second + } } } + + // The execution context must comfortably outlast a default-length run. + if config.DefaultTimeout <= config.DefaultTestDuration { + config.DefaultTimeout = config.DefaultTestDuration + 5*time.Minute + } + if config.MaxDuration < config.DefaultTestDuration { + config.MaxDuration = config.DefaultTestDuration + } + 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) { + if b := cfg.Performance.Benchmarks; b != nil && !b.Enabled { + logrus.Info("Benchmarking disabled in configuration; skipping benchmark tool registration") + return + } + + 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) + return + } + logrus.Info("Registered sysbench benchmark tool") +} + +// createSysbenchConfig maps user configuration onto the sysbench adapter config, +// starting from the adapter defaults. +func createSysbenchConfig(cfg *models.Config) *performance.SysbenchConfig { + sbConfig := performance.DefaultSysbenchConfig() + + if b := cfg.Performance.Benchmarks; b != nil && b.Sysbench != nil { + if b.Sysbench.ExecutablePath != "" { + sbConfig.BinaryPath = b.Sysbench.ExecutablePath + } + if d := b.Sysbench.Defaults; d != nil && d.TableSize > 0 { + sbConfig.DefaultTableSize = d.TableSize + } + } + + return sbConfig +} + +// benchmarkDatabaseURL builds a sysbench-compatible database URL and driver type +// from the active source database configuration. +func benchmarkDatabaseURL(cfg *models.Config) (string, string) { + dbCfg := cfg.GetDatabaseConfig() + switch dbCfg.Type { + case models.DatabaseTypePostgreSQL: + if pg := dbCfg.PostgreSQL; pg != nil { + return fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", + pg.GetUsername(), pg.GetPassword(), pg.GetHost(), pg.GetPort(), pg.GetDatabase()), "postgresql" + } + case models.DatabaseTypeMySQL: + if my := dbCfg.MySQL; my != nil { + return fmt.Sprintf("mysql://%s:%s@%s:%d/%s", + my.GetUsername(), my.GetPassword(), my.GetHost(), my.GetPort(), my.GetDatabase()), "mysql" + } + } + return "", "" +} diff --git a/internal/application/services/performance/benchmark_service.go b/internal/application/services/performance/benchmark_service.go index bec4c82..d0a1cab 100644 --- a/internal/application/services/performance/benchmark_service.go +++ b/internal/application/services/performance/benchmark_service.go @@ -50,6 +50,15 @@ type BenchmarkServiceConfig struct { MaxDuration time.Duration `yaml:"max_duration" json:"max_duration"` MaxThreads int `yaml:"max_threads" json:"max_threads"` + // Default execution parameters (applied when a request omits them) + DefaultDatabaseURL string `yaml:"default_database_url" json:"default_database_url"` + DefaultDatabaseType string `yaml:"default_database_type" json:"default_database_type"` + DefaultTestType string `yaml:"default_test_type" json:"default_test_type"` + DefaultTestDuration time.Duration `yaml:"default_test_duration" json:"default_test_duration"` + DefaultThreads int `yaml:"default_threads" json:"default_threads"` + DefaultTables int `yaml:"default_tables" json:"default_tables"` + DefaultTableSize int `yaml:"default_table_size" json:"default_table_size"` + // Tool configurations EnabledTools []string `yaml:"enabled_tools" json:"enabled_tools"` ToolConfigurations map[string]interface{} `yaml:"tool_configurations" json:"tool_configurations"` @@ -142,6 +151,10 @@ func (s *BenchmarkService) GetAvailableTools() []string { // ExecuteBenchmark runs a benchmark with the specified configuration func (s *BenchmarkService) ExecuteBenchmark(ctx context.Context, config ports.BenchmarkConfig, toolName string) (string, error) { + // Fill in any unset fields from the service defaults (database URL, threads, + // table sizing, duration, ...) so callers can submit a minimal request. + s.applyConfigDefaults(&config) + // Validate configuration if err := s.validateConfig(config); err != nil { return "", fmt.Errorf("invalid configuration: %w", err) @@ -163,7 +176,10 @@ func (s *BenchmarkService) ExecuteBenchmark(ctx context.Context, config ports.Be } executionID := uuid.New().String() - executionCtx, cancel := context.WithTimeout(ctx, s.config.DefaultTimeout) + // Detach from the caller's context: benchmarks run asynchronously and must + // outlive the originating HTTP request. Cancellation is handled explicitly + // through CancelBenchmark/StopBenchmark via the stored cancel function. + executionCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.config.DefaultTimeout) execution := &BenchmarkExecution{ ID: executionID, @@ -357,6 +373,33 @@ func (s *BenchmarkService) validateConfig(config ports.BenchmarkConfig) error { return nil } +// applyConfigDefaults fills unset benchmark configuration fields from the +// service defaults so that minimal requests (e.g. only a tool name) still +// produce a runnable configuration. +func (s *BenchmarkService) applyConfigDefaults(config *ports.BenchmarkConfig) { + if config.TestType == "" { + config.TestType = s.config.DefaultTestType + } + if config.DatabaseURL == "" { + config.DatabaseURL = s.config.DefaultDatabaseURL + } + if config.DatabaseType == "" { + config.DatabaseType = s.config.DefaultDatabaseType + } + if config.Threads <= 0 { + config.Threads = s.config.DefaultThreads + } + if config.Tables <= 0 { + config.Tables = s.config.DefaultTables + } + if config.TableSize <= 0 { + config.TableSize = s.config.DefaultTableSize + } + if config.Duration <= 0 { + config.Duration = s.config.DefaultTestDuration + } +} + func (s *BenchmarkService) getBenchmarkTool(name string) (ports.BenchmarkToolPort, error) { s.toolsMutex.RLock() defer s.toolsMutex.RUnlock() @@ -473,17 +516,30 @@ func (s *BenchmarkService) cleanupOldExecutions() { // defaultBenchmarkServiceConfig returns default configuration func defaultBenchmarkServiceConfig() *BenchmarkServiceConfig { return &BenchmarkServiceConfig{ - MaxConcurrentRuns: 5, - DefaultTimeout: 30 * time.Minute, - CleanupInterval: 15 * time.Minute, - RetainResults: 2 * time.Hour, - MaxResultsInMemory: 100, - MaxTableSize: 1000000, // 1M rows - MaxDuration: 1 * time.Hour, - MaxThreads: 64, - EnabledTools: []string{"sysbench", "custom"}, - ToolConfigurations: make(map[string]interface{}), - } + MaxConcurrentRuns: 5, + DefaultTimeout: 30 * time.Minute, + CleanupInterval: 15 * time.Minute, + RetainResults: 2 * time.Hour, + MaxResultsInMemory: 100, + MaxTableSize: 1000000, // 1M rows + MaxDuration: 1 * time.Hour, + MaxThreads: 64, + EnabledTools: []string{"sysbench", "custom"}, + ToolConfigurations: make(map[string]interface{}), + DefaultDatabaseType: "mysql", + DefaultTestType: "oltp_read_write", + DefaultTestDuration: 60 * time.Second, + DefaultThreads: 4, + DefaultTables: 4, + DefaultTableSize: 10000, + } +} + +// DefaultBenchmarkServiceConfig returns the default benchmark service +// configuration. Exported so callers (e.g. bootstrap) can start from sane +// defaults and override individual fields from user configuration. +func DefaultBenchmarkServiceConfig() *BenchmarkServiceConfig { + return defaultBenchmarkServiceConfig() } // Additional methods for integration with existing graph services diff --git a/internal/application/services/performance/sysbench_adapter.go b/internal/application/services/performance/sysbench_adapter.go index f6dd2c8..d48d90d 100644 --- a/internal/application/services/performance/sysbench_adapter.go +++ b/internal/application/services/performance/sysbench_adapter.go @@ -568,6 +568,13 @@ func (s *SysbenchAdapter) classifyPerformanceImpact(avgLatency float64) string { return "HIGH" } +// DefaultSysbenchConfig returns the default sysbench adapter configuration. +// Exported so callers (e.g. bootstrap) can start from sane defaults and +// override individual fields from user configuration. +func DefaultSysbenchConfig() *SysbenchConfig { + return defaultSysbenchConfig() +} + // defaultSysbenchConfig returns default sysbench configuration func defaultSysbenchConfig() *SysbenchConfig { return &SysbenchConfig{ diff --git a/internal/domain/models/config.go b/internal/domain/models/config.go index 6d71510..dc25aa5 100644 --- a/internal/domain/models/config.go +++ b/internal/domain/models/config.go @@ -216,7 +216,7 @@ func (c *Config) GetDatabaseType() DatabaseType { // PerformanceConfig represents the main performance .monitoring configuration type PerformanceConfig struct { - Monitoring *MonitoringConfig `yaml:".monitoring,omitempty"` + Monitoring *MonitoringConfig `yaml:"monitoring,omitempty"` Realtime *RealtimeConfig `yaml:"realtime,omitempty"` Benchmarks *BenchmarksConfig `yaml:"benchmarks,omitempty"` Visualization *VisualizationConfig `yaml:"visualization,omitempty"` diff --git a/internal/interfaces/api/performance_handlers.go b/internal/interfaces/api/performance_handlers.go index 82e99ce..74b3f63 100644 --- a/internal/interfaces/api/performance_handlers.go +++ b/internal/interfaces/api/performance_handlers.go @@ -44,10 +44,22 @@ type Error struct { // BenchmarkRequest represents a benchmark execution request type BenchmarkRequest struct { - BenchmarkType string `json:"benchmark_type"` - Config map[string]interface{} `json:"config"` - Duration int `json:"duration_seconds"` - Description string `json:"description,omitempty"` + // BenchmarkType is kept for backward compatibility. Historically the + // dashboard sends the tool name here (e.g. "sysbench"); it may also carry a + // sysbench test type (e.g. "oltp_read_write"). + BenchmarkType string `json:"benchmark_type"` + Tool string `json:"tool,omitempty"` + TestType string `json:"test_type,omitempty"` + Threads int `json:"threads,omitempty"` + Tables int `json:"tables,omitempty"` + TableSize int `json:"table_size,omitempty"` + WarmupSeconds int `json:"warmup_seconds,omitempty"` + DatabaseURL string `json:"database_url,omitempty"` + DatabaseType string `json:"database_type,omitempty"` + + Config map[string]interface{} `json:"config"` + Duration int `json:"duration_seconds"` + Description string `json:"description,omitempty"` } // BenchmarkStatusResponse represents benchmark status @@ -158,20 +170,25 @@ func (ph *PerformanceHandlers) StartBenchmark(w http.ResponseWriter, r *http.Req return } - // Validate request - if req.BenchmarkType == "" { - ph.sendErrorResponse(w, http.StatusBadRequest, "validation_error", "benchmark_type is required", "") - return - } + // Resolve the tool to run and the sysbench test type. For backward + // compatibility the dashboard sends the tool name in benchmark_type; it may + // also carry a test type. An empty test type lets the service apply its + // configured default. + tool, testType := resolveBenchmarkRequest(req) - // Create benchmark configuration from ports config := ports.BenchmarkConfig{ - TestType: req.BenchmarkType, + TestType: testType, Duration: time.Duration(req.Duration) * time.Second, + Threads: req.Threads, + Tables: req.Tables, + TableSize: req.TableSize, + WarmupTime: time.Duration(req.WarmupSeconds) * time.Second, + DatabaseType: req.DatabaseType, + DatabaseURL: req.DatabaseURL, CustomParams: req.Config, } - executionID, err := ph.benchmarkService.ExecuteBenchmark(r.Context(), config, req.BenchmarkType) + executionID, err := ph.benchmarkService.ExecuteBenchmark(r.Context(), config, tool) if err != nil { ph.sendErrorResponse(w, http.StatusInternalServerError, "benchmark_error", "Failed to start benchmark", err.Error()) return @@ -183,8 +200,9 @@ func (ph *PerformanceHandlers) StartBenchmark(w http.ResponseWriter, r *http.Req StartTime: time.Now(), Progress: 0.0, Metadata: map[string]interface{}{ - "benchmark_type": req.BenchmarkType, - "duration": req.Duration, + "tool": tool, + "test_type": testType, + "duration": req.Duration, }, } @@ -195,6 +213,36 @@ func (ph *PerformanceHandlers) StartBenchmark(w http.ResponseWriter, r *http.Req }) } +// knownBenchmarkTools enumerates tool selectors accepted in the legacy +// benchmark_type field for backward compatibility with the dashboard. +var knownBenchmarkTools = map[string]bool{"sysbench": true, "custom": true} + +// resolveBenchmarkRequest determines the benchmark tool and (optional) sysbench +// test type from a request. It preserves backward compatibility with the +// dashboard, which sends the tool name in benchmark_type. An empty test type is +// returned when none is specified, letting the service apply its default. +func resolveBenchmarkRequest(req BenchmarkRequest) (tool, testType string) { + tool = req.Tool + testType = req.TestType + + switch { + case tool != "": + // explicit tool wins + case knownBenchmarkTools[req.BenchmarkType]: + tool = req.BenchmarkType + case req.BenchmarkType != "": + // benchmark_type carried a test type; default the tool to sysbench + tool = "sysbench" + if testType == "" { + testType = req.BenchmarkType + } + default: + tool = "sysbench" + } + + return tool, testType +} + // GetBenchmark handles requests to get a specific benchmark. func (ph *PerformanceHandlers) GetBenchmark(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r)