fix(runtime): review findings — recursion-safe run cap, atomic detach counter, summary edge cases, per-row event-log fallback
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user