test(localdb): sustained concurrent write load through pooled connections
Closes criterion 5 of ScadaBridge/docs/known-issues/2026-07-20-localdb-disk-io- error-under-load.md §9: Phase 1 shipped only correctness and convergence tests, so nothing exercised many pooled per-operation connections writing a REGISTERED table (capture trigger + zb_hlc_next UDF) concurrently — which is exactly the shape the ScadaBridge site nodes run under load. 8 writers x 250 inserts, one fresh pooled connection per operation, with concurrent readers churning the pool and the WAL index throughout. Asserts both that every write lands and that the oplog captured each exactly once, so a trigger that dropped or double-counted under contention fails it. Written during the 2026-07-20 disk-I/O incident investigation and left uncommitted; the incident itself was root-caused to host-side sqlite3 access on a bind-mounted WAL database, not a library defect. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.LocalDb.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.LocalDb.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user