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:
@@ -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 > 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user