fix(runtime): review findings — recursion-safe run cap, atomic detach counter, summary edge cases, per-row event-log fallback

This commit is contained in:
Joseph Doherty
2026-08-14 23:42:29 -04:00
parent b1de9dfdd4
commit 950c54c5fc
10 changed files with 626 additions and 42 deletions
@@ -155,4 +155,135 @@ public class SiteEventLoggerBatchingTests : IDisposable
Assert.True(_logger.FailedWriteCount >= burstCount,
$"Expected FailedWriteCount to account for all {burstCount} events in the rolled-back batch, got {_logger.FailedWriteCount}.");
}
[Fact]
public async Task ProcessWriteQueue_PoisonRowMidBatch_OtherRowsLand_AndOnlyThePoisonRowIsCountedFailed()
{
// A batch is all-or-nothing, so a SINGLE unwritable row used to take its ~255 innocent
// neighbours down with it — every Task faulted and FailedWriteCount (a health metric)
// inflated by the batch size instead of by the number of bad rows. The rollback is now
// followed by a row-by-row retry: one poison row must cost exactly one row.
_logger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TRIGGER reject_poison BEFORE INSERT ON site_events
WHEN NEW.message = 'poison'
BEGIN SELECT RAISE(ABORT, 'poison row rejected'); END;
""";
cmd.ExecuteNonQuery();
});
// Hold the writer off the connection so the whole burst is queued together and lands
// in one batch — the poison row is therefore mid-batch, not alone in its own.
var busyStarted = new ManualResetEventSlim(false);
var releaseBusy = new ManualResetEventSlim(false);
var busyThread = new Thread(() =>
{
_logger.WithConnection(_ =>
{
busyStarted.Set();
releaseBusy.Wait(TimeSpan.FromSeconds(10));
});
});
busyThread.Start();
Assert.True(busyStarted.Wait(TimeSpan.FromSeconds(5)), "Busy thread did not start.");
const int burstCount = 10;
const int poisonIndex = 5;
var tasks = new List<Task>(burstCount);
for (var i = 0; i < burstCount; i++)
{
tasks.Add(_logger.LogEventAsync(
"script", "Info", null, "Poison", i == poisonIndex ? "poison" : $"event {i}"));
}
releaseBusy.Set();
busyThread.Join(TimeSpan.FromSeconds(10));
// Every non-poison event still completes successfully…
for (var i = 0; i < burstCount; i++)
{
if (i == poisonIndex) continue;
await tasks[i].WaitAsync(TimeSpan.FromSeconds(15));
}
// …and only the poison event's own caller sees a failure.
await Assert.ThrowsAnyAsync<Exception>(() => tasks[poisonIndex])
.WaitAsync(TimeSpan.FromSeconds(15));
Assert.Equal(1, _logger.FailedWriteCount);
var landed = _logger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM site_events WHERE source = 'Poison'";
return (long)cmd.ExecuteScalar()!;
});
Assert.Equal(burstCount - 1, landed);
}
[Fact]
public async Task Dispose_PersistsEventsStillQueuedAtShutdown()
{
// Graceful shutdown must DRAIN the queue, not discard it. _disposed is what makes
// WithConnection refuse a write, so flipping it before waiting for the writer loop
// would fault every remaining batch with ObjectDisposedException — losing exactly the
// shutdown-time diagnostics most worth keeping. It flips only after the drain.
var dbPath = Path.Combine(Path.GetTempPath(), $"test_dispose_{Guid.NewGuid()}.db");
var localDb = TestLocalDb.Create(dbPath);
try
{
var logger = new SiteEventLogger(
Options.Create(new SiteEventLogOptions { DatabasePath = dbPath }),
NullLogger<SiteEventLogger>.Instance,
localDb.Db);
// Block the writer so the whole burst is provably still queued when Dispose starts.
var busyStarted = new ManualResetEventSlim(false);
var releaseBusy = new ManualResetEventSlim(false);
var busyThread = new Thread(() =>
{
logger.WithConnection(_ =>
{
busyStarted.Set();
releaseBusy.Wait(TimeSpan.FromSeconds(10));
});
});
busyThread.Start();
Assert.True(busyStarted.Wait(TimeSpan.FromSeconds(5)), "Busy thread did not start.");
// More than WriteBatchSize, so the drain spans several batches and a mid-drain
// _disposed flip would be caught even if the first batch slipped through.
const int burstCount = 500;
var tasks = new List<Task>(burstCount);
for (var i = 0; i < burstCount; i++)
{
tasks.Add(logger.LogEventAsync("script", "Info", null, "Shutdown", $"event {i}"));
}
// Dispose blocks on the write lock the busy thread holds; release it once Dispose
// is under way, so the drain and the shutdown genuinely overlap.
var dispose = Task.Run(logger.Dispose);
Thread.Sleep(250);
releaseBusy.Set();
busyThread.Join(TimeSpan.FromSeconds(10));
await dispose.WaitAsync(TimeSpan.FromSeconds(30));
// Every caller that got an accepted enqueue before shutdown sees a completed write…
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(15));
Assert.Equal(0, logger.FailedWriteCount);
// …and the rows are really in the file, read back through a fresh connection.
using var connection = localDb.Db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM site_events WHERE source = 'Shutdown'";
Assert.Equal(burstCount, (long)cmd.ExecuteScalar()!);
}
finally
{
localDb.Dispose();
TestLocalDb.DeleteFiles(dbPath);
}
}
}