fix(auditlog): advance the served-rowid mark under the write lock so a purge clamp is never overwritten stale-high (review 2026-10-21 follow-up L1)

This commit is contained in:
Joseph Doherty
2026-10-22 09:44:38 -04:00
parent 1f87942ce3
commit 530a096111
3 changed files with 77 additions and 29 deletions
@@ -165,4 +165,38 @@ public class SqliteAuditWriterForwardStateTests : IDisposable
Assert.Equal(0, (await restarted.GetBacklogStatsAsync()).PendingCount);
}
}
/// <summary>
/// Follow-up L1: the served high-water mark is only ever MUTATED under the write lock — the
/// same lock the purge's clamp runs under. Before the fix the read path wrote the mark under
/// its own read lock from a value it sampled before the SELECT, so a purge that clamped the
/// mark in between was overwritten with the stale-high value, re-vouching for reusable rowids.
/// Holding the write lock across a pull must therefore leave the mark untouched until released.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ServedHighWaterMark_IsOnlyAdvancedUnderTheWriteLock()
{
await using var writer = CreateWriter();
await WriteAsync(writer, DateTime.UtcNow.AddMinutes(-5));
var before = writer.MaxServedRowIdForTests;
Task<IReadOnlyList<AuditEvent>> pull;
System.Threading.Monitor.Enter(writer.WriteLockForTests);
try
{
pull = Task.Run(() => writer.ReadPendingSinceAsync(DateTime.MinValue, 100));
// The SELECT itself needs only the read lock; give it ample time to run. A
// synchronous sleep (not an await) so Monitor.Exit runs on the owning thread.
Thread.Sleep(500);
Assert.Equal(before, writer.MaxServedRowIdForTests);
}
finally
{
System.Threading.Monitor.Exit(writer.WriteLockForTests);
}
Assert.Single(await pull.WaitAsync(TimeSpan.FromSeconds(10)));
Assert.True(writer.MaxServedRowIdForTests > before);
}
}