docs+log(siteeventlogging): explain the site_events purge oplog-backlog burst (R7)

The daily site_events retention purge (and the storage-cap trim) is CDC-captured
on a replication-enabled site node exactly like any other write — correct by
design, since LocalDb Phase 2 deliberately has no purge-exemption path — so the
backlog jumps by the deleted batch size at purge time. LocalDbOplogBacklog /
localdb_oplog_depth spike, drain, and an operator watching the gauge with no
context reads it as a replication fault.

Documentation + one log line, no behaviour change:

- topology-guide.md gains "Reading the replication backlog — the daily
  site_events purge burst": when it fires (PurgeInterval 24h, anchored to the
  active node's PROCESS START, not a wall-clock hour, so it moves after every
  failover), where it shows (replicated nodes only — not rig site-b/site-c),
  the healthy signature (LocalDbReplicationConnected stays true, backlog
  returns to ~0) and what a genuine fault looks like instead.
- Component-SiteEventLogging.md Storage records the same under retention/purge;
  Component-HealthMonitoring.md gains the two previously-undocumented
  LocalDbReplicationConnected / LocalDbOplogBacklog metric rows carrying the
  caveat, with cross-references both ways.
- EventLogPurgeService emits one Information line naming the row count and the
  expected transient backlog when a purge deleted rows on a replication-enabled
  node, so the spike is correlatable in the log. Replication-awareness comes in
  as a Host-supplied SiteEventLogReplicationCheck delegate, mirroring the
  existing SiteEventLogActiveNodeCheck seam: SiteLocalDbSetup.ReplicationIsConfigured
  goes internal so the PeerAddress-OR-ApiKey rule stays in one place and
  SiteEventLogging never learns to read LocalDb config. Unregistered ⇒ no note,
  matching the default that replication is opt-in and off.

Both delete paths carry the note (a cap trim is usually the larger burst); the
predicate is try/caught since a log-wording check must never break the purge.

