7b0b9c7365
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
381 lines
14 KiB
C#
381 lines
14 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
|
|
|
|
public class EventLogPurgeServiceTests : IDisposable
|
|
{
|
|
private readonly SiteEventLogger _eventLogger;
|
|
private readonly string _dbPath;
|
|
private readonly SiteEventLogOptions _options;
|
|
|
|
/// <summary>
|
|
/// SiteEventLogging-023: stop flag for the concurrent stress test. Declared as
|
|
/// a <c>volatile</c> field so every writer thread observes the main thread's
|
|
/// `_stop = true` write without depending on JIT/runtime quirks. A plain
|
|
/// <c>bool</c> local would be legal-cached in a register inside the tight
|
|
/// <c>while (!_stop)</c> loop under release-mode optimisation.
|
|
/// </summary>
|
|
private volatile bool _stop;
|
|
|
|
public EventLogPurgeServiceTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"test_purge_{Guid.NewGuid()}.db");
|
|
_options = new SiteEventLogOptions
|
|
{
|
|
DatabasePath = _dbPath,
|
|
RetentionDays = 30,
|
|
MaxStorageMb = 1024
|
|
};
|
|
_eventLogger = new SiteEventLogger(
|
|
Options.Create(_options),
|
|
NullLogger<SiteEventLogger>.Instance);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_eventLogger.Dispose();
|
|
if (File.Exists(_dbPath)) File.Delete(_dbPath);
|
|
}
|
|
|
|
private EventLogPurgeService CreatePurgeService(
|
|
SiteEventLogOptions? optionsOverride = null,
|
|
SiteEventLogActiveNodeCheck? isActiveNode = null)
|
|
{
|
|
var opts = optionsOverride ?? _options;
|
|
return new EventLogPurgeService(
|
|
_eventLogger,
|
|
Options.Create(opts),
|
|
NullLogger<EventLogPurgeService>.Instance,
|
|
isActiveNode);
|
|
}
|
|
|
|
private void InsertEventWithTimestamp(DateTimeOffset timestamp)
|
|
{
|
|
_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()
|
|
{
|
|
return _eventLogger.WithConnection(connection =>
|
|
{
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
|
|
return (long)cmd.ExecuteScalar()!;
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void PurgeByRetention_DeletesOldEvents()
|
|
{
|
|
// Insert an old event (31 days ago) and a recent one
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
|
|
|
|
var purge = CreatePurgeService();
|
|
purge.RunPurge();
|
|
|
|
Assert.Equal(1, GetEventCount());
|
|
}
|
|
|
|
[Fact]
|
|
public void PurgeByRetention_KeepsRecentEvents()
|
|
{
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-29));
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-1));
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
|
|
|
|
var purge = CreatePurgeService();
|
|
purge.RunPurge();
|
|
|
|
Assert.Equal(3, GetEventCount());
|
|
}
|
|
|
|
[Fact]
|
|
public void PurgeByStorageCap_DeletesOldestWhenOverCap()
|
|
{
|
|
// Insert enough events to have some data
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
// Set an artificially small cap to trigger purge
|
|
var smallCapOptions = new SiteEventLogOptions
|
|
{
|
|
DatabasePath = _dbPath,
|
|
RetentionDays = 30,
|
|
MaxStorageMb = 0 // 0 MB cap forces purge
|
|
};
|
|
|
|
var purge = CreatePurgeService(smallCapOptions);
|
|
purge.RunPurge();
|
|
|
|
// All events should be purged since cap is 0
|
|
Assert.Equal(0, GetEventCount());
|
|
}
|
|
|
|
[Fact]
|
|
public void GetDatabaseSizeBytes_ReturnsPositiveValue()
|
|
{
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
|
|
|
|
var purge = CreatePurgeService();
|
|
var size = purge.GetDatabaseSizeBytes();
|
|
|
|
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 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()
|
|
{
|
|
// 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>();
|
|
// SiteEventLogging-023: must be volatile so the writer threads observe the
|
|
// main thread's `stop = true` flip after the purge task completes. Without
|
|
// it, a release-mode JIT is permitted to cache the `stop = false` read in
|
|
// a register inside the tight `while (!stop)` loop and never see the flip,
|
|
// causing the writer tasks to hang past xUnit's per-test timeout instead
|
|
// of asserting `Empty(exceptions)`.
|
|
_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);
|
|
}
|
|
|
|
// ── SiteEventLogging-019: purge runs only on the active node ──
|
|
|
|
[Fact]
|
|
public void RunPurge_OnStandbyNode_SkipsAllWork()
|
|
{
|
|
// SiteEventLogging-019: per design, the daily purge runs on the active
|
|
// node only. The standby's local SQLite receives no writes, so purging
|
|
// there is unnecessary; we gate the purge tick on the injected
|
|
// active-node check and early-exit when it returns false. The row
|
|
// inserted here is well past retention, so a real purge would delete
|
|
// it — the standby gate must leave it intact.
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
|
|
Assert.Equal(1, GetEventCount());
|
|
|
|
var purge = CreatePurgeService(isActiveNode: () => false);
|
|
purge.RunPurge();
|
|
|
|
Assert.Equal(1, GetEventCount());
|
|
}
|
|
|
|
[Fact]
|
|
public void RunPurge_OnActiveNode_RunsTheRetentionPurge()
|
|
{
|
|
// SiteEventLogging-019: when the active-node check returns true the
|
|
// service runs the purge as before. Pinned alongside the standby case
|
|
// so a future regression that inverts the gate is caught.
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
|
|
|
|
var purge = CreatePurgeService(isActiveNode: () => true);
|
|
purge.RunPurge();
|
|
|
|
Assert.Equal(1, GetEventCount());
|
|
}
|
|
|
|
[Fact]
|
|
public void RunPurge_WithNullCheck_FallsBackToRunning()
|
|
{
|
|
// SiteEventLogging-019: when no active-node check is supplied (the
|
|
// default for non-clustered hosts and pre-existing tests), the service
|
|
// preserves the pre-fix "run on every tick" behaviour rather than
|
|
// silently skipping every tick. Backward compatibility guard.
|
|
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
|
|
|
|
var purge = CreatePurgeService(isActiveNode: null);
|
|
purge.RunPurge();
|
|
|
|
Assert.Equal(0, GetEventCount());
|
|
}
|
|
}
|