fix(site-event-logging): resolve SiteEventLogging-012..014 — fault dropped-event tasks, escape LIKE wildcards, re-triage startup-purge finding (Won't Fix)
This commit is contained in:
@@ -64,15 +64,19 @@ public class EventLogCoverageTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogEventAsync_AfterDispose_CompletesWithoutThrowing()
|
||||
public async Task LogEventAsync_AfterDispose_FaultsTask_NotReportsSuccess()
|
||||
{
|
||||
// Logging after disposal must not throw or hang — the event is simply
|
||||
// dropped because the background writer has been completed.
|
||||
// SiteEventLogging-012: when the logger has been disposed the event cannot
|
||||
// be persisted. The returned Task must FAULT (not complete successfully) so
|
||||
// an awaiting caller can distinguish a dropped audit event from a written
|
||||
// one. Per the XML doc contract, the Task "faults if the write fails".
|
||||
var logger = NewLogger();
|
||||
logger.Dispose();
|
||||
|
||||
await logger.LogEventAsync("script", "Info", null, "Source", "After dispose")
|
||||
.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var task = logger.LogEventAsync("script", "Info", null, "Source", "After dispose");
|
||||
|
||||
await Assert.ThrowsAsync<ObjectDisposedException>(
|
||||
() => task.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -83,4 +87,48 @@ public class EventLogCoverageTests : IDisposable
|
||||
logger.Dispose();
|
||||
logger.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogEventAsync_EnqueuedThenDisposed_FaultsTask_WhenWriteCannotComplete()
|
||||
{
|
||||
// SiteEventLogging-012, second path: an event enqueued onto the background
|
||||
// writer just before disposal must NOT be reported as persisted if the
|
||||
// writer's WithConnection returns false (logger disposed mid-drain). We
|
||||
// flood the queue and dispose immediately; any event whose write did not
|
||||
// actually run must have a faulted Task, never a successful one.
|
||||
var logger = NewLogger();
|
||||
|
||||
var tasks = new List<Task>();
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
tasks.Add(logger.LogEventAsync("script", "Info", null, "Source", $"event-{i}"));
|
||||
}
|
||||
|
||||
logger.Dispose();
|
||||
|
||||
// Every task must reach a terminal state. None may be left as a successful
|
||||
// completion for an event the writer never persisted: a task is either
|
||||
// RanToCompletion (genuinely written before the connection closed) or
|
||||
// Faulted (could not be persisted). Count persisted vs faulted and assert
|
||||
// the persisted count matches the actual row count.
|
||||
await Task.WhenAll(tasks.Select(t => t.ContinueWith(_ => { })));
|
||||
|
||||
var succeeded = tasks.Count(t => t.Status == TaskStatus.RanToCompletion);
|
||||
var faulted = tasks.Count(t => t.IsFaulted);
|
||||
Assert.Equal(tasks.Count, succeeded + faulted);
|
||||
|
||||
long rowCount;
|
||||
using (var conn = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={_dbPath}"))
|
||||
{
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
|
||||
rowCount = (long)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
// A successfully-completed Task must correspond to a row that was actually
|
||||
// written. If the disposed-mid-drain path falsely reported success, the
|
||||
// success count would exceed the row count.
|
||||
Assert.Equal(rowCount, succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,42 @@ public class EventLogPurgeServiceTests : IDisposable
|
||||
Assert.Equal(1L, newestPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotBlock_OnTheInitialPurge()
|
||||
{
|
||||
// SiteEventLogging-014 (re-triaged): on .NET 8+ BackgroundService runs
|
||||
// ExecuteAsync on a thread-pool thread — the synchronous prelude (the
|
||||
// initial RunPurge()) does NOT execute on the host startup thread, so
|
||||
// StartAsync returns promptly and host startup / the /health/ready gate is
|
||||
// not blocked even by a large initial purge. This test pins that behaviour:
|
||||
// StartAsync returns fast, and the initial purge still happens shortly
|
||||
// afterwards on the background scheduler.
|
||||
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
|
||||
Assert.Equal(1, GetEventCount());
|
||||
|
||||
var purge = CreatePurgeService();
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await purge.StartAsync(cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.ElapsedMilliseconds < 1000,
|
||||
$"StartAsync blocked for {sw.ElapsedMilliseconds} ms — the initial purge " +
|
||||
"must not run on the host startup thread.");
|
||||
|
||||
// The initial purge still runs on the background scheduler.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (GetEventCount() != 0 && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25);
|
||||
}
|
||||
Assert.Equal(0, GetEventCount());
|
||||
|
||||
await cts.CancelAsync();
|
||||
await purge.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeByStorageCap_ConcurrentWritesDoNotCorruptConnection()
|
||||
{
|
||||
|
||||
@@ -254,6 +254,50 @@ public class EventLogQueryServiceTests : IDisposable
|
||||
Assert.Equal("{\"stack\":\"trace\"}", entry.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_KeywordSearch_TreatsUnderscoreAsLiteral_NotWildcard()
|
||||
{
|
||||
// SiteEventLogging-013: a SQL LIKE '_' matches any single character. A
|
||||
// keyword search for a literal underscore (common in identifiers such as
|
||||
// "store_and_forward") must NOT match strings that merely have any
|
||||
// character in that position.
|
||||
await _eventLogger.LogEventAsync("script", "Info", null, "store_and_forward", "Buffer queued");
|
||||
await _eventLogger.LogEventAsync("script", "Info", null, "storeXandYforward", "Other event");
|
||||
|
||||
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "store_and_forward"));
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Single(response.Entries);
|
||||
Assert.Equal("store_and_forward", response.Entries[0].Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_KeywordSearch_TreatsPercentAsLiteral_NotWildcard()
|
||||
{
|
||||
// SiteEventLogging-013: a SQL LIKE '%' matches any run of characters. A
|
||||
// keyword search containing a literal '%' must match only the literal.
|
||||
await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90% utilisation");
|
||||
await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90 then 0 utilisation");
|
||||
|
||||
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "90% utilisation"));
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Single(response.Entries);
|
||||
Assert.Contains("90% utilisation", response.Entries[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_KeywordSearch_StillMatchesPlainSubstring()
|
||||
{
|
||||
// Escaping wildcards must not break ordinary substring search.
|
||||
await SeedEvents();
|
||||
|
||||
var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout"));
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Single(response.Entries);
|
||||
}
|
||||
|
||||
private void InsertEventAt(DateTimeOffset timestamp, string eventType, string severity, string? instanceId, string source, string message)
|
||||
{
|
||||
_eventLogger.WithConnection(connection =>
|
||||
|
||||
Reference in New Issue
Block a user