fix(site-event-logging): resolve SiteEventLogging-001/002/003, re-triage 004 — incremental auto_vacuum, cap-purge guard, write-lock connection access
This commit is contained in:
@@ -40,20 +40,26 @@ public class EventLogPurgeServiceTests : IDisposable
|
||||
|
||||
private void InsertEventWithTimestamp(DateTimeOffset timestamp)
|
||||
{
|
||||
using var cmd = _eventLogger.Connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO site_events (timestamp, event_type, severity, source, message)
|
||||
VALUES ($ts, 'script', 'Info', 'Test', 'Test message')
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
|
||||
cmd.ExecuteNonQuery();
|
||||
_eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO site_events (timestamp, event_type, severity, source, message)
|
||||
VALUES ($ts, 'script', 'Info', 'Test', 'Test message')
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
|
||||
cmd.ExecuteNonQuery();
|
||||
});
|
||||
}
|
||||
|
||||
private long GetEventCount()
|
||||
{
|
||||
using var cmd = _eventLogger.Connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
|
||||
return (long)cmd.ExecuteScalar()!;
|
||||
return _eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
|
||||
return (long)cmd.ExecuteScalar()!;
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,4 +122,155 @@ public class EventLogPurgeServiceTests : IDisposable
|
||||
|
||||
Assert.True(size > 0);
|
||||
}
|
||||
|
||||
private void InsertBulkEvents(int count)
|
||||
{
|
||||
// Each event carries a sizeable details payload so the database grows
|
||||
// measurably and the storage cap can be exercised against a realistic file.
|
||||
var details = new string('x', 2000);
|
||||
_eventLogger.WithConnection(connection =>
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO site_events (timestamp, event_type, severity, source, message, details)
|
||||
VALUES ($ts, 'script', 'Info', 'Test', 'Bulk event', $details)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("$details", details);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private long MinEventId()
|
||||
{
|
||||
return _eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT MIN(id) FROM site_events";
|
||||
var result = cmd.ExecuteScalar();
|
||||
return result is long l ? l : 0;
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PurgeByStorageCap_StopsWhenUnderCap_DoesNotEmptyTable()
|
||||
{
|
||||
// Regression test for SiteEventLogging-001 / -002:
|
||||
// a realistic non-zero cap must trim the oldest events to the budget,
|
||||
// not delete the entire table.
|
||||
InsertBulkEvents(3000);
|
||||
|
||||
var purge = CreatePurgeService();
|
||||
var totalSize = purge.GetDatabaseSizeBytes();
|
||||
|
||||
// Cap at roughly half the current database size — purge must keep some rows.
|
||||
var capBytes = totalSize / 2;
|
||||
var capOptions = new SiteEventLogOptions
|
||||
{
|
||||
DatabasePath = _dbPath,
|
||||
RetentionDays = 30,
|
||||
MaxStorageMb = (int)Math.Max(1, capBytes / (1024 * 1024))
|
||||
};
|
||||
|
||||
var cappedPurge = CreatePurgeService(capOptions);
|
||||
cappedPurge.RunPurge();
|
||||
|
||||
var remaining = GetEventCount();
|
||||
Assert.True(remaining > 0, "Storage-cap purge must not delete the entire table.");
|
||||
Assert.True(remaining < 3000, "Storage-cap purge must remove some events when over cap.");
|
||||
|
||||
// The database must actually be back under the cap after purge.
|
||||
var finalSize = cappedPurge.GetDatabaseSizeBytes();
|
||||
var finalCapBytes = (long)capOptions.MaxStorageMb * 1024 * 1024;
|
||||
Assert.True(finalSize <= finalCapBytes,
|
||||
$"Database size {finalSize} must be at or below cap {finalCapBytes} after purge.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PurgeByStorageCap_RemovesOldestEventsFirst()
|
||||
{
|
||||
// Regression test for SiteEventLogging-002: only the oldest events
|
||||
// (lowest ids) should be removed when trimming to the cap.
|
||||
InsertBulkEvents(3000);
|
||||
|
||||
var purge = CreatePurgeService();
|
||||
var totalSize = purge.GetDatabaseSizeBytes();
|
||||
|
||||
var capOptions = new SiteEventLogOptions
|
||||
{
|
||||
DatabasePath = _dbPath,
|
||||
RetentionDays = 30,
|
||||
MaxStorageMb = (int)Math.Max(1, (totalSize / 2) / (1024 * 1024))
|
||||
};
|
||||
|
||||
var minIdBefore = MinEventId();
|
||||
var cappedPurge = CreatePurgeService(capOptions);
|
||||
cappedPurge.RunPurge();
|
||||
var minIdAfter = MinEventId();
|
||||
|
||||
// The surviving rows must be the newest ones — minimum id has advanced.
|
||||
Assert.True(minIdAfter > minIdBefore,
|
||||
"Oldest events (lowest ids) must be purged first.");
|
||||
|
||||
// The newest event (highest id) must still be present.
|
||||
var newestPresent = _eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM site_events WHERE id = 3000";
|
||||
return (long)cmd.ExecuteScalar()!;
|
||||
});
|
||||
Assert.Equal(1L, newestPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeByStorageCap_ConcurrentWritesDoNotCorruptConnection()
|
||||
{
|
||||
// Regression test for SiteEventLogging-003: purge running on a background
|
||||
// thread while events are recorded on other threads must not throw
|
||||
// "DataReader already open" / "connection busy" from a shared connection.
|
||||
InsertBulkEvents(2000);
|
||||
|
||||
var purge = CreatePurgeService();
|
||||
var totalSize = purge.GetDatabaseSizeBytes();
|
||||
var capOptions = new SiteEventLogOptions
|
||||
{
|
||||
DatabasePath = _dbPath,
|
||||
RetentionDays = 30,
|
||||
MaxStorageMb = (int)Math.Max(1, (totalSize / 2) / (1024 * 1024))
|
||||
};
|
||||
|
||||
var exceptions = new System.Collections.Concurrent.ConcurrentBag<Exception>();
|
||||
var stop = false;
|
||||
|
||||
var purgeTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = CreatePurgeService(capOptions);
|
||||
for (int i = 0; i < 20; i++) p.RunPurge();
|
||||
}
|
||||
catch (Exception ex) { exceptions.Add(ex); }
|
||||
});
|
||||
|
||||
var writeTasks = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!stop)
|
||||
{
|
||||
await _eventLogger.LogEventAsync("script", "Info", null, "Concurrent", "Concurrent write");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { exceptions.Add(ex); }
|
||||
})).ToArray();
|
||||
|
||||
await purgeTask;
|
||||
stop = true;
|
||||
await Task.WhenAll(writeTasks);
|
||||
|
||||
Assert.Empty(exceptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,17 +256,20 @@ public class EventLogQueryServiceTests : IDisposable
|
||||
|
||||
private void InsertEventAt(DateTimeOffset timestamp, string eventType, string severity, string? instanceId, string source, string message)
|
||||
{
|
||||
using var cmd = _eventLogger.Connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO site_events (timestamp, event_type, severity, instance_id, source, message)
|
||||
VALUES ($ts, $et, $sev, $iid, $src, $msg)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("$et", eventType);
|
||||
cmd.Parameters.AddWithValue("$sev", severity);
|
||||
cmd.Parameters.AddWithValue("$iid", (object?)instanceId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$src", source);
|
||||
cmd.Parameters.AddWithValue("$msg", message);
|
||||
cmd.ExecuteNonQuery();
|
||||
_eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO site_events (timestamp, event_type, severity, instance_id, source, message)
|
||||
VALUES ($ts, $et, $sev, $iid, $src, $msg)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("$et", eventType);
|
||||
cmd.Parameters.AddWithValue("$sev", severity);
|
||||
cmd.Parameters.AddWithValue("$iid", (object?)instanceId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$src", source);
|
||||
cmd.Parameters.AddWithValue("$msg", message);
|
||||
cmd.ExecuteNonQuery();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user