Tests: 5 new EventLogPurgeServiceTests cases (replicated logs it, unreplicated
does not, zero-rows does not, cap purge logs it, throwing predicate still purges
and swallows) via a local capturing ILogger. SiteEventLogging 81/81 green,
Host 490/490 green, full solution build clean (0 warnings).
This commit is contained in:
Joseph Doherty
2026-08-15 03:26:30 -04:00
parent 2b74851f96
commit 9d2834e30a
9 changed files with 322 additions and 9 deletions
@@ -195,6 +195,14 @@ public static class SiteLocalDbSetup
/// replicates.
/// </para>
/// <para>
/// <b><c>internal</c>, not private, so there is exactly one copy of this rule.</b>
/// <c>SiteServiceRegistration</c> reuses it to supply SiteEventLogging's
/// <c>SiteEventLogReplicationCheck</c> — the predicate that decides whether the daily
/// <c>site_events</c> purge adds its "expect a transient oplog backlog" operator note.
/// A second hand-rolled PeerAddress-OR-ApiKey test would be free to drift out of step
/// with the one that actually installs the triggers.
/// </para>
/// <para>
/// <b>Both directions of a change to this predicate are now handled at boot</b> (LocalDb
/// 0.2.0). Flipping it to false deregisters, so a file registered by an older build stops
/// capturing on the next start instead of paying for triggers forever; flipping it to true
@@ -208,7 +216,7 @@ public static class SiteLocalDbSetup
/// <c>docs/deployment/topology-guide.md</c>.
/// </para>
/// </remarks>
private static bool ReplicationIsConfigured(IConfiguration config)
internal static bool ReplicationIsConfigured(IConfiguration config)
{
var section = config.GetSection("LocalDb:Replication");
@@ -210,6 +210,18 @@ public static class SiteServiceRegistration
return () => nodeProvider.SelfIsPrimary;
});
// Wording-only companion to the gate above: when a purge on a replication-enabled node
// actually deletes rows, it appends an operator note explaining the LocalDbOplogBacklog
// spike that follows. site_events is a replicated table and a retention/cap DELETE is
// CDC-captured like any other write (LocalDb Phase 2 — no purge-exemption path), so the
// backlog genuinely rises at purge time and reads like a replication fault to anyone who
// does not know a purge just ran. Delegating to SiteLocalDbSetup.ReplicationIsConfigured
// keeps the PeerAddress-OR-ApiKey rule in one place — SiteEventLogging must not learn to
// read LocalDb configuration itself.
var replicationConfigured = SiteLocalDbSetup.ReplicationIsConfigured(config);
SiteEventLogReplicationCheck replicationCheck = () => replicationConfigured;
services.AddSingleton(replicationCheck);
// Health checks — the shared ZB.MOM.WW.Health probes, mapped by MapZbHealth on the site's
// HTTP/1.1 listener (Program.cs). Site nodes served no health endpoints before this; the
// family overview dashboard probes every node the same way, so a site node has to answer
@@ -20,6 +20,32 @@ namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging;
/// <returns><c>true</c> if this node is the active site member and should run the purge; <c>false</c> to skip.</returns>
public delegate bool SiteEventLogActiveNodeCheck();
/// <summary>
/// Predicate the <see cref="EventLogPurgeService"/> consults when a purge actually deleted
/// rows, to decide whether to add the "expect a transient replication backlog" operator note
/// to its purge log line.
///
/// <para>
/// <c>site_events</c> is one of LocalDb's replicated tables, and CDC captures a retention
/// DELETE exactly like any other row change — by design, since Phase 2 there is deliberately
/// no purge-exemption path. So on a replication-enabled node the purge briefly inflates the
/// oplog (health field <c>LocalDbOplogBacklog</c>, Prometheus <c>localdb_oplog_depth</c>),
/// which reads like a replication fault to an operator who does not know a purge just ran.
/// The log line is the breadcrumb that ties the two together; it is a message-wording choice
/// only and changes no purge behaviour.
/// </para>
///
/// <para>
/// Registration is the Host's responsibility — SiteEventLogging has no view of
/// <c>LocalDb:Replication</c> and must not grow one; the Host already owns that predicate
/// (<c>SiteLocalDbSetup.ReplicationIsConfigured</c>, the <c>PeerAddress</c>-OR-<c>ApiKey</c>
/// rule) and passes it in. When no implementation is registered the note is suppressed, which
/// matches the product default: replication is opt-in and off unless configured.
/// </para>
/// </summary>
/// <returns><c>true</c> if this node has LocalDb replication configured; <c>false</c> otherwise.</returns>
public delegate bool SiteEventLogReplicationCheck();
/// <summary>
/// Background service that periodically purges old events from the SQLite event log.
/// Enforces both time-based retention (default 30 days) and storage cap (default 1GB).
@@ -37,6 +63,7 @@ public class EventLogPurgeService : BackgroundService
private readonly SiteEventLogOptions _options;
private readonly ILogger<EventLogPurgeService> _logger;
private readonly SiteEventLogActiveNodeCheck _isActiveNode;
private readonly SiteEventLogReplicationCheck _isReplicationConfigured;
/// <summary>Initializes a new instance of <see cref="EventLogPurgeService"/>.</summary>
/// <param name="eventLogger">The concrete event logger providing lock-guarded database access.</param>
@@ -49,11 +76,18 @@ public class EventLogPurgeService : BackgroundService
/// the Host on a site node — each tick early-exits on the standby so the
/// daily purge runs only on the active node, matching the design.
/// </param>
/// <param name="isReplicationConfigured">
/// Optional LocalDb-replication check. When <c>null</c> — non-clustered hosts,
/// unit tests — the purge log omits the replication-backlog operator note, matching
/// the product default that replication is opt-in and off unless configured.
/// See <see cref="SiteEventLogReplicationCheck"/>.
/// </param>
public EventLogPurgeService(
SiteEventLogger eventLogger,
IOptions<SiteEventLogOptions> options,
ILogger<EventLogPurgeService> logger,
SiteEventLogActiveNodeCheck? isActiveNode = null)
SiteEventLogActiveNodeCheck? isActiveNode = null,
SiteEventLogReplicationCheck? isReplicationConfigured = null)
{
// Depend on the concrete recorder directly: purge must funnel database access
// through its lock-guarded WithConnection. Taking ISiteEventLogger and
@@ -62,6 +96,7 @@ public class EventLogPurgeService : BackgroundService
_options = options.Value;
_logger = logger;
_isActiveNode = isActiveNode ?? (static () => true);
_isReplicationConfigured = isReplicationConfigured ?? (static () => false);
}
/// <inheritdoc />
@@ -161,9 +196,49 @@ public class EventLogPurgeService : BackgroundService
if (totalDeleted > 0)
{
_logger.LogInformation("Purged {Count} events older than {Days} days", totalDeleted, _options.RetentionDays);
LogReplicationBacklogNote(totalDeleted);
}
}
/// <summary>
/// Emits the operator breadcrumb that explains the oplog backlog spike a purge produces on a
/// replication-enabled node. No-op when this node has no replication peer.
/// </summary>
/// <remarks>
/// <c>site_events</c> is a replicated table, and a retention/cap DELETE is captured by CDC
/// exactly like an ordinary write — there is deliberately no purge-exemption path (LocalDb
/// Phase 2: CDC does all three jobs). The daily purge therefore pushes one oplog row per
/// deleted event, and the site health report's <c>LocalDbOplogBacklog</c> (Prometheus
/// <c>localdb_oplog_depth</c>) spikes until the peer acks them. That is expected and drains
/// on its own; without this line an operator correlating the spike has nothing in the log to
/// tie it to. Information level because it is only interesting next to the purge line it
/// follows — the spike itself is not a fault.
/// </remarks>
/// <param name="deletedRows">Number of rows the purge just deleted; always &gt; 0 at the call sites.</param>
private void LogReplicationBacklogNote(int deletedRows)
{
bool replicated;
try
{
replicated = _isReplicationConfigured();
}
catch (Exception checkEx)
{
// A log-wording predicate must never break the purge loop.
_logger.LogDebug(checkEx, "Replication check threw while composing the purge log note; note suppressed");
return;
}
if (!replicated)
return;
_logger.LogInformation(
"Purged {Count} site_events rows on a replication-enabled node — a transient LocalDb oplog backlog " +
"is expected while the deletes replicate to the peer. It drains on its own; " +
"LocalDbReplicationConnected staying true with the backlog returning to ~0 is the healthy signature.",
deletedRows);
}
private void PurgeByStorageCap()
{
var capBytes = (long)_options.MaxStorageMb * 1024 * 1024;
@@ -180,6 +255,8 @@ public class EventLogPurgeService : BackgroundService
// The loop also stops if the on-disk size fails to decrease across an
// iteration (e.g. if vacuum cannot reclaim space), so a cap that can never
// be met does not silently empty the entire table.
var totalDeleted = 0;
while (currentSizeBytes > capBytes)
{
var previousSizeBytes = currentSizeBytes;
@@ -214,6 +291,7 @@ public class EventLogPurgeService : BackgroundService
if (deleted == 0)
break;
totalDeleted += deleted;
currentSizeBytes = GetDatabaseSizeBytes();
if (currentSizeBytes >= previousSizeBytes)
@@ -228,6 +306,13 @@ public class EventLogPurgeService : BackgroundService
break;
}
}
// Same CDC cost as the retention purge above — a cap-driven trim is usually the
// larger of the two, so it gets the same operator breadcrumb.
if (totalDeleted > 0)
{
LogReplicationBacklogNote(totalDeleted);
}
}
/// <summary>
@@ -30,11 +30,18 @@ public static class ServiceCollectionExtensions
// it unregistered, and the purge defaults to "always run" (the
// pre-fix behaviour). Building the service via a factory so the
// optional delegate flows from DI rather than the constructor default.
//
// SiteEventLogReplicationCheck flows the same way and is likewise Host-supplied:
// it only selects the wording of the purge log line (whether to add the
// "expect a transient LocalDb oplog backlog" note), and this component has no
// view of LocalDb:Replication. Unregistered ⇒ no note, matching the default
// that replication is opt-in and off.
services.AddHostedService(sp => new EventLogPurgeService(
sp.GetRequiredService<SiteEventLogger>(),
sp.GetRequiredService<IOptions<SiteEventLogOptions>>(),
sp.GetRequiredService<ILogger<EventLogPurgeService>>(),
sp.GetService<SiteEventLogActiveNodeCheck>()));
sp.GetService<SiteEventLogActiveNodeCheck>(),
sp.GetService<SiteEventLogReplicationCheck>()));
return services;
}