diff --git a/src/SharpCoreDB/DataStructures/HashIndex.cs b/src/SharpCoreDB/DataStructures/HashIndex.cs index b845c756..9b7c9125 100644 --- a/src/SharpCoreDB/DataStructures/HashIndex.cs +++ b/src/SharpCoreDB/DataStructures/HashIndex.cs @@ -335,12 +335,9 @@ internal void RemoveBatchKeys(object?[] keys, long[] positions) if (deferred != null) { - foreach (var kvp in deferred) + foreach (var kvp in deferred.Where(static kvp => kvp.Value is not null)) { - if (kvp.Value is not null) - { - CompactPositionList(kvp.Key, kvp.Value); - } + CompactPositionList(kvp.Key, kvp.Value!); } } } diff --git a/src/SharpCoreDB/DataStructures/Table.Compaction.cs b/src/SharpCoreDB/DataStructures/Table.Compaction.cs index c138ccb8..23983b53 100644 --- a/src/SharpCoreDB/DataStructures/Table.Compaction.cs +++ b/src/SharpCoreDB/DataStructures/Table.Compaction.cs @@ -300,14 +300,10 @@ private void RebuildAllIndexesFromFile() hashIndex.Clear(); } - var loadedHashIndexes = new List(); - foreach (var kvp in hashIndexes) - { - if (loadedIndexes.Contains(kvp.Key)) - { - loadedHashIndexes.Add(kvp.Value); - } - } + var loadedHashIndexes = hashIndexes + .Where(kvp => loadedIndexes.Contains(kvp.Key)) + .Select(kvp => kvp.Value) + .ToList(); foreach (var (position, data) in engine.GetAllRecords(Name)) { diff --git a/src/SharpCoreDB/DataStructures/Table.Serialization.cs b/src/SharpCoreDB/DataStructures/Table.Serialization.cs index 448139dc..7a9380cc 100644 --- a/src/SharpCoreDB/DataStructures/Table.Serialization.cs +++ b/src/SharpCoreDB/DataStructures/Table.Serialization.cs @@ -481,12 +481,9 @@ private void RebuildOverflowArenaFreeListFromDisk() return; } - foreach (var offset in _overflowArena.GetAllOffsets().ToList()) + foreach (var offset in _overflowArena.GetAllOffsets().Where(offset => !live.Contains(offset)).ToList()) { - if (!live.Contains(offset)) - { - _overflowArena.Free(offset); - } + _overflowArena.Free(offset); } } diff --git a/src/SharpCoreDB/Services/Storage.Append.cs b/src/SharpCoreDB/Services/Storage.Append.cs index b6a01898..f53abfaa 100644 --- a/src/SharpCoreDB/Services/Storage.Append.cs +++ b/src/SharpCoreDB/Services/Storage.Append.cs @@ -326,9 +326,9 @@ private void WriteRecordInPlace(string path, long offset, ReadOnlySpan len /// public void CloseWriteHandles() { - foreach (var (key, handle) in _writeHandleCache) + foreach (var (handleKey, handle) in _writeHandleCache) { - if (_writeHandleCache.TryRemove(key, out _)) + if (_writeHandleCache.TryRemove(handleKey, out _)) { handle.Dispose(); } diff --git a/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/FixedWidthBenchmark.cs b/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/FixedWidthBenchmark.cs index 656ea124..94159f6e 100644 --- a/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/FixedWidthBenchmark.cs +++ b/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/FixedWidthBenchmark.cs @@ -21,6 +21,8 @@ internal static class FixedWidthBenchmark private const int VariableUpdates = 1_000; private const int SelectRows = 100_000; private const int SelectRounds = 30; + private const string FixedWidthLabel = "fixed-width"; + private const string LegacyLabel = "legacy "; public static void Run() { @@ -104,7 +106,7 @@ private static void RunGrowingUpdates(DatabaseFactory factory) // overflow arena, which the auto-compaction (1000-update threshold) keeps bounded. foreach (var fixedWidth in new[] { true, false }) { - var label = fixedWidth ? "fixed-width" : "legacy "; + var label = fixedWidth ? FixedWidthLabel : LegacyLabel; var (db, dir) = CreateDatabase(factory, fixedWidth); try { @@ -132,7 +134,7 @@ private static void RunGrowingUpdates(DatabaseFactory factory) finally { (db as IDisposable)?.Dispose(); - try { Directory.Delete(dir, true); } catch { } + try { Directory.Delete(dir, true); } catch { /* best-effort temp-dir cleanup */ } } } } @@ -141,7 +143,7 @@ private static void RunVariableUpdates(DatabaseFactory factory) { foreach (var fixedWidth in new[] { true, false }) { - var label = fixedWidth ? "fixed-width" : "legacy "; + var label = fixedWidth ? FixedWidthLabel : LegacyLabel; var (db, dir) = CreateDatabase(factory, fixedWidth); try { @@ -173,7 +175,7 @@ private static void RunVariableUpdates(DatabaseFactory factory) finally { (db as IDisposable)?.Dispose(); - try { Directory.Delete(dir, true); } catch { } + try { Directory.Delete(dir, true); } catch { /* best-effort temp-dir cleanup */ } } } } @@ -182,7 +184,7 @@ private static void RunSelectWhere(DatabaseFactory factory) { foreach (var fixedWidth in new[] { true, false }) { - var label = fixedWidth ? "fixed-width" : "legacy "; + var label = fixedWidth ? FixedWidthLabel : LegacyLabel; var (db, dir) = CreateDatabase(factory, fixedWidth); try { @@ -220,7 +222,7 @@ private static void RunSelectWhere(DatabaseFactory factory) finally { (db as IDisposable)?.Dispose(); - try { Directory.Delete(dir, true); } catch { } + try { Directory.Delete(dir, true); } catch { /* best-effort temp-dir cleanup */ } } } } @@ -232,7 +234,7 @@ private static void RunInsertThroughput(DatabaseFactory factory) foreach (var fixedWidth in new[] { true, false }) { - var label = fixedWidth ? "fixed-width" : "legacy "; + var label = fixedWidth ? FixedWidthLabel : LegacyLabel; var (db, dir) = CreateDatabase(factory, fixedWidth); try { @@ -272,7 +274,7 @@ private static void RunInsertThroughput(DatabaseFactory factory) finally { (db as IDisposable)?.Dispose(); - try { Directory.Delete(dir, true); } catch { } + try { Directory.Delete(dir, true); } catch { /* best-effort temp-dir cleanup */ } } } } diff --git a/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/Program.cs b/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/Program.cs index 4fd752b0..3aa02747 100644 --- a/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/Program.cs +++ b/tests/benchmarks/SharpCoreDB.Benchmarks.Comparative/Program.cs @@ -27,6 +27,17 @@ private Program() { } // Static utility class - prevent instantiation. const int ReadCount = 10_000; const int UpdateCount = 10_000; const int DeleteCount = 10_000; + const string EngineArgPrefix = "--engine="; + const string EnginePageBased = "pagebased"; + const string BannerTop = "╔══════════════════════════════════════════════════════════╗"; + const string BannerBottom = "╚══════════════════════════════════════════════════════════╝"; + const string ResultsDirName = "results"; + const string BenchDbPassword = "bench123"; + const string EmailColumn = "email"; + const string ScoreColumn = "score"; + const string NameParam = "@name"; + const string CreateDocsIndexSql = "CREATE INDEX idx_docs_name ON docs(name)"; + const string SelectDocsByNameSql = "SELECT * FROM docs WHERE name = @name"; static async Task Main(string[] args) { @@ -56,9 +67,9 @@ static async Task Main(string[] args) // so the PK B-tree fast paths and the recommended usage are measured vs SQLite. if (args.Any(a => a.Equals("--pk", StringComparison.OrdinalIgnoreCase))) { - var engineArgPk = args.FirstOrDefault(a => a.StartsWith("--engine=", StringComparison.OrdinalIgnoreCase)); + var engineArgPk = args.FirstOrDefault(a => a.StartsWith(EngineArgPrefix, StringComparison.OrdinalIgnoreCase)); var engineTypePk = engineArgPk is not null - && engineArgPk.Substring("--engine=".Length).Equals("pagebased", StringComparison.OrdinalIgnoreCase) + && engineArgPk.Substring(EngineArgPrefix.Length).Equals(EnginePageBased, StringComparison.OrdinalIgnoreCase) ? SharpCoreDB.Interfaces.StorageEngineType.PageBased : SharpCoreDB.Interfaces.StorageEngineType.AppendOnly; RunPkComparison(engineTypePk); @@ -70,9 +81,9 @@ static async Task Main(string[] args) // out-of-the-box default path engages the fixed-width fast paths vs SQLite. if (args.Any(a => a.Equals("--pk-default", StringComparison.OrdinalIgnoreCase))) { - var engineArgDefault = args.FirstOrDefault(a => a.StartsWith("--engine=", StringComparison.OrdinalIgnoreCase)); + var engineArgDefault = args.FirstOrDefault(a => a.StartsWith(EngineArgPrefix, StringComparison.OrdinalIgnoreCase)); var engineTypeDefault = engineArgDefault is not null - && engineArgDefault.Substring("--engine=".Length).Equals("pagebased", StringComparison.OrdinalIgnoreCase) + && engineArgDefault.Substring(EngineArgPrefix.Length).Equals(EnginePageBased, StringComparison.OrdinalIgnoreCase) ? SharpCoreDB.Interfaces.StorageEngineType.PageBased : SharpCoreDB.Interfaces.StorageEngineType.AppendOnly; RunPkDefaultComparison(engineTypeDefault); @@ -85,9 +96,9 @@ static async Task Main(string[] args) // SHARPCOREDB_PK_AB_ARM_A / SHARPCOREDB_PK_AB_ARM_B (defaults: pure default vs 'plain'). if (args.Any(a => a.Equals("--pk-ab", StringComparison.OrdinalIgnoreCase))) { - var engineArgAb = args.FirstOrDefault(a => a.StartsWith("--engine=", StringComparison.OrdinalIgnoreCase)); + var engineArgAb = args.FirstOrDefault(a => a.StartsWith(EngineArgPrefix, StringComparison.OrdinalIgnoreCase)); var engineTypeAb = engineArgAb is not null - && engineArgAb.Substring("--engine=".Length).Equals("pagebased", StringComparison.OrdinalIgnoreCase) + && engineArgAb.Substring(EngineArgPrefix.Length).Equals(EnginePageBased, StringComparison.OrdinalIgnoreCase) ? SharpCoreDB.Interfaces.StorageEngineType.PageBased : SharpCoreDB.Interfaces.StorageEngineType.AppendOnly; RunPkAbComparison(engineTypeAb); @@ -96,17 +107,17 @@ static async Task Main(string[] args) // Optional: --engine=appendonly (default) | --engine=pagebased // PageBased is the v2.0 in-place-update engine (WP10-WP13 storage engine roadmap). - var engineArg = args.FirstOrDefault(a => a.StartsWith("--engine=", StringComparison.OrdinalIgnoreCase)); + var engineArg = args.FirstOrDefault(a => a.StartsWith(EngineArgPrefix, StringComparison.OrdinalIgnoreCase)); var engineType = engineArg is not null - && engineArg.Substring("--engine=".Length).Equals("pagebased", StringComparison.OrdinalIgnoreCase) + && engineArg.Substring(EngineArgPrefix.Length).Equals(EnginePageBased, StringComparison.OrdinalIgnoreCase) ? SharpCoreDB.Interfaces.StorageEngineType.PageBased : SharpCoreDB.Interfaces.StorageEngineType.AppendOnly; var engineLabel = engineType == SharpCoreDB.Interfaces.StorageEngineType.PageBased ? "PageBased" : "AppendOnly"; - Console.WriteLine("╔══════════════════════════════════════════════════════════╗"); + Console.WriteLine(BannerTop); Console.WriteLine("║ SharpCoreDB vs BLite vs LiteDB vs SQLite ║"); Console.WriteLine("║ Comparative Document CRUD Benchmark ║"); - Console.WriteLine("╚══════════════════════════════════════════════════════════╝"); + Console.WriteLine(BannerBottom); Console.WriteLine(); Console.WriteLine($"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); Console.WriteLine($"OS: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}"); @@ -159,7 +170,7 @@ static async Task Main(string[] args) PrintComparison(results); // Save JSON - var dir = "results"; + var dir = ResultsDirName; Directory.CreateDirectory(dir); var path = Path.Combine(dir, $"comparative_{DateTime.UtcNow:yyyyMMdd_HHmmss}.json"); File.WriteAllText(path, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true })); @@ -197,7 +208,7 @@ static void RunReadMicroBenchmark() try { db.ExecuteSQL("CREATE TABLE docs (name TEXT NOT NULL, email TEXT, age INTEGER, score REAL, data TEXT)"); - db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + db.ExecuteSQL(CreateDocsIndexSql); for (int batch = 0; batch < rows; batch += 10_000) { @@ -207,9 +218,9 @@ static void RunReadMicroBenchmark() list.Add(new Dictionary { ["name"] = $"User{i}", - ["email"] = $"user{i}@test.com", + [EmailColumn] = $"user{i}@test.com", ["age"] = 20 + i % 60, - ["score"] = i * 0.1, + [ScoreColumn] = i * 0.1, ["data"] = $"payload-{i}", }); } @@ -222,8 +233,8 @@ static void RunReadMicroBenchmark() // Warmup (JIT + index load). for (int i = 0; i < 1000; i++) { - db.ExecuteQuery("SELECT * FROM docs WHERE name = @name", - new Dictionary { ["@name"] = $"User{i}" }); + db.ExecuteQuery(SelectDocsByNameSql, + new Dictionary { [NameParam] = $"User{i}" }); db.FindByIndex("docs", "name", $"User{i}"); } @@ -235,8 +246,8 @@ static void RunReadMicroBenchmark() var sw = Stopwatch.StartNew(); for (int i = 0; i < queries; i++) { - db.ExecuteQuery("SELECT * FROM docs WHERE name = @name", - new Dictionary { ["@name"] = $"User{i}" }); + db.ExecuteQuery(SelectDocsByNameSql, + new Dictionary { [NameParam] = $"User{i}" }); } sw.Stop(); @@ -266,7 +277,7 @@ static void RunReadMicroBenchmark() finally { try { Directory.Delete(dbPath, true); } - catch { } + catch { /* best-effort temp-dir cleanup */ } } } @@ -296,7 +307,7 @@ static void RunInsertMicroBenchmark() using (var db = (SharpCoreDB.Database)factory.Create(sqlPath, "pw", isReadOnly: false, config: config)) { db.ExecuteSQL("CREATE TABLE docs (name TEXT NOT NULL, email TEXT, age INTEGER, score REAL, data TEXT)"); - db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + db.ExecuteSQL(CreateDocsIndexSql); // Build the statements once (outside the timed region — this is caller work, // identical for SQLite in the comparative benchmark). @@ -324,13 +335,13 @@ static void RunInsertMicroBenchmark() sqlTimes[r] = sw.Elapsed.TotalSeconds; } - try { Directory.Delete(sqlPath, true); } catch { } + try { Directory.Delete(sqlPath, true); } catch { /* best-effort temp-dir cleanup */ } var directPath = Path.Combine(Path.GetTempPath(), $"scdb-insert-direct-{Guid.NewGuid()}"); using (var db = (SharpCoreDB.Database)factory.Create(directPath, "pw", isReadOnly: false, config: config)) { db.ExecuteSQL("CREATE TABLE docs (name TEXT NOT NULL, email TEXT, age INTEGER, score REAL, data TEXT)"); - db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + db.ExecuteSQL(CreateDocsIndexSql); var rowBatches = new List>>(); for (int b = 0; b < inserts; b += batch) @@ -341,9 +352,9 @@ static void RunInsertMicroBenchmark() rows.Add(new Dictionary { ["name"] = $"User{i}", - ["email"] = $"user{i}@test.com", + [EmailColumn] = $"user{i}@test.com", ["age"] = 20 + i % 60, - ["score"] = i * 0.1, + [ScoreColumn] = i * 0.1, ["data"] = $"payload-{i}", }); } @@ -361,7 +372,7 @@ static void RunInsertMicroBenchmark() directTimes[r] = sw.Elapsed.TotalSeconds; } - try { Directory.Delete(directPath, true); } catch { } + try { Directory.Delete(directPath, true); } catch { /* best-effort temp-dir cleanup */ } } Array.Sort(sqlTimes); @@ -454,7 +465,7 @@ static BenchmarkResult RunSharpCoreDB(SharpCoreDB.Interfaces.StorageEngineType e using var db = (SharpCoreDB.Database)factory.Create( dbPath: dbPath, - masterPassword: "bench123", + masterPassword: BenchDbPassword, isReadOnly: false, config: config); @@ -467,7 +478,7 @@ data TEXT )"); // Index lookup path used by READ/UPDATE/DELETE in this benchmark - db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + db.ExecuteSQL(CreateDocsIndexSql); // INSERT (batched via InsertBatch API for optimal performance) var sw = Stopwatch.StartNew(); @@ -480,9 +491,9 @@ data TEXT rows.Add(new Dictionary { ["name"] = $"User{i}", - ["email"] = $"user{i}@test.com", + [EmailColumn] = $"user{i}@test.com", ["age"] = 20 + i % 60, - ["score"] = i * 0.1, + [ScoreColumn] = i * 0.1, ["data"] = $"payload-{i}" }); } @@ -498,9 +509,9 @@ data TEXT sw.Restart(); for (int i = 0; i < ReadCount; i++) { - db.ExecuteQuery("SELECT * FROM docs WHERE name = @name", new Dictionary + db.ExecuteQuery(SelectDocsByNameSql, new Dictionary { - ["@name"] = $"User{i}" + [NameParam] = $"User{i}" }); } sw.Stop(); @@ -574,7 +585,7 @@ static BenchmarkResult RunSharpCoreDBDirectApi(SharpCoreDB.Interfaces.StorageEng using var db = (SharpCoreDB.Database)factory.Create( dbPath: dbPath, - masterPassword: "bench123", + masterPassword: BenchDbPassword, isReadOnly: false, config: config); @@ -586,7 +597,7 @@ static BenchmarkResult RunSharpCoreDBDirectApi(SharpCoreDB.Interfaces.StorageEng data TEXT )"); - db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + db.ExecuteSQL(CreateDocsIndexSql); // INSERT (same batched API — no SQL parsing either way) var sw = Stopwatch.StartNew(); @@ -599,9 +610,9 @@ data TEXT rows.Add(new Dictionary { ["name"] = $"User{i}", - ["email"] = $"user{i}@test.com", + [EmailColumn] = $"user{i}@test.com", ["age"] = 20 + i % 60, - ["score"] = i * 0.1, + [ScoreColumn] = i * 0.1, ["data"] = $"payload-{i}" }); } @@ -693,7 +704,7 @@ static BenchmarkResult RunSharpCoreDBStruct(SharpCoreDB.Interfaces.StorageEngine using var db = (SharpCoreDB.Database)factory.Create( dbPath: dbPath, - masterPassword: "bench123", + masterPassword: BenchDbPassword, isReadOnly: false, config: config); @@ -705,7 +716,7 @@ static BenchmarkResult RunSharpCoreDBStruct(SharpCoreDB.Interfaces.StorageEngine data TEXT )"); - db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + db.ExecuteSQL(CreateDocsIndexSql); // INSERT (same batched API as the other SharpCoreDB rows) var sw = Stopwatch.StartNew(); @@ -718,9 +729,9 @@ data TEXT rows.Add(new Dictionary { ["name"] = $"User{i}", - ["email"] = $"user{i}@test.com", + [EmailColumn] = $"user{i}@test.com", ["age"] = 20 + i % 60, - ["score"] = i * 0.1, + [ScoreColumn] = i * 0.1, ["data"] = $"payload-{i}" }); } @@ -736,9 +747,9 @@ data TEXT sw.Restart(); for (int i = 0; i < ReadCount; i++) { - var parameters = new Dictionary { ["@name"] = $"User{i}" }; + var parameters = new Dictionary { [NameParam] = $"User{i}" }; int matched = 0; - foreach (var row in db.ExecuteQueryStruct("SELECT * FROM docs WHERE name = @name", parameters)) + foreach (var row in db.ExecuteQueryStruct(SelectDocsByNameSql, parameters)) { matched++; } @@ -818,7 +829,7 @@ data TEXT { using var cmd = conn.CreateCommand(); cmd.CommandText = "INSERT INTO docs (name, email, age, score, data) VALUES (@name, @email, @age, @score, @payload)"; - var pName = cmd.CreateParameter(); pName.ParameterName = "@name"; pName.Value = $"User{i}"; cmd.Parameters.Add(pName); + var pName = cmd.CreateParameter(); pName.ParameterName = NameParam; pName.Value = $"User{i}"; cmd.Parameters.Add(pName); var pEmail = cmd.CreateParameter(); pEmail.ParameterName = "@email"; pEmail.Value = $"user{i}@test.com"; cmd.Parameters.Add(pEmail); var pAge = cmd.CreateParameter(); pAge.ParameterName = "@age"; pAge.Value = 20 + i % 60; cmd.Parameters.Add(pAge); var pScore = cmd.CreateParameter(); pScore.ParameterName = "@score"; pScore.Value = i * 0.1; cmd.Parameters.Add(pScore); @@ -932,7 +943,7 @@ static BenchmarkResult RunSharpCoreDBPk( using var db = (SharpCoreDB.Database)factory.Create( dbPath: dbPath, - masterPassword: "bench123", + masterPassword: BenchDbPassword, isReadOnly: false, config: config); @@ -944,7 +955,7 @@ static BenchmarkResult RunSharpCoreDBPk( score REAL, data TEXT )"); - db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); + db.ExecuteSQL(CreateDocsIndexSql); // INSERT (batched via InsertBatch with explicit ids, mirroring SQLite's rowid 1..N) var sw = Stopwatch.StartNew(); @@ -958,9 +969,9 @@ data TEXT { ["id"] = i + 1, ["name"] = $"User{i}", - ["email"] = $"user{i}@test.com", + [EmailColumn] = $"user{i}@test.com", ["age"] = 20 + i % 60, - ["score"] = i * 0.1, + [ScoreColumn] = i * 0.1, ["data"] = $"payload-{i}", }); } @@ -1057,10 +1068,10 @@ static double MedianOf(IEnumerable xs, Func r.DeleteTime), }; - median.InsertOpsPerSec = InsertCount > 0 && median.InsertTime > 0 ? (int)(InsertCount / median.InsertTime) : 0; - median.ReadOpsPerSec = ReadCount > 0 && median.ReadTime > 0 ? (int)(ReadCount / median.ReadTime) : 0; - median.UpdateOpsPerSec = UpdateCount > 0 && median.UpdateTime > 0 ? (int)(UpdateCount / median.UpdateTime) : 0; - median.DeleteOpsPerSec = DeleteCount > 0 && median.DeleteTime > 0 ? (int)(DeleteCount / median.DeleteTime) : 0; + median.InsertOpsPerSec = median.InsertTime > 0 ? (int)(InsertCount / median.InsertTime) : 0; + median.ReadOpsPerSec = median.ReadTime > 0 ? (int)(ReadCount / median.ReadTime) : 0; + median.UpdateOpsPerSec = median.UpdateTime > 0 ? (int)(UpdateCount / median.UpdateTime) : 0; + median.DeleteOpsPerSec = median.DeleteTime > 0 ? (int)(DeleteCount / median.DeleteTime) : 0; return median; } @@ -1070,10 +1081,10 @@ static double MedianOf(IEnumerable xs, Func a, IEnumerable ["medianReadRatio_B_over_A"] = Ratio(listA, listB, static r => r.ReadOpsPerSec), }; - var dir = "results"; + var dir = ResultsDirName; Directory.CreateDirectory(dir); var path = Path.Combine(dir, $"pk_ab_{armA}_{armB}_{DateTime.UtcNow:yyyyMMdd_HHmmss}.json"); File.WriteAllText(path, JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true }));