diff --git a/src/SQLQueryStress/LoadEngine.cs b/src/SQLQueryStress/LoadEngine.cs index b87c458..7d33ce8 100644 --- a/src/SQLQueryStress/LoadEngine.cs +++ b/src/SQLQueryStress/LoadEngine.cs @@ -79,14 +79,14 @@ public static bool ExecuteCommand(string connectionString, string sql) return true; } - public void StartLoad(BackgroundWorker worker, int queryDelay) + public void StartLoad(BackgroundWorker worker, int queryDelay, Action outputCallback = null) { _queryDelay = queryDelay; - StartLoad(worker); + StartLoad(worker, outputCallback); } - private void StartLoad(BackgroundWorker worker) + private void StartLoad(BackgroundWorker worker, Action outputCallback) { var useParams = false; @@ -203,7 +203,14 @@ private void StartLoad(BackgroundWorker worker) //Report output to the UI int finishedThreads = Interlocked.CompareExchange(ref _finishedThreads, 0, 0); theOut.ActiveThreads = _threads - finishedThreads; - worker.ReportProgress((int)(_finishedThreads / (decimal)_threads * 100), theOut); + if (outputCallback == null) + { + worker.ReportProgress((int)(_finishedThreads / (decimal)_threads * 100), theOut); + } + else + { + outputCallback(theOut); + } } } } diff --git a/src/SQLQueryStress/RunResult.cs b/src/SQLQueryStress/RunResult.cs new file mode 100644 index 0000000..dab2779 --- /dev/null +++ b/src/SQLQueryStress/RunResult.cs @@ -0,0 +1,46 @@ +using System; +using System.Runtime.Serialization; + +namespace SQLQueryStress +{ + [Serializable] + [DataContract] + public sealed record RunResult + { + [DataMember(Order = 1)] + public Guid TestId { get; init; } + + [DataMember(Order = 2)] + public string StartTime { get; init; } = string.Empty; + + [DataMember(Order = 3)] + public double ElapsedTime { get; init; } + + [DataMember(Order = 4)] + public int Threads { get; init; } + + [DataMember(Order = 5)] + public int Iterations { get; init; } + + [DataMember(Order = 6)] + public int CompletedIterations { get; init; } + + [DataMember(Order = 7)] + public int Delay { get; init; } + + [DataMember(Order = 8)] + public double AvgCpuSeconds { get; init; } + + [DataMember(Order = 9)] + public double AvgActualSeconds { get; init; } + + [DataMember(Order = 10)] + public double AvgClientSeconds { get; init; } + + [DataMember(Order = 11)] + public double AvgLogicalReads { get; init; } + + [DataMember(Order = 12)] + public int ExceptionCount { get; init; } + } +} diff --git a/src/SqlQueryStressCLI/CommandLineOptions.cs b/src/SqlQueryStressCLI/CommandLineOptions.cs index c5e19df..393078c 100644 --- a/src/SqlQueryStressCLI/CommandLineOptions.cs +++ b/src/SqlQueryStressCLI/CommandLineOptions.cs @@ -27,9 +27,12 @@ public class CommandLineOptions - //TODO Implement [Option('r', "results", - HelpText = "Autosave results to specified file")] + HelpText = "Autosave results to the specified file (CSV or JSON)")] public string ResultsAutoSaveFileName { get; set; } = string.Empty; + + [Option('f', "format", + HelpText = "Result output format: csv or json (defaults to file extension)")] + public string ResultFormat { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/src/SqlQueryStressCLI/LoadRunner.cs b/src/SqlQueryStressCLI/LoadRunner.cs index d967135..21568f9 100644 --- a/src/SqlQueryStressCLI/LoadRunner.cs +++ b/src/SqlQueryStressCLI/LoadRunner.cs @@ -76,6 +76,8 @@ public class LoadRunner private System.Timers.Timer timer = new System.Timers.Timer(100); private readonly CommandLineOptions _runParameters; + private readonly ManualResetEventSlim _runCompleted = new ManualResetEventSlim(); + private bool _runSucceeded = true; public LoadRunner(QueryStressSettings settings, CommandLineOptions options) { @@ -83,12 +85,22 @@ public LoadRunner(QueryStressSettings settings, CommandLineOptions options) _runParameters = options; } - public void Run() + public bool Run() { + if (!string.IsNullOrEmpty(_runParameters.ResultsAutoSaveFileName)) + { + var format = GetResultFormat(_runParameters.ResultsAutoSaveFileName); + if (format != "csv" && format != "json") + { + Console.Error.WriteLine($"Unsupported result format '{format}'. Use csv or json."); + return false; + } + } + if (!_settings.MainDbConnectionInfo.TestConnection()) { Console.Error.WriteLine("Invalid connection info"); - return; + return false; } _testStartTime = DateTime.Now; @@ -124,10 +136,8 @@ public void Run() _settings.ParamQuery, _settings.ParamMappings, paramConnectionInfo.ConnectionString, _settings.CommandTimeout, _settings.CollectIoStats, _settings.CollectTimeStats, _settings.ForceDataRetrieval, _settings.KillQueriesOnCancel, _backgroundWorkerCTS, _settings.ParamTypeMappings); - backgroundWorker1.WorkerReportsProgress = true; backgroundWorker1.WorkerSupportsCancellation = true; backgroundWorker1.DoWork += new DoWorkEventHandler(this.backgroundWorker1_DoWork); - backgroundWorker1.ProgressChanged += new ProgressChangedEventHandler(this.backgroundWorker1_ProgressChanged); backgroundWorker1.RunWorkerCompleted += new RunWorkerCompletedEventHandler(this.backgroundWorker1_RunWorkerCompleted); backgroundWorker1.RunWorkerAsync(engine); @@ -137,13 +147,9 @@ public void Run() //timer.Enabled = true; _start = new TimeSpan(DateTime.Now.Ticks); - - while (backgroundWorker1.IsBusy) - { - Thread.Sleep(1000); - } - - Thread.Sleep(2000); + _runCompleted.Wait(); + var succeeded = _runSucceeded; + return succeeded; } private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) @@ -153,13 +159,11 @@ private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e) { - ((LoadEngine)e.Argument).StartLoad(backgroundWorker1, _settings.DelayBetweenQueries); + ((LoadEngine)e.Argument).StartLoad(backgroundWorker1, _settings.DelayBetweenQueries, ProcessQueryOutput); } - private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e) + private void ProcessQueryOutput(LoadEngine.QueryOutput output) { - var output = (LoadEngine.QueryOutput)e.UserState; - _totalIterations++; if (output.LogicalReads > 0) @@ -218,14 +222,29 @@ private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEve private void backgroundWorker1_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) { - UpdateUi(); + try + { + UpdateUi(); + + if (e.Error != null) + { + Console.Error.WriteLine($"Load failed: {e.Error.Message}"); + _runSucceeded = false; + return; + } - ((BackgroundWorker)sender).Dispose(); - _backgroundWorkerCTS?.Dispose(); + _runSucceeded = _totalExceptions == 0; - if (!string.IsNullOrEmpty(_runParameters.ResultsAutoSaveFileName)) + if (!string.IsNullOrEmpty(_runParameters.ResultsAutoSaveFileName)) + { + _runSucceeded = AutoSaveResults(_runParameters.ResultsAutoSaveFileName) && _runSucceeded; + } + } + finally { - AutoSaveResults(_runParameters.ResultsAutoSaveFileName); + ((BackgroundWorker)sender).Dispose(); + _backgroundWorkerCTS?.Dispose(); + _runCompleted.Set(); } } @@ -268,16 +287,75 @@ private void UpdateUi() AnsiConsole.Write(table); } - private void AutoSaveResults(string resultsAutoSaveFileName) + private bool AutoSaveResults(string resultsAutoSaveFileName) { - string extension = Path.GetExtension(resultsAutoSaveFileName).ToUpperInvariant(); - if (extension.Equals(".csv", StringComparison.InvariantCultureIgnoreCase)) + var format = GetResultFormat(resultsAutoSaveFileName); + + switch (format) + { + case "csv": + return ExportBenchMarkToCsvFile(resultsAutoSaveFileName); + case "json": + return ExportBenchmarkToJsonFile(resultsAutoSaveFileName); + default: + Console.Error.WriteLine($"Unsupported result format '{format}'. Use csv or json."); + return false; + } + } + + private string GetResultFormat(string fileName) + { + if (!string.IsNullOrWhiteSpace(_runParameters.ResultFormat)) { - ExportBenchMarkToCsvFile(resultsAutoSaveFileName); + var format = _runParameters.ResultFormat.Trim(); + return format.ToLowerInvariant(); } + + return Path.GetExtension(fileName).TrimStart('.').ToLowerInvariant(); + } + + private bool ExportBenchmarkToJsonFile(string fileName) + { + try + { + var result = BuildRunResult(); + var json = JsonSerializer.WriteFromObject(result); + File.WriteAllText(fileName, json, Encoding.UTF8); + return true; + } + catch (Exception) + { + Console.Error.WriteLine($"There was an error saving the benchmark to '{fileName}', make sure you have write privileges to that path"); + return false; + } + } + + private RunResult BuildRunResult() + { + var elapsed = _testStartTime == default ? 0d : (DateTime.Now - _testStartTime).TotalSeconds; + var avgClientSeconds = _totalIterations == 0 ? 0d : _totalTime / _totalIterations / 1000d; + var avgCpuSeconds = _totalTimeMessages == 0 ? 0d : _totalCpuTime / _totalTimeMessages / 1000d; + var avgActualSeconds = _totalTimeMessages == 0 ? 0d : _totalElapsedTime / _totalTimeMessages / 1000d; + var avgLogicalReads = _totalReadMessages == 0 ? 0d : _totalLogicalReads / _totalReadMessages; + + return new RunResult + { + TestId = _testGuid, + StartTime = _testStartTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + ElapsedTime = elapsed, + Threads = _numThreads, + Iterations = _settings.NumIterations, + CompletedIterations = _totalIterations, + Delay = _settings.DelayBetweenQueries, + AvgCpuSeconds = avgCpuSeconds, + AvgActualSeconds = avgActualSeconds, + AvgClientSeconds = avgClientSeconds, + AvgLogicalReads = avgLogicalReads, + ExceptionCount = _totalExceptions + }; } - private void ExportBenchMarkToCsvFile(string fileName) + private bool ExportBenchMarkToCsvFile(string fileName) { try { @@ -289,11 +367,12 @@ private void ExportBenchMarkToCsvFile(string fileName) WriteBenchmarkCsvHeader(textWriter); } WriteBenchmarkCsvText(textWriter); + return true; } catch (Exception) { - Console.Error.WriteLine("Error While Saving BenchMark", - $"There was an error saving the benchmark to '{fileName}', make sure you have write privileges to that path"); + Console.Error.WriteLine($"There was an error saving the benchmark to '{fileName}', make sure you have write privileges to that path"); + return false; } } @@ -306,10 +385,10 @@ private void WriteBenchmarkCsvText(TextWriter tw) { tw.WriteLine("{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10}", _testGuid, - _testStartTime, + _testStartTime.ToString("O", CultureInfo.InvariantCulture), theTime, _totalIterations, - _runParameters.NumberOfThreads, + _runParameters.NumberOfThreads ?? _numThreads, _settings.DelayBetweenQueries, _totalIterations, cpuTime, diff --git a/src/SqlQueryStressCLI/Program.cs b/src/SqlQueryStressCLI/Program.cs index 68463eb..9b5bd22 100644 --- a/src/SqlQueryStressCLI/Program.cs +++ b/src/SqlQueryStressCLI/Program.cs @@ -11,35 +11,43 @@ namespace SqlQueryStressCLI { class Program { - static void Main(string[] args) + static int Main(string[] args) { var options = new CommandLineOptions(); var parserResult = Parser.Default.ParseArguments(args); - parserResult - .WithParsed(options => Run(options)) - .WithNotParsed(errors => DisplayHelp(parserResult, errors)); + return parserResult.MapResult( + Run, + errors => + { + DisplayHelp(parserResult, errors); + return errors.IsHelp() || errors.IsVersion() ? 0 : 1; + }); } - private static void Run(CommandLineOptions options) + private static int Run(CommandLineOptions options) { if (options.ExtractSample) { ExtractSample(); + return 0; } if (File.Exists(options.SettingsFile)) { var settings = OpenConfigFile(options.SettingsFile); - var runner = new LoadRunner(settings, options); + if (settings == null) + { + return 1; + } - runner.Run(); - } - else - { - Console.Error.WriteLine($"Settings file could not be found, or not specified: {options.SettingsFile}"); + var runner = new LoadRunner(settings, options); + return runner.Run() ? 0 : 1; } + + Console.Error.WriteLine($"Settings file could not be found, or not specified: {options.SettingsFile}"); + return 1; } private static void ExtractSample() diff --git a/src/SqlQueryStressCLI/README.md b/src/SqlQueryStressCLI/README.md index 8bc558c..ceece62 100644 --- a/src/SqlQueryStressCLI/README.md +++ b/src/SqlQueryStressCLI/README.md @@ -32,6 +32,15 @@ sqlstresscmd -x sqlstresscmd -s sample.json -t 1 ``` +To save a machine-readable result file, use `-r`/`--results` and optionally `-f`/`--format`: + +```bash +sqlstresscmd -s sample.json -t 1 -r results.json -f json +sqlstresscmd -s sample.json -t 1 -r results.csv +``` + +The CSV output keeps the existing append-friendly format, while JSON writes a single structured run summary with invariant culture formatting and ISO-8601 timestamps. + ![Sample screenshot](https://raw.githubusercontent.com/ErikEJ/SqlQueryStress/refs/heads/master/src/SqlQueryStressCLI/sample.png) To get help, run diff --git a/src/SqlQueryStressCLI/sqlstresscmd.csproj b/src/SqlQueryStressCLI/sqlstresscmd.csproj index 2896076..9d923dc 100644 --- a/src/SqlQueryStressCLI/sqlstresscmd.csproj +++ b/src/SqlQueryStressCLI/sqlstresscmd.csproj @@ -29,6 +29,7 @@ +