diff --git a/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/ConcurrentWriteLoadTests.cs b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/ConcurrentWriteLoadTests.cs new file mode 100644 index 0000000..8a0a4d0 --- /dev/null +++ b/ZB.MOM.WW.LocalDb/tests/ZB.MOM.WW.LocalDb.Tests/ConcurrentWriteLoadTests.cs @@ -0,0 +1,77 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.LocalDb.Internal; + +namespace ZB.MOM.WW.LocalDb.Tests; + +/// +/// Sustained concurrent write pressure against a real on-disk LocalDb file — +/// the gap named by criterion 5 of +/// ScadaBridge/docs/known-issues/2026-07-20-localdb-disk-io-error-under-load.md §9: +/// Phase 1 shipped only correctness/convergence tests, so nothing exercised +/// many pooled per-operation connections writing a registered (trigger + +/// zb_hlc_next UDF) table at once, which is exactly the shape the ScadaBridge +/// site nodes run under load. +/// +public sealed class ConcurrentWriteLoadTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db"); + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + { + var f = _path + suffix; + if (File.Exists(f)) + File.Delete(f); + } + } + + [Fact] + public async Task Sustained_Concurrent_Writes_Through_Pooled_Connections_All_Succeed() + { + const int Writers = 8; + const int PerWriter = 250; + + using var db = new SqliteLocalDb(new LocalDbOptions { Path = _path }); + await db.ExecuteAsync( + "CREATE TABLE load_t (id TEXT PRIMARY KEY, writer INTEGER NOT NULL, seq INTEGER NOT NULL, payload TEXT NOT NULL)"); + db.RegisterReplicated("load_t"); + + var payload = new string('x', 256); + + // Concurrent readers churn pooled connections + the WAL index while the + // writers run; WAL readers must never fail alongside writers. + using var stopReaders = new CancellationTokenSource(); + var reader = Task.Run(async () => + { + while (!stopReaders.IsCancellationRequested) + { + await db.QueryAsync("SELECT COUNT(*) FROM load_t", r => r.GetInt64(0)); + } + }); + + // One fresh pooled connection per operation (the production pattern), + // every insert firing the capture trigger + zb_hlc_next UDF. + var writers = Enumerable.Range(0, Writers).Select(w => Task.Run(async () => + { + for (var i = 0; i < PerWriter; i++) + { + await db.ExecuteAsync( + "INSERT INTO load_t (id, writer, seq, payload) VALUES (@id, @writer, @seq, @payload)", + new { id = $"{w}-{i}", writer = w, seq = i, payload }); + } + })).ToArray(); + + await Task.WhenAll(writers); // faults on the first failed write + stopReaders.Cancel(); + await reader; + + var rows = (await db.QueryAsync("SELECT COUNT(*) FROM load_t", r => r.GetInt64(0)))[0]; + Assert.Equal((long)Writers * PerWriter, rows); + + // Every write must have been captured to the oplog exactly once. + var oplog = (await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", r => r.GetInt64(0)))[0]; + Assert.Equal((long)Writers * PerWriter, oplog); + } +}