feat(localdb): move site_events to application-minted GUID ids

Tasks 2 + 5a-writer + 5b of the LocalDb Phase 1 plan, landed together because
they are one indivisible change: the schema, the writer that fills it, and every
consumer that assumed the old id semantics.

WHY the id changes. Site pairs will replicate site_events with last-writer-wins on
the primary key. With INTEGER PRIMARY KEY AUTOINCREMENT both nodes independently
mint 1, 2, 3... for unrelated events, so sync would treat them as the same row and
silently overwrite. A GUID makes the event log a pure union across the pair.

Task 2 - schema extracted so the Host's AddZbLocalDb onReady can create the tables
before RegisterReplicated installs capture triggers (pre-registration rows are
never captured):
  - new OperationTrackingSchema.Apply / SiteEventLogSchema.Apply, plain
    Microsoft.Data.Sqlite, no LocalDb dependency
  - both stores delegate their InitializeSchema to them (idempotent, so a
    directly-constructed store still works)
  - OperationTracking is unchanged - it already had a TEXT PK and replicates as-is

Task 5a - SiteEventLogger mints Guid.NewGuid("N") per event and inserts it.

Task 5b - three consumers assumed a monotonic integer id. All three move to
timestamp ordering; leaving any one behind would be a live bug:
  - EventLogQueryService: "id > $afterId" would return an ARBITRARY subset of a
    GUID-keyed table and SILENTLY DROP ROWS from page-through. Now a composite
    (timestamp, id) keyset cursor with an opaque string token; timestamps are not
    unique, so id is the tie-break that guarantees exactly-once paging.
  - EventLogPurgeService: "ORDER BY id ASC LIMIT 1000" would delete a RANDOM batch
    instead of the oldest. Now orders by timestamp.
  - EventLogEntry.Id and both ContinuationTokens: long -> string.

WIRE COMPATIBILITY. Those DTOs cross the site<->central Akka boundary
(SiteCommunicationActor -> CommunicationService -> ManagementActor / CentralUI).
No rolling-upgrade shim is needed because both sides ship in the same deployable
and the rig redeploys as a unit. Checked: no Akka serializer binding pins these
types by name. A stale numeric token degrades to "start from the beginning"
(a visible repeat) rather than throwing or losing rows.

Tests: the two that encoded the old semantics were rewritten to guard the new
invariant rather than deleted - uniqueness instead of monotonicity, and
oldest-purged-first keyed on timestamp. That second test also exposed a latent
weakness: the bulk seed stamped every row with the same UtcNow, so "oldest" was
never actually well-defined; rows now get distinct increasing timestamps, kept
inside the retention window so the retention purge does not eat them first.

Verified:
  dotnet build ZB.MOM.WW.ScadaBridge.slnx -> 0 Error(s), 0 Warning(s)
  SiteEventLogging.Tests -> 70 passed (12 new schema tests, red-first)
  CentralUI.Tests        -> 925 passed
  Commons.Tests          -> 684 passed
  SiteRuntime.Tests      -> 529 passed, 1 pre-existing flaky failure
                            (InstanceActorChildAttributeRaceTests - passes 3/3 in
                            isolation with and without this change)

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-19 03:15:17 -04:00
parent f056b67e9a
commit 727fa48cba
14 changed files with 659 additions and 147 deletions
@@ -56,9 +56,10 @@ public class EventLogPurgeServiceTests : IDisposable
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (timestamp, event_type, severity, source, message)
VALUES ($ts, 'script', 'Info', 'Test', 'Test message')
INSERT INTO site_events (id, timestamp, event_type, severity, source, message)
VALUES ($id, $ts, 'script', 'Info', 'Test', 'Test message')
""";
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N"));
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
cmd.ExecuteNonQuery();
});
@@ -135,6 +136,29 @@ public class EventLogPurgeServiceTests : IDisposable
Assert.True(size > 0);
}
/// <summary>
/// Base instant for bulk-seeded events: one day ago, evaluated once so a run is
/// self-consistent.
/// <para>
/// Must stay INSIDE the retention window (<c>RetentionDays</c>, default 30).
/// <see cref="EventLogPurgeService.RunPurge"/> applies the retention purge before
/// the storage-cap purge, so a fixed calendar date in the past would be deleted
/// wholesale as stale and the storage-cap assertions would silently test an empty
/// table rather than cap-trimming behaviour.
/// </para>
/// </summary>
private static readonly DateTimeOffset BulkSeedStart =
DateTimeOffset.UtcNow.AddDays(-1);
/// <summary>
/// Timestamp of the i-th bulk-seeded event. Each row is one second newer than the
/// last, so "oldest" is unambiguous — the storage-cap purge orders by timestamp,
/// and previously every bulk row shared the same <c>UtcNow</c>, which left the
/// oldest-first guarantee untestable.
/// </summary>
private static DateTimeOffset BulkSeedTimestamp(int index) =>
BulkSeedStart.AddSeconds(index);
private void InsertBulkEvents(int count)
{
// Each event carries a sizeable details payload so the database grows
@@ -146,24 +170,30 @@ public class EventLogPurgeServiceTests : IDisposable
{
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)
INSERT INTO site_events (id, timestamp, event_type, severity, source, message, details)
VALUES ($id, $ts, 'script', 'Info', 'Test', 'Bulk event', $details)
""";
cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.ToString("o"));
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N"));
cmd.Parameters.AddWithValue("$ts", BulkSeedTimestamp(i).ToString("o"));
cmd.Parameters.AddWithValue("$details", details);
cmd.ExecuteNonQuery();
}
});
}
private long MinEventId()
/// <summary>
/// Oldest surviving event timestamp. Replaces the former <c>MIN(id)</c> probe:
/// ids are GUIDs since LocalDb Phase 1 and carry no ordering, so age is measured
/// on the timestamp column the purge itself orders by.
/// </summary>
private string MinEventTimestamp()
{
return _eventLogger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT MIN(id) FROM site_events";
cmd.CommandText = "SELECT MIN(timestamp) FROM site_events";
var result = cmd.ExecuteScalar();
return result is long l ? l : 0;
return result as string ?? "";
});
}
@@ -204,9 +234,15 @@ public class EventLogPurgeServiceTests : IDisposable
[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);
// Regression test for SiteEventLogging-002: only the oldest events should be
// removed when trimming to the cap.
//
// "Oldest" used to mean "lowest autoincrement id". Since LocalDb Phase 1 the id
// is a GUID with no ordering, so both the purge and this test key on timestamp.
// Ordering by id here would delete an arbitrary batch, which is precisely the
// regression this test now guards.
const int eventCount = 3000;
InsertBulkEvents(eventCount);
var purge = CreatePurgeService();
var totalSize = purge.GetDatabaseSizeBytes();
@@ -218,20 +254,23 @@ public class EventLogPurgeServiceTests : IDisposable
MaxStorageMb = (int)Math.Max(1, (totalSize / 2) / (1024 * 1024))
};
var minIdBefore = MinEventId();
var oldestBefore = MinEventTimestamp();
var cappedPurge = CreatePurgeService(capOptions);
cappedPurge.RunPurge();
var minIdAfter = MinEventId();
var oldestAfter = MinEventTimestamp();
// 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 surviving rows must be the newest ones — the oldest timestamp advanced.
Assert.True(
string.CompareOrdinal(oldestAfter, oldestBefore) > 0,
$"Oldest events must be purged first; oldest went from '{oldestBefore}' to '{oldestAfter}'.");
// The newest event (highest id) must still be present.
// The newest event must still be present.
var newestTimestamp = BulkSeedTimestamp(eventCount - 1).ToString("o");
var newestPresent = _eventLogger.WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM site_events WHERE id = 3000";
cmd.CommandText = "SELECT COUNT(*) FROM site_events WHERE timestamp = $ts";
cmd.Parameters.AddWithValue("$ts", newestTimestamp);
return (long)cmd.ExecuteScalar()!;
});
Assert.Equal(1L, newestPresent);
@@ -45,7 +45,7 @@ public class EventLogQueryServiceTests : IDisposable
string? severity = null,
string? instanceId = null,
string? keyword = null,
long? continuationToken = null,
string? continuationToken = null,
int pageSize = 500,
DateTimeOffset? from = null,
DateTimeOffset? to = null) =>
@@ -281,7 +281,7 @@ public class EventLogQueryServiceTests : IDisposable
Assert.Single(response.Entries);
var entry = response.Entries[0];
Assert.True(entry.Id > 0);
Assert.False(string.IsNullOrWhiteSpace(entry.Id));
Assert.Equal("script", entry.EventType);
Assert.Equal("Error", entry.Severity);
Assert.Equal("inst-1", entry.InstanceId);
@@ -340,9 +340,10 @@ public class EventLogQueryServiceTests : IDisposable
{
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)
INSERT INTO site_events (id, timestamp, event_type, severity, instance_id, source, message)
VALUES ($id, $ts, $et, $sev, $iid, $src, $msg)
""";
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N"));
cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
cmd.Parameters.AddWithValue("$et", eventType);
cmd.Parameters.AddWithValue("$sev", severity);
@@ -0,0 +1,133 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
/// <summary>
/// LocalDb Phase 1 (Task 2) — the <c>site_events</c> DDL is extracted out of
/// <see cref="SiteEventLogger"/> so the Host's <c>AddZbLocalDb</c> onReady callback can
/// create it in the consolidated site database.
/// <para>
/// The extraction also carries the one deliberate schema CHANGE of Phase 1: the primary
/// key moves from <c>INTEGER PRIMARY KEY AUTOINCREMENT</c> to a TEXT GUID. Under the
/// library's last-writer-wins replication, two site nodes each minting autoincrement id 1
/// would be treated as the same row and silently overwrite one another. GUID keys make
/// the event log a pure union across the pair instead.
/// </para>
/// </summary>
public class SiteEventLogSchemaTests
{
private static SqliteConnection OpenConnection()
{
var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
return conn;
}
private static List<(string Name, string Type, bool NotNull, bool Pk)> TableInfo(
SqliteConnection conn, string table)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA table_info('{table}')";
using var reader = cmd.ExecuteReader();
var cols = new List<(string, string, bool, bool)>();
while (reader.Read())
{
cols.Add((reader.GetString(1), reader.GetString(2), reader.GetInt32(3) == 1,
reader.GetInt32(5) > 0));
}
return cols;
}
[Fact]
public void Apply_CreatesTableWithExpectedColumns()
{
using var conn = OpenConnection();
SiteEventLogSchema.Apply(conn);
Assert.Equal(
["id", "timestamp", "event_type", "severity", "instance_id", "source", "message", "details"],
TableInfo(conn, "site_events").Select(c => c.Name));
}
[Fact]
public void Apply_IdIsTextPrimaryKey_NotAutoincrement()
{
using var conn = OpenConnection();
SiteEventLogSchema.Apply(conn);
var pk = Assert.Single(TableInfo(conn, "site_events"), c => c.Pk);
Assert.Equal("id", pk.Name);
Assert.Equal("TEXT", pk.Type);
// AUTOINCREMENT creates the sqlite_sequence bookkeeping table. Its absence
// is the load-bearing assertion: ids must be application-minted GUIDs, not
// server-minted integers that collide across the replicating pair.
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'";
Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar()));
}
[Fact]
public void Apply_RejectsIntegerIdInserts()
{
using var conn = OpenConnection();
SiteEventLogSchema.Apply(conn);
// A TEXT PK with no AUTOINCREMENT means an INSERT omitting id is an error,
// which is what forces the writer to mint one (Task 5a).
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (timestamp, event_type, severity, source, message)
VALUES ('2026-07-19T00:00:00Z', 'Test', 'Info', 'test', 'no id supplied')
""";
Assert.Throws<SqliteException>(() => cmd.ExecuteNonQuery());
}
[Fact]
public void Apply_HasNoBlobColumns()
{
using var conn = OpenConnection();
SiteEventLogSchema.Apply(conn);
Assert.DoesNotContain(
TableInfo(conn, "site_events"),
c => c.Type.Contains("BLOB", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Apply_CreatesExpectedIndexes()
{
using var conn = OpenConnection();
SiteEventLogSchema.Apply(conn);
using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'site_events' " +
"AND name NOT LIKE 'sqlite_%' ORDER BY name";
using var reader = cmd.ExecuteReader();
var indexes = new List<string>();
while (reader.Read()) indexes.Add(reader.GetString(0));
Assert.Equal(
["idx_events_instance", "idx_events_severity", "idx_events_timestamp", "idx_events_type"],
indexes);
}
[Fact]
public void Apply_IsIdempotent()
{
using var conn = OpenConnection();
SiteEventLogSchema.Apply(conn);
SiteEventLogSchema.Apply(conn);
Assert.Equal(8, TableInfo(conn, "site_events").Count);
}
}
@@ -110,20 +110,30 @@ public class SiteEventLoggerTests : IDisposable
}
[Fact]
public async Task LogEventAsync_MultipleEvents_AutoIncrementIds()
public async Task LogEventAsync_MultipleEvents_GetDistinctApplicationMintedIds()
{
// Replaces LogEventAsync_MultipleEvents_AutoIncrementIds. Ids are no longer
// server-minted autoincrement integers: under LocalDb replication the site pair
// converges by last-writer-wins on the primary key, so both nodes issuing 1, 2,
// 3... for unrelated events would silently overwrite each other on sync.
//
// The invariant is therefore uniqueness, NOT monotonicity — ordering is carried
// by the timestamp column, which is what the query and purge paths sort on.
await _logger.LogEventAsync("script", "Info", null, "S1", "First");
await _logger.LogEventAsync("script", "Info", null, "S2", "Second");
await _logger.LogEventAsync("script", "Info", null, "S3", "Third");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT id FROM site_events ORDER BY id";
cmd.CommandText = "SELECT id FROM site_events ORDER BY timestamp";
using var reader = cmd.ExecuteReader();
var ids = new List<long>();
while (reader.Read()) ids.Add(reader.GetInt64(0));
var ids = new List<string>();
while (reader.Read()) ids.Add(reader.GetString(0));
Assert.Equal(3, ids.Count);
Assert.True(ids[0] < ids[1] && ids[1] < ids[2]);
Assert.All(ids, id => Assert.False(string.IsNullOrWhiteSpace(id)));
Assert.Equal(3, ids.Distinct(StringComparer.Ordinal).Count());
Assert.All(ids, id => Assert.True(Guid.TryParseExact(id, "N", out _),
$"Event id '{id}' is not a GUID in 'N' format."));
}
[Fact]