docs(comments): strip internal task/milestone/bundle bookkeeping from code comments

Remove project bookkeeping citations from shipped code comments across the
solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/
issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels,
and C/D/K/S/T phase labels.

Comment text only — no code logic, string/log literals, or XML-doc structure
changed. Genuine descriptions are preserved (only the citation is stripped),
and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365,
UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker
TaskReferenceInComment / TrackingReferenceInComment checks plus targeted
grep passes; full solution builds clean, append-only guard tests pass.
This commit is contained in:
Joseph Doherty
2026-07-07 11:03:26 -04:00
parent 67005ca4c0
commit 9cff87fe85
435 changed files with 2338 additions and 2547 deletions
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Audit Log (#23) M6 Bundle E (T8, T9) — central singleton implementation of
/// Central singleton implementation of
/// <see cref="IAuditCentralHealthSnapshot"/>. Owns thread-safe
/// <see cref="System.Threading.Interlocked"/> counters for
/// <c>CentralAuditWriteFailures</c> + <c>AuditRedactionFailure</c> and a
@@ -71,7 +71,7 @@ public class AuditLogIngestActor : ReceiveActor
_logger = logger;
ReceiveAsync<IngestAuditEventsCommand>(OnIngestAsync);
// The single-repository test ctor cannot service the M3 dual-write —
// The single-repository test ctor cannot service the dual-write —
// it has no SiteCalls repo and no DbContext. The handler still
// registers (so callers don't dead-letter) but replies empty so the
// test surface stays explicit about what this ctor supports.
@@ -125,7 +125,7 @@ public class AuditLogIngestActor : ReceiveActor
// InsertIfNotExistsAsync. The single-repository test ctor has no
// service provider — it falls through with no redactor, which preserves
// the small-payload assumptions baked into the existing fixtures.
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
// Use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup.
if (_injectedRepository is not null)
@@ -135,7 +135,7 @@ public class AuditLogIngestActor : ReceiveActor
}
else
{
// AuditLog-014: guard scope-creation + repository resolution in a
// Guard scope-creation + repository resolution in a
// try/catch, mirroring OnCachedTelemetryAsync. A transient DI /
// DbContext-factory fault (pooled-context init, SQL-connection
// exhaustion, a resolution race during host churn) would otherwise
@@ -151,7 +151,7 @@ public class AuditLogIngestActor : ReceiveActor
await using var scope = _serviceProvider!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): central health counter is best-effort —
// Central health counter is best-effort —
// unregistered (test composition roots) means the per-row catch
// simply logs without surfacing on the health dashboard.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
@@ -194,11 +194,11 @@ public class AuditLogIngestActor : ReceiveActor
// retry, reconciliation) is a silent no-op.
// Redact BEFORE the IngestedAtUtc stamp so the redacted
// copy carries the central-side ingest timestamp. The redactor
// is contract-bound to never throw. AuditLog-008: a null
// is contract-bound to never throw. A null
// redactor (test composition root, no IAuditRedactor
// registered) now falls back to the SafeDefault rather than
// pass-through, so HTTP header redaction always runs.
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on
// IngestedAtUtc is a DetailsJson field on
// the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filtered = safeRedactor.Apply(evt);
@@ -210,7 +210,7 @@ public class AuditLogIngestActor : ReceiveActor
{
// Per-row catch — one bad row never sinks the whole batch.
// The row stays Pending at the site; the next drain retries.
// M6 Bundle E (T8): bump the central health counter so a
// Bump the central health counter so a
// sustained insert-throw failure surfaces on the dashboard.
try { failureCounter?.Increment(); }
catch { /* counter must never throw — defence in depth */ }
@@ -222,7 +222,7 @@ public class AuditLogIngestActor : ReceiveActor
}
/// <summary>
/// M3 dual-write handler. For every <see cref="CachedTelemetryEntry"/> the
/// Dual-write handler. For every <see cref="CachedTelemetryEntry"/> the
/// actor opens a fresh MS SQL transaction, inserts the AuditLog row
/// idempotently AND upserts the SiteCalls row monotonically. Both succeed
/// or both roll back, so the audit and operational mirrors never drift
@@ -248,13 +248,13 @@ public class AuditLogIngestActor : ReceiveActor
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var siteCallRepo = scope.ServiceProvider.GetRequiredService<ISiteCallAuditRepository>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Bundle C (M5-T6): resolve the redactor for the whole batch from
// Resolve the redactor for the whole batch from
// the scope; null = SafeDefault for test composition roots that
// skip the redactor registration. The redactor is contract-bound to
// never throw, so we can apply it inside the per-entry try
// without risking an unbounded blast radius.
var redactor = scope.ServiceProvider.GetService<IAuditRedactor>();
// M6 Bundle E (T8): same best-effort central health counter as
// Same best-effort central health counter as
// the OnIngestAsync path — null on test composition roots that
// skip the registration.
var failureCounter = scope.ServiceProvider.GetService<ICentralAuditWriteFailureCounter>();
@@ -275,9 +275,9 @@ public class AuditLogIngestActor : ReceiveActor
// Redact the audit half BEFORE the dual-write — only the
// AuditLog row's payload columns are redactable; SiteCalls
// carries operational state only (status, retry count) and
// is left untouched. AuditLog-008: null redactor falls back
// is left untouched. Null redactor falls back
// to SafeDefault so header redaction always runs.
// C3 transitional shim: IngestedAtUtc is a DetailsJson field
// IngestedAtUtc is a DetailsJson field
// on the canonical record, so stamp it via the projection helper.
var safeRedactor = redactor ?? SafeDefaultAuditRedactor.Instance;
var filteredAudit = safeRedactor.Apply(entry.Audit);
@@ -298,7 +298,7 @@ public class AuditLogIngestActor : ReceiveActor
// EventId is NOT added to `accepted` so the site keeps its
// row Pending and retries on the next drain. Other entries
// in the batch continue with their own transactions.
// M6 Bundle E (T8): bump the central health counter so a
// Bump the central health counter so a
// sustained dual-write failure surfaces on the dashboard.
try { failureCounter?.Increment(); }
catch { /* counter must never throw — defence in depth */ }
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Tuning knobs for the central
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service (M6-T5).
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service.
/// Defaults: once every 24 hours, keep at least one future monthly
/// boundary ahead of <see cref="DateTime.UtcNow"/>.
/// </summary>
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Central <see cref="IHostedService"/> (M6-T5, Bundle D) that rolls
/// Central <see cref="IHostedService"/> that rolls
/// <c>pf_AuditLog_Month</c> forward once a day. Each tick opens a fresh DI
/// scope, resolves <see cref="IPartitionMaintenance"/>, and calls
/// <see cref="IPartitionMaintenance.EnsureLookaheadAsync"/> to SPLIT any
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a hosted service, not an actor.</b> Bundle C's
/// <b>Why a hosted service, not an actor.</b>
/// <see cref="AuditLogPurgeActor"/> sits inside the central singleton
/// because it needs supervised lifecycle alongside the rest of the
/// reconciliation / ingest pipeline. Roll-forward is genuinely a once-a-day
@@ -33,8 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <b>Failure containment.</b> The tick body wraps the maintenance call in
/// a try/catch so a transient SQL Server error never tears down the hosted
/// service — the next tick simply retries. The exception is logged with
/// the original stack trace at <c>Error</c> level; ops surfaces (M6 Bundle
/// E's central health collector) can subscribe to the logger to alert on
/// the original stack trace at <c>Error</c> level; ops surfaces can subscribe to the logger to alert on
/// repeated failures.
/// </para>
/// <para>
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Central singleton (M6 Bundle C) that drives the daily AuditLog partition
/// Central singleton that drives the daily AuditLog partition
/// purge. On a configurable timer (default 24 hours) the actor:
/// <list type="number">
/// <item>Queries <see cref="IAuditLogRepository.GetPartitionBoundariesOlderThanAsync"/>
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <see cref="IAuditLogRepository.SwitchOutPartitionAsync"/> which runs
/// the drop-and-rebuild dance around <c>UX_AuditLog_EventId</c>.</item>
/// <item>Publishes <see cref="AuditLogPurgedEvent"/> on the actor-system
/// EventStream so the Bundle E central health collector + ops surfaces
/// EventStream so the central health collector + ops surfaces
/// can subscribe without coupling to this actor.</item>
/// </list>
/// </summary>
@@ -51,8 +51,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <para>
/// <b>EventStream.</b> Publishing <see cref="AuditLogPurgedEvent"/> through
/// the EventStream rather than direct messaging avoids coupling this actor
/// to its consumers; M6 Bundle E will subscribe a central health-counter
/// bridge that surfaces purge progress on the central health report.
/// to its consumers; a central health-counter bridge will subscribe to
/// surface purge progress on the central health report.
/// </para>
/// </remarks>
public class AuditLogPurgeActor : ReceiveActor
@@ -134,7 +134,7 @@ public class AuditLogPurgeActor : ReceiveActor
// restart.
var threshold = DateTime.UtcNow - TimeSpan.FromDays(_auditOptions.RetentionDays);
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
// Use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup.
await using var scope = _services.CreateAsyncScope();
@@ -206,7 +206,7 @@ public class AuditLogPurgeActor : ReceiveActor
}
}
// M5.5 (T3): after the channel-blind global partition switch-out, apply any
// After the channel-blind global partition switch-out, apply any
// per-channel retention overrides that are SHORTER than the global window via
// a bounded, batched row DELETE on the same maintenance path. The global
// switch-out has already dropped whole months older than RetentionDays; these
@@ -216,7 +216,7 @@ public class AuditLogPurgeActor : ReceiveActor
}
/// <summary>
/// M5.5 (T3): runs each per-channel retention override whose window is strictly
/// Runs each per-channel retention override whose window is strictly
/// shorter than the global <see cref="AuditLogOptions.RetentionDays"/>, deleting
/// rows of that channel older than the channel-specific threshold via a bounded,
/// batched maintenance-path DELETE. Each channel runs inside its own try/catch so
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Tuning knobs for the central <see cref="AuditLogPurgeActor"/> singleton.
/// Default cadence is 24 hours per the M6 plan; the retention window itself
/// Default cadence is 24 hours; the retention window itself
/// is sourced from <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.RetentionDays"/>
/// (default 365) so operators tune retention from a single section.
/// </summary>
@@ -31,7 +31,7 @@ public sealed class AuditLogPurgeOptions
public int IntervalHours { get; set; } = 24;
/// <summary>
/// M5.5 (T3): batch size for the per-channel retention-override row DELETE
/// Batch size for the per-channel retention-override row DELETE
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.PurgeChannelOlderThanAsync"/>).
/// Each <c>DELETE TOP (@batch)</c> caps the transaction-log and lock footprint
/// per statement; the repository loops batches until no rows remain. Default
@@ -40,7 +40,7 @@ public sealed class AuditLogPurgeOptions
/// <see cref="ChannelPurgeBatchSize"/>.
/// </summary>
/// <remarks>
/// AuditLog-013: the operator-facing config key is <c>ChannelPurgeBatchSize</c>
/// The operator-facing config key is <c>ChannelPurgeBatchSize</c>
/// (per Component-AuditLog.md), so the binder maps that documented key onto this
/// backing property via <see cref="ConfigurationKeyNameAttribute"/>. The unattributed
/// property name (<c>ChannelPurgeBatchSizeConfigured</c>) would otherwise have been
@@ -2,8 +2,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Published on the actor-system EventStream by <see cref="AuditLogPurgeActor"/>
/// after each successful partition switch-out. Downstream consumers (Bundle E
/// central health collector, ops dashboards, audit trails) subscribe so a
/// after each successful partition switch-out. Downstream consumers (central
/// health collector, ops dashboards, audit trails) subscribe so a
/// purge action is observable without the actor needing to know about any
/// specific subscriber.
/// </summary>
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Audit Log (#23) M6 Bundle E (T9) — bridges
/// Bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// a header / body / SQL parameter redactor stage throws and the redactor has
@@ -13,11 +13,11 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// </summary>
/// <remarks>
/// <para>
/// <b>Site vs central.</b> M5 Bundle C wired the SITE-side bridge
/// <b>Site vs central.</b> Wired the SITE-side bridge
/// (<see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.HealthMetricsAuditRedactionFailureCounter"/>),
/// which routes increments into the site health report payload's
/// <c>AuditRedactionFailure</c> field. That handles redactor failures on the
/// site SQLite hot-path (FallbackAuditWriter). M6 Bundle E (T9) adds the
/// site SQLite hot-path (FallbackAuditWriter). Adds the
/// MIRROR bridge here so the same payload filter — when it runs on the
/// central <see cref="CentralAuditWriter"/> /
/// <see cref="AuditLogIngestActor"/> paths — surfaces its failures on the
@@ -47,22 +47,22 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
private readonly INodeIdentityProvider? _nodeIdentity;
/// <summary>
/// Bundle C (M5-T6) — the central direct-write path used by the
/// The central direct-write path used by the
/// NotificationOutboxActor dispatch and the Inbound API middleware also
/// needs to truncate + redact before the row hits MS SQL. The filter is
/// optional so the M4 test composition roots that don't pass one keep
/// optional so test composition roots that don't pass one keep
/// working (they only ever write small payloads); production DI registers
/// the real filter via <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
/// M6 Bundle E (T8) — adds the optional
/// Adds the optional
/// <see cref="ICentralAuditWriteFailureCounter"/> so a swallowed repository
/// throw bumps the central health surface's
/// <c>CentralAuditWriteFailures</c> counter. Defaults to a NoOp so test
/// composition roots that don't wire the counter keep their current
/// behaviour. SourceNode-stamping (Task 12) — adds the optional
/// behaviour. SourceNode-stamping — adds the optional
/// <see cref="INodeIdentityProvider"/> so central-origin rows (Notification
/// Outbox dispatch, Inbound API) carry the writing central node's
/// identifier when the caller hasn't already supplied one. Optional /
/// defaulting-to-null so M4 test composition roots that don't pass a
/// defaulting-to-null so test composition roots that don't pass a
/// provider keep working — the caller-wins discipline means an absent
/// provider simply leaves SourceNode at whatever the caller set (often
/// null, which is the legacy behaviour).
@@ -81,8 +81,8 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to null — over-redact instead.
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
// Never default to null — over-redact instead.
// Wired via the canonical IAuditRedactor seam.
// SafeDefaultAuditRedactor applies HTTP header redaction with
// hard-coded sensitive defaults so a composition root that omits the
// real redactor still scrubs Authorization / X-Api-Key / Cookie /
@@ -106,13 +106,13 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try
{
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
// redactor contract is "never throws". AuditLog-008: _redactor is
// redactor contract is "never throws". _redactor is
// now non-null (SafeDefaultAuditRedactor fallback) so header
// redaction always runs even in composition roots that omit the
// real redactor.
var filtered = _redactor.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins
// SourceNode-stamping: caller-provided value wins
// (supports any future direct-write callsite that already has its
// own node id); otherwise stamp from the local
// INodeIdentityProvider, when one is wired. Production DI on
@@ -126,7 +126,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
await using var scope = _services.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
// C3 transitional shim: IngestedAtUtc is a DetailsJson field on the
// IngestedAtUtc is a DetailsJson field on the
// canonical record, so stamp it via the projection helper.
var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow);
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
@@ -134,7 +134,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
catch (Exception ex)
{
// Audit failure NEVER aborts the user-facing action — swallow and log.
// M6 Bundle E (T8): also surface the failure on the central health
// Also surface the failure on the central health
// counter so a sustained audit-write outage is visible on the
// health dashboard rather than disappearing into the log file.
try
@@ -12,7 +12,7 @@ using PullAuditEventsResponse = ZB.MOM.WW.ScadaBridge.Commons.Messages.Integrati
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Production <see cref="IPullAuditEventsClient"/> (Audit Log #23, M6) that the
/// Production <see cref="IPullAuditEventsClient"/> that the
/// central <see cref="SiteAuditReconciliationActor"/> uses to pull the next
/// reconciliation batch from a site over the <c>PullAuditEvents</c> unary gRPC
/// RPC served by <c>SiteStreamGrpcServer</c>.
@@ -13,7 +13,7 @@ using PullSiteCallsResponse = ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Production <see cref="IPullSiteCallsClient"/> (Site Call Audit #22) that the
/// Production <see cref="IPullSiteCallsClient"/> (Site Call Audit) that the
/// central reconciliation tick (a separate follow-up component) uses to pull the
/// next batch of cached-call operational rows from a site over the
/// <c>PullSiteCalls</c> unary gRPC RPC served by <c>SiteStreamGrpcServer</c>.
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Audit Log (#23) M6 Bundle E read-side surface exposing the central-side
/// Read-side surface exposing the central-side
/// audit-health counters: <see cref="CentralAuditWriteFailures"/> (every
/// repository insert throw from <see cref="CentralAuditWriter"/> /
/// <see cref="AuditLogIngestActor"/>), <see cref="AuditRedactionFailure"/>
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <para>
/// <b>Read-only contract.</b> Implementations expose a point-in-time snapshot
/// — increments and tracker updates happen through the dedicated counter /
/// tracker interfaces, not through this surface. Consumers (M7+ central
/// tracker interfaces, not through this surface. Consumers (central
/// health pages) read these properties; they never mutate.
/// </para>
/// <para>
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// failure / redaction-failure counters originate ON central (no site report
/// carries them), so they live on a dedicated snapshot rather than being
/// retro-fitted into a per-site state. The two surfaces will be composed at
/// the M7 dashboard layer.
/// the dashboard layer.
/// </para>
/// </remarks>
public interface IAuditCentralHealthSnapshot
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Audit Log (#23) M5.3 (T7) counter sink incremented by
/// Audit Log counter sink incremented by
/// <see cref="ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware.AuditWriteMiddleware"/>
/// whenever an inbound request or response body is truncated at the
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.AuditLogOptions.InboundMaxBytes"/>
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Audit Log (#23) M6 Bundle E (T8) counter sink invoked by central-side audit
/// Counter sink invoked by central-side audit
/// writers (<see cref="CentralAuditWriter"/>, <see cref="AuditLogIngestActor"/>)
/// every time a repository <c>InsertIfNotExistsAsync</c> throws. Mirrors the
/// site-side <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.IAuditWriteFailureCounter"/>
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// instead. A NoOp default is the correct safe fallback while the central
/// health surface is being wired in; <see cref="AuditCentralHealthSnapshot"/>
/// is the production binding that routes increments into the aggregated
/// central health snapshot consumed by future M7+ pages.
/// central health snapshot, to be consumed by future central health pages.
/// </remarks>
public interface ICentralAuditWriteFailureCounter
{
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Mockable abstraction over the central-side <c>PullSiteCalls</c> gRPC client
/// surface used by the Site Call Audit (#22) reconciliation tick to fetch the
/// surface used by the Site Call Audit reconciliation tick to fetch the
/// next batch of cached-call operational rows from a specific site — the
/// documented periodic self-heal pull that backfills the eventually-consistent
/// central <c>SiteCalls</c> mirror when best-effort push telemetry is lost.
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Central singleton (M6 Bundle B) that drives the audit-log reconciliation
/// Central singleton that drives the audit-log reconciliation
/// pull loop. On a configurable timer (default 5 minutes) the actor walks every
/// known site, asks the site for any <see cref="AuditEvent"/> rows with
/// <see cref="AuditEvent.OccurredAtUtc"/> &gt;= the site's last reconciled
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// missed push (gRPC blip, central restart, site offline) is eventually
/// repaired by central re-pulling whatever the site still has in
/// <c>Pending</c>/<c>Forwarded</c> state. Idempotency on
/// <see cref="AuditEvent.EventId"/> (M2 Bundle A's race-fix) makes duplicate
/// <see cref="AuditEvent.EventId"/> makes duplicate
/// arrivals from both paths a silent no-op.
/// </para>
/// <para>
@@ -33,7 +33,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// the cursors reset to <see cref="DateTime.MinValue"/>. That is conservative
/// but correct — the next tick simply asks for everything the site still has,
/// and idempotent ingest swallows the dupes. Persisting cursors to MS SQL was
/// considered and rejected for M6: the cost of a write per tick outweighs the
/// considered and rejected: the cost of a write per tick outweighs the
/// rare benefit of avoiding one over-broad pull after a restart.
/// </para>
/// <para>
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// consecutive pull cycles BOTH return non-empty AND <c>MoreAvailable=true</c>
/// — i.e. the backlog isn't draining. The actor publishes
/// <see cref="SiteAuditTelemetryStalledChanged"/> on the actor system's
/// EventStream so a future <c>ICentralHealthCollector</c> bridge (M6 Bundle E)
/// EventStream so a future <c>ICentralHealthCollector</c> bridge
/// can flip the health metric without coupling this actor to the health
/// collection surface today.
/// </para>
@@ -97,7 +97,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
private readonly Dictionary<string, bool> _stalled = new();
/// <summary>
/// AuditLog-004: per-EventId retry counter for rows whose central insert
/// Per-EventId retry counter for rows whose central insert
/// threw. While a row keeps failing AND is below
/// <see cref="MaxPermanentInsertAttempts"/>, the cursor is held back so the
/// next reconciliation tick re-pulls and retries the row. Crossing the
@@ -110,7 +110,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
private readonly Dictionary<Guid, int> _failedInsertAttempts = new();
/// <summary>
/// AuditLog-004: number of consecutive central-insert failures before a row
/// Number of consecutive central-insert failures before a row
/// is permanently abandoned with a Critical log entry and the cursor is
/// allowed to advance past it. Five attempts at the 5-minute default tick
/// is ~25 min of retry budget before a stuck row stops blocking progress.
@@ -199,7 +199,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
return;
}
// AuditLog-003: use CreateAsyncScope + await using so scoped EF Core
// Use CreateAsyncScope + await using so scoped EF Core
// services (IAsyncDisposable DbContexts) dispose asynchronously
// without blocking on sync Dispose() of pending connection cleanup.
await using var scope = _services.CreateAsyncScope();
@@ -260,9 +260,8 @@ public class SiteAuditReconciliationActor : ReceiveActor
{
// Idempotent repository write: duplicate EventIds (from a
// concurrent push, or a retry of this very pull) collapse to
// a no-op courtesy of M2 Bundle A's race-fix on
// InsertIfNotExistsAsync.
// C3: IngestedAtUtc is a DetailsJson field on the canonical record —
// a no-op on InsertIfNotExistsAsync.
// IngestedAtUtc is a DetailsJson field on the canonical record —
// stamp it via the projection helper.
var ingested = AuditRowProjection.WithIngestedAtUtc(evt, nowUtc);
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
@@ -271,7 +270,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
}
catch (Exception ex)
{
// AuditLog-004: per-row catch so one bad event does not abandon
// Per-row catch so one bad event does not abandon
// the rest of the batch. Track the failure count per EventId —
// below MaxPermanentInsertAttempts the cursor is HELD BACK so
// the next tick re-pulls and retries; at the threshold the row
@@ -305,7 +304,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
}
}
// C3: canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
// Canonical OccurredAtUtc is a DateTimeOffset; the cursor is a UTC DateTime.
var occurredUtc = evt.OccurredAtUtc.UtcDateTime;
if (advanceForThisRow && occurredUtc > maxOccurred)
{
@@ -313,7 +312,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
}
}
// AuditLog-004: only advance the persisted cursor if no event in this
// Only advance the persisted cursor if no event in this
// batch is still being retried. Leaving the cursor at `since` re-pulls
// the whole batch next tick — successful rows are no-ops thanks to
// InsertIfNotExistsAsync's idempotency, and the failing row gets
@@ -388,7 +387,7 @@ public class SiteAuditReconciliationActor : ReceiveActor
/// <summary>
/// Published on the actor system EventStream when a site's reconciliation
/// puller transitions into or out of the "stalled" state (backlog not
/// draining across multiple cycles). The M6 Bundle E central health collector
/// draining across multiple cycles). The central health collector
/// will subscribe to this and surface
/// <c>SiteAuditTelemetryStalled</c> on the health-report payload.
/// </summary>
@@ -2,13 +2,13 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Tuning knobs for the central <see cref="SiteAuditReconciliationActor"/> singleton.
/// Defaults mirror the M6 Bundle B brief: pull every 5 minutes per site, 256 rows per
/// Defaults: pull every 5 minutes per site, 256 rows per
/// batch, declare a site "stalled" after two consecutive pull cycles return non-empty
/// AND <c>MoreAvailable=true</c> (the backlog is not draining).
/// </summary>
/// <remarks>
/// <para>
/// Per the M6 plan the reconciliation actor is the fallback when push telemetry is
/// The reconciliation actor is the fallback when push telemetry is
/// lost; it is intentionally low-frequency. Lowering
/// <see cref="ReconciliationIntervalSeconds"/> in production trades MS SQL load for
/// fresher self-healing — keep the default unless a deployment can prove the extra
@@ -5,10 +5,10 @@ using Akka.Event;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Audit Log (#23) M6 Bundle E (T7) — central singleton that subscribes to the
/// Central singleton that subscribes to the
/// actor system's EventStream for <see cref="SiteAuditTelemetryStalledChanged"/>
/// publications and maintains a per-site latched stalled-state map readable
/// via <see cref="Snapshot"/>. Consumed by the M6 Bundle E
/// via <see cref="Snapshot"/>. Consumed by the
/// <see cref="AuditCentralHealthSnapshot"/> aggregator so the central health
/// surface can surface per-site "reconciliation isn't draining" without
/// coupling the publisher (<see cref="SiteAuditReconciliationActor"/>) to the
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
/// <summary>
/// Configuration for Audit Log (#23). Bound from the <c>AuditLog</c> section of
/// Configuration for Audit Log. Bound from the <c>AuditLog</c> section of
/// <c>appsettings.json</c>. Defaults reflect the design (alog.md §6, §10): an
/// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central
/// retention window with monthly partition-switch purge. The default
@@ -38,7 +38,7 @@ public sealed class AuditLogOptions
public int RetentionDays { get; set; } = 365;
/// <summary>
/// M5.5 (T3) per-channel retention overrides, keyed by the canonical channel name
/// Per-channel retention overrides, keyed by the canonical channel name
/// (the <see cref="AuditChannel"/> enum name — e.g. <c>ApiOutbound</c>,
/// <c>DbOutbound</c>, <c>Notification</c>, <c>ApiInbound</c>). The value is a
/// retention window in days that MUST be SHORTER than or equal to the global
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
/// <summary>
/// Validates <see cref="AuditLogOptions"/> on startup. The caps drive payload
/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily
/// truncation in the writers, so an unset/zero cap would let arbitrarily
/// large blobs into the central <c>AuditLog</c> table. <see cref="AuditLogOptions.ErrorCapBytes"/>
/// must be at least as large as <see cref="AuditLogOptions.DefaultCapBytes"/>
/// because the error cap is meant to capture <em>more</em> detail than the
@@ -54,7 +54,7 @@ public sealed class AuditLogOptionsValidator : OptionsValidatorBase<AuditLogOpti
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
// M5.5 (T3): per-channel retention overrides. Each entry must be keyed by a
// Per-channel retention overrides. Each entry must be keyed by a
// recognized AuditChannel name and carry a window in [MinRetentionDays,
// RetentionDays] — i.e. SHORTER than or equal to the global window. A longer
// per-channel window is meaningless under month-partition switch-out (governed
@@ -17,7 +17,7 @@ public sealed class PerTargetRedactionOverride
/// <summary>
/// Opt-in SQL parameter redaction: case-insensitive regex matched against
/// each SQL parameter NAME in the M4 <c>AuditingDbCommand</c> RequestSummary
/// each SQL parameter NAME in the <c>AuditingDbCommand</c> RequestSummary
/// JSON (<c>{"sql":"...","parameters":{"@name":"value", ...}}</c>); values
/// whose name matches are replaced with <c>&lt;redacted&gt;</c>. Null (the
/// default) means parameter values are captured verbatim. Only applied to
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Kpi;
/// <summary>
/// Audit Log (#23) M6 "KPI History &amp; Trends" (K8) — the
/// "KPI History &amp; Trends" — the
/// <see cref="IKpiSampleSource"/> for the <see cref="KpiSources.AuditLog"/> source.
/// The central recorder singleton enumerates this provider once per sampling pass
/// and persists its samples into the central <c>KpiSample</c> history table.
@@ -38,7 +38,7 @@ public sealed class AuditLogKpiSampleSource : IKpiSampleSource
// Metric catalog — the exact strings persisted in KpiSample.Metric. All three are
// charted, so they share the public Commons catalog: source + UI trend page key off
// one symbol (#178). Stable identifiers; do not rename (values are persisted).
// one symbol. Stable identifiers; do not rename (values are persisted).
private const string TotalEventsLastHourMetric = KpiMetrics.AuditLog.TotalEventsLastHour;
private const string ErrorEventsLastHourMetric = KpiMetrics.AuditLog.ErrorEventsLastHour;
private const string BacklogTotalMetric = KpiMetrics.AuditLog.BacklogTotal;
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// Pure, stateless redaction + truncation primitives used by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// (which operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> + its <c>DetailsJson</c>).
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) so the
/// Extracted in a ScadaBridge audit re-architecture so the
/// byte-exact redaction logic lives in ONE place.
/// </summary>
/// <remarks>
@@ -193,7 +193,7 @@ internal static class AuditRedactionPrimitives
}
/// <summary>
/// Walk the M4 <c>{"sql":"...","parameters":{...}}</c> RequestSummary
/// Walk the <c>{"sql":"...","parameters":{...}}</c> RequestSummary
/// shape; for each parameter whose NAME matches
/// <paramref name="paramNameRegex"/>, replace its value with
/// <see cref="RedactedMarker"/>. Re-serialise. No-op pass-through when the
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Per-instance compiled-regex cache for audit body / SQL-parameter redactors
/// used by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>.
/// Extracted in ScadaBridge audit re-architecture stage C2 (Task 2.5) to
/// Extracted in ScadaBridge audit re-architecture to
/// centralize compile rules (50 ms per-match timeout, 100 ms compile budget,
/// invalid-pattern sentinel).
/// </summary>
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// Counter sink invoked by <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// every time a redactor (header / body regex / SQL parameter) throws and the
/// redactor has to over-redact the offending field with the
/// <c>&lt;redacted: redactor error&gt;</c> marker. Bundle C bridges this into
/// <c>&lt;redacted: redactor error&gt;</c> marker. This is bridged into
/// the Site Health Monitoring report payload as <c>AuditRedactionFailure</c>.
/// </summary>
/// <remarks>
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
/// <summary>
/// Default <see cref="IAuditRedactionFailureCounter"/> binding used when the
/// Site Health Monitoring bridge has not been wired yet. Bundle C replaces
/// Site Health Monitoring bridge has not been wired yet. Replaces
/// this registration with the real counter that surfaces in the site health
/// report payload as <c>AuditRedactionFailure</c>.
/// </summary>
@@ -11,7 +11,7 @@ public sealed class NoOpAuditRedactionFailureCounter : IAuditRedactionFailureCou
/// <inheritdoc/>
public void Increment()
{
// Intentionally empty — Bundle C overrides this binding with the real
// Intentionally empty — overrides this binding with the real
// health-metric counter.
}
}
@@ -19,12 +19,12 @@ using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWri
namespace ZB.MOM.WW.ScadaBridge.AuditLog;
/// <summary>
/// Composition root for the Audit Log (#23) component.
/// Composition root for the Audit Log component.
/// </summary>
/// <remarks>
/// <para>
/// M1 registered <see cref="AuditLogOptions"/> + the validator. M2 Bundle E
/// extends the surface with the site-side writer chain
/// Registered <see cref="AuditLogOptions"/> + the validator. Extends the
/// surface with the site-side writer chain
/// (<see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/>) and the telemetry collaborators
/// (<see cref="ISiteAuditQueue"/>, <see cref="ISiteStreamAuditClient"/>,
@@ -32,8 +32,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog;
/// <see cref="SqliteAuditWriterOptions"/>).
/// </para>
/// <para>
/// Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call
/// Audit (#22). <c>IAuditLogRepository</c> is registered by
/// Audit Log sits alongside Notification Outbox and Site Call
/// Audit. <c>IAuditLogRepository</c> is registered by
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
/// so the caller (the Host on the central node) must also call that.
/// </para>
@@ -59,7 +59,7 @@ public static class ServiceCollectionExtensions
public const string ReconciliationSectionName = "AuditLog:Reconciliation";
/// <summary>
/// Registers the Audit Log (#23) component services: options, the site
/// Registers the Audit Log component services: options, the site
/// SQLite writer chain (primary + ring fallback + failure-counter sink),
/// and the site-→central telemetry collaborators. Idempotent re-registration
/// is not supported; call this exactly once per <see cref="IServiceCollection"/>.
@@ -72,7 +72,7 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
// Top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
// Collapsed onto the shared ZB.MOM.WW.Configuration helper: it binds the
// "AuditLog" section, registers the validator, and enables ValidateOnStart in
// one call. Same section path as before; AddAuditLog is call-once per
@@ -80,20 +80,19 @@ public static class ServiceCollectionExtensions
// validator (a strict improvement over the previous AddSingleton).
services.AddValidatedOptions<AuditLogOptions, AuditLogOptionsValidator>(config, ConfigSectionName);
// C3 (Task 2.5): the canonical IAuditRedactor is wired as
// The canonical IAuditRedactor is wired as
// ScadaBridgeAuditRedactor — same truncation + header / body /
// SQL-parameter redaction as the original pipeline, applied between
// event construction and persistence. Singleton — stateless; the
// IOptionsMonitor dependency picks up hot reloads on its own.
services.AddSingleton<IAuditRedactor, ScadaBridgeAuditRedactor>();
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
// Bundle C replaces this binding with the Site Health Monitoring
// bridge that surfaces failures as AuditRedactionFailure on the site
// health report.
// Per-stage redactor-failure counter. NoOp default; this binding is
// later replaced with the Site Health Monitoring bridge that surfaces
// failures as AuditRedactionFailure on the site health report.
services.TryAddSingleton<IAuditRedactionFailureCounter, NoOpAuditRedactionFailureCounter>();
// M2 Bundle E: site writer + telemetry options bindings.
// Site writer + telemetry options bindings.
// BindConfiguration is not used because the configuration root supplied
// by the caller may not be the application root — we go through the
// section explicitly so a partial IConfiguration (e.g. a test stub
@@ -113,18 +112,18 @@ public static class ServiceCollectionExtensions
// RingBufferFallback: drop-oldest in-memory ring used by
// FallbackAuditWriter when the primary SQLite writer throws. Default
// capacity is fine for M2 (1024).
// capacity is fine (1024).
services.AddSingleton<RingBufferFallback>();
// IAuditWriteFailureCounter: NoOp default. Bundle G overrides this
// binding with the real Site Health Monitoring counter. Registered
// IAuditWriteFailureCounter: NoOp default. This binding is later
// overridden with the real Site Health Monitoring counter. Registered
// before FallbackAuditWriter so the factory can resolve it.
services.AddSingleton<IAuditWriteFailureCounter, NoOpAuditWriteFailureCounter>();
// The script-thread surface is FallbackAuditWriter (primary + ring +
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
// abort the user-facing action.
// C3 (Task 2.5): the canonical IAuditRedactor singleton above is wired
// The canonical IAuditRedactor singleton above is wired
// through the factory so every event written through this surface is
// truncated + redacted before it hits SQLite (and the ring on
// failure).
@@ -145,72 +144,71 @@ public static class ServiceCollectionExtensions
// created during Akka bootstrap, not at DI-composition time).
services.AddSingleton<ISiteStreamAuditClient, NoOpSiteStreamAuditClient>();
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
// Site-side dual emitter for cached-call lifecycle
// telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
// Database.CachedWrite resolves this through DI and pushes one combined
// packet per lifecycle event; the forwarder writes the audit half
// through IAuditWriter and the operational half through the
// IOperationTrackingStore. The audit writer is always wired (the M2
// IOperationTrackingStore. The audit writer is always wired (the
// chain above); the operational tracking store is SITE-ONLY (registered
// by ZB.MOM.WW.ScadaBridge.SiteRuntime). On a Central composition root the tracking
// store has no registration, so the factory resolves it with GetService
// (returning null) — the forwarder degrades to "audit-only" emission,
// mirroring the lazy IAuditWriter chain established in M2.
// mirroring the lazy IAuditWriter chain established above.
services.AddSingleton<ICachedCallTelemetryForwarder>(sp =>
new CachedCallTelemetryForwarder(
sp.GetRequiredService<IAuditWriter>(),
sp.GetService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IOperationTrackingStore>(),
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(),
// AuditLog-007: INodeIdentityProvider is now required across
// INodeIdentityProvider is now required across
// every consumer in AddAuditLog. The Host's
// SiteServiceRegistration registers it as a singleton on both
// site and central paths (InboundAPI-022 / Host registration
// sweep), and the AddAuditLogTests fixture provides a
// site and central paths, and the AddAuditLogTests fixture provides a
// FakeNodeIdentityProvider; a silent GetService() that
// returned null would mask a future composition root that
// forgot to register the provider.
sp.GetRequiredService<INodeIdentityProvider>()));
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
// Bridge the store-and-forward retry-loop observer hook
// to the cached-call forwarder so per-attempt + terminal telemetry
// emitted from the S&F retry sweep lands on the same SQLite hot-path
// as the script-thread CachedSubmit row. Registered as a singleton
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
// can resolve it through DI (Bundle F StoreAndForward wiring change).
// SourceNode-stamping (Task 14): factory-resolved so the
// can resolve it through DI.
// SourceNode-stamping: factory-resolved so the
// INodeIdentityProvider singleton can be threaded through — the
// bridge stamps SiteCallOperational.SourceNode from
// INodeIdentityProvider.NodeName on every cached-call lifecycle row.
// AuditLog-007: the provider is resolved with GetRequiredService —
// The provider is resolved with GetRequiredService —
// SiteServiceRegistration.BindSharedOptions registers it on both
// site and central paths, so a missing registration is a
// composition-root bug, not a silent null-SourceNode degradation.
services.AddSingleton<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge(
sp.GetRequiredService<ICachedCallTelemetryForwarder>(),
sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(),
// AuditLog-007: required, matches the other consumers in this
// Required, matches the other consumers in this
// composition root — the provider is always registered by
// SiteServiceRegistration.
sp.GetRequiredService<INodeIdentityProvider>()));
services.AddSingleton<ICachedCallLifecycleObserver>(
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
// M6 Bundle E (T8): central audit-write failure counter — NoOp default
// Central audit-write failure counter — NoOp default
// for site/test composition roots that don't wire the central health
// snapshot. AddAuditLogCentralMaintenance below replaces this binding
// with the AuditCentralHealthSnapshot implementation so increments
// surface on the central dashboard.
services.TryAddSingleton<ICentralAuditWriteFailureCounter, NoOpCentralAuditWriteFailureCounter>();
// M5.3 (T7): inbound body-ceiling hit counter — NoOp default for
// Inbound body-ceiling hit counter — NoOp default for
// site/test roots. AddAuditLogCentralMaintenance replaces this binding
// with the AuditCentralHealthSnapshot implementation so ceiling-hit
// counts surface on the central dashboard alongside write-failure and
// redaction-failure counters.
services.TryAddSingleton<IAuditInboundCeilingHitsCounter, NoOpAuditInboundCeilingHitsCounter>();
// M4 Bundle B: central direct-write audit writer used by
// NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to
// Central direct-write audit writer used by
// NotificationOutboxActor and Inbound API to
// emit AuditLog rows that originate ON central, not via site telemetry.
// Singleton — the writer is stateless; its per-call scope opens a fresh
// IAuditLogRepository (a SCOPED EF Core service registered by
@@ -218,17 +216,17 @@ public static class ServiceCollectionExtensions
// is intentionally distinct from IAuditWriter so site composition roots
// do not accidentally bind it; central composition roots that include
// AddConfigurationDatabase get a working implementation transparently.
// C3 (Task 2.5): wire the canonical IAuditRedactor into the factory so
// Wire the canonical IAuditRedactor into the factory so
// NotificationOutboxActor + Inbound API rows are truncated + redacted
// before they hit MS SQL.
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
// Also wire the ICentralAuditWriteFailureCounter
// so swallowed repo throws bump the central health counter.
services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
sp,
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
sp.GetRequiredService<IAuditRedactor>(),
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
// SourceNode-stamping (Task 12): wire the local node identity so
// SourceNode-stamping: wire the local node identity so
// central-origin rows (Notification Outbox dispatch, Inbound API)
// carry the writing node's identifier when the caller hasn't
// already supplied one. GetRequiredService — the production
@@ -236,7 +234,7 @@ public static class ServiceCollectionExtensions
// provider as a singleton on both site and central paths.
sp.GetRequiredService<INodeIdentityProvider>()));
// M6 (K8) KPI History & Trends: the AuditLog source the central KPI
// KPI History & Trends: the AuditLog source the central KPI
// recorder enumerates each sampling pass to snapshot Global-scope Audit
// Log KPIs (volume / error rate / backlog) into the KpiSample history
// table. Scoped to match its IAuditLogRepository dependency — a SCOPED
@@ -252,7 +250,7 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Audit Log (#23) M2 Bundle G + M5 Bundle C — swap the default
/// Swap the default
/// <see cref="NoOpAuditWriteFailureCounter"/> and
/// <see cref="NoOpAuditRedactionFailureCounter"/> registrations for the
/// real <see cref="HealthMetricsAuditWriteFailureCounter"/> /
@@ -282,10 +280,10 @@ public static class ServiceCollectionExtensions
/// double-registered (AddHostedService has no TryAdd variant).
/// </para>
/// <para>
/// Site-side only for M5: the central composition root keeps the NoOp
/// Site-side only: the central composition root keeps the NoOp
/// defaults; the central health-metric surface that would expose
/// <c>AuditRedactionFailure</c> next to the existing central counters
/// ships in M6.
/// ships later.
/// </para>
/// </remarks>
/// <param name="services">The service collection to register into.</param>
@@ -294,7 +292,7 @@ public static class ServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
// AuditLog-011: guard against double-registration. AddHostedService is
// Guard against double-registration. AddHostedService is
// additive (no TryAdd variant) so a second call without this sentinel
// would spin up a second SiteAuditBacklogReporter, doubling the 30 s
// SQL probe rate and racing on the ISiteHealthCollector snapshot. The
@@ -310,7 +308,7 @@ public static class ServiceCollectionExtensions
ServiceDescriptor.Singleton<IAuditWriteFailureCounter, HealthMetricsAuditWriteFailureCounter>());
services.Replace(
ServiceDescriptor.Singleton<IAuditRedactionFailureCounter, HealthMetricsAuditRedactionFailureCounter>());
// M6 Bundle E (T6): the site-side backlog reporter polls the
// The site-side backlog reporter polls the
// SqliteAuditWriter every 30 s and pushes the snapshot into the
// collector so the next SiteHealthReport carries a fresh
// SiteAuditBacklog field. Registered alongside the other site-only
@@ -322,7 +320,7 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Audit Log (#23) M6-T5 Bundle D — central-only registration for the
/// Central-only registration for the
/// <see cref="AuditLogPartitionMaintenanceService"/> hosted service plus
/// its <see cref="AuditLogPartitionMaintenanceOptions"/> binding. Must be
/// called from the Central role's composition root (not from a site
@@ -372,7 +370,7 @@ public static class ServiceCollectionExtensions
services.AddOptions<SiteAuditReconciliationOptions>()
.Bind(config.GetSection(ReconciliationSectionName));
// M6 Bundle E (T8 + T9): central health snapshot — a single object
// Central health snapshot — a single object
// that owns the CentralAuditWriteFailures + AuditRedactionFailure
// Interlocked counters AND surfaces them on
// IAuditCentralHealthSnapshot. The same instance is bound to BOTH
@@ -391,7 +389,7 @@ public static class ServiceCollectionExtensions
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>());
services.Replace(ServiceDescriptor.Singleton<ICentralAuditWriteFailureCounter>(
sp => sp.GetRequiredService<AuditCentralHealthSnapshot>()));
// M6 Bundle E (T9): override the NoOp IAuditRedactionFailureCounter
// Override the NoOp IAuditRedactionFailureCounter
// (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter
// bridge so payload-filter throws on CentralAuditWriter /
// AuditLogIngestActor paths surface on the central dashboard. The
@@ -400,11 +398,11 @@ public static class ServiceCollectionExtensions
// counter as CentralAuditWriteFailures. The site composition root
// overrides this binding AGAIN via AddAuditLogHealthMetricsBridge —
// central nodes do not call that bridge, so this is the final
// binding on a central host. Mirrors the M5 Bundle C
// binding on a central host. Mirrors the
// HealthMetricsAuditRedactionFailureCounter shape one-for-one.
services.Replace(ServiceDescriptor.Singleton<IAuditRedactionFailureCounter,
CentralAuditRedactionFailureCounter>());
// M5.3 (T7): replace the NoOp IAuditInboundCeilingHitsCounter with the
// Replace the NoOp IAuditInboundCeilingHitsCounter with the
// AuditCentralHealthSnapshot so ceiling-hit counts surface on the
// central dashboard. Same singleton-forward pattern as
// ICentralAuditWriteFailureCounter above.
@@ -415,7 +413,7 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Audit Log (#23) M6 — central-only registration of the production
/// Audit Log — central-only registration of the production
/// <see cref="IPullAuditEventsClient"/> (<see cref="GrpcPullAuditEventsClient"/>)
/// and its unary-call invoker (<see cref="GrpcPullAuditEventsInvoker"/>) used
/// by <see cref="SiteAuditReconciliationActor"/> to pull reconciliation
@@ -500,7 +498,7 @@ public static class ServiceCollectionExtensions
sp.GetRequiredService<GrpcPullAuditEventsClient.IPullAuditEventsInvoker>(),
sp.GetRequiredService<ILogger<GrpcPullAuditEventsClient>>()));
// Site Call Audit (#22) reconciliation pull client — central-only, the
// Site Call Audit reconciliation pull client — central-only, the
// sibling of the audit pull client above. Lives here (not in the
// SiteCallAudit project) so it can reuse the central-only
// ISiteEnumerator registered just above; SiteCallAudit does not
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <para>
/// Each primary failure increments <see cref="IAuditWriteFailureCounter"/> so
/// Site Health Monitoring can surface a sustained outage as
/// <c>SiteAuditWriteFailures</c> (Bundle G).
/// <c>SiteAuditWriteFailures</c>.
/// </para>
/// <para>
/// Errors raised by the ring drain on recovery are logged and silently dropped
@@ -36,7 +36,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
private readonly SemaphoreSlim _drainGate = new(1, 1);
/// <summary>
/// Bundle C (M5-T6) wires the singleton <see cref="IAuditRedactor"/>
/// Wires the singleton <see cref="IAuditRedactor"/>
/// here so every event written via the site hot path is truncated +
/// header/body/SQL-param redacted before it hits both the primary SQLite
/// writer AND the ring fallback. The parameter is optional (defaults to
@@ -62,8 +62,8 @@ public sealed class FallbackAuditWriter : IAuditWriter
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to a null redactor — over-redact instead.
// C3 (Task 2.5): wired via the canonical IAuditRedactor seam.
// Never default to a null redactor — over-redact instead.
// Wired via the canonical IAuditRedactor seam.
// SafeDefaultAuditRedactor performs HTTP header redaction with the
// hard-coded sensitive defaults (Authorization, X-Api-Key, Cookie,
// Set-Cookie) on the DetailsJson summaries so a test composition root
@@ -82,7 +82,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
// and (on failure) to the ring buffer — so a primary outage that
// drains later still hands the SqliteAuditWriter a row that has
// already been truncated and redacted. The redactor contract is
// "MUST NOT throw". AuditLog-008: _redactor is now non-null (defaults
// "MUST NOT throw". _redactor is now non-null (defaults
// to SafeDefaultAuditRedactor so header redaction is always applied
// even in composition roots that don't wire the real redactor).
var filtered = _redactor.Apply(evt);
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Audit Log (#23) M5 Bundle C — bridges
/// Bridges
/// <see cref="IAuditRedactionFailureCounter"/> (incremented by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> every time
/// a header / body / SQL parameter redactor stage throws and the redactor has
@@ -22,16 +22,15 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// redaction failures must NEVER abort the user-facing action, alog.md §7).
/// </para>
/// <para>
/// Mirrors the M2 Bundle G <see cref="HealthMetricsAuditWriteFailureCounter"/>
/// Mirrors the <see cref="HealthMetricsAuditWriteFailureCounter"/>
/// shape one-for-one so the two health-metric bridges age together.
/// </para>
/// <para>
/// Site-side only for M5: the redaction filter also runs on the central
/// writers (CentralAuditWriter + AuditLogIngestActor), but the central
/// health-metric surface that would expose <c>AuditRedactionFailure</c>
/// alongside the existing central counters ships in M6. Until then, the
/// central composition root keeps the NoOp default — the redactions still
/// happen, they just don't get counted into a health report.
/// Site-side only: the redaction filter also runs on the central
/// writers (CentralAuditWriter + AuditLogIngestActor); the central
/// health-metric surface exposing <c>AuditRedactionFailure</c> is bridged
/// separately by <c>CentralAuditRedactionFailureCounter</c>, which the
/// central composition root registers in place of the NoOp default.
/// </para>
/// </remarks>
public sealed class HealthMetricsAuditRedactionFailureCounter : IAuditRedactionFailureCounter
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Audit Log (#23) M2 Bundle G — bridges <see cref="IAuditWriteFailureCounter"/>
/// Bridges <see cref="IAuditWriteFailureCounter"/>
/// (incremented by <see cref="FallbackAuditWriter"/> every time the primary
/// SQLite writer throws) into <see cref="ISiteHealthCollector"/> so the count
/// surfaces in the site health report payload as
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Lightweight counter sink invoked by <see cref="FallbackAuditWriter"/> every
/// time the primary <see cref="SqliteAuditWriter"/> throws on an audit write.
/// Bundle G (M2-T11) implements this as a thread-safe Interlocked counter
/// Implements this as a thread-safe Interlocked counter
/// bridged into the Site Health Monitoring report payload as
/// <c>SiteAuditWriteFailures</c>.
/// </summary>
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Default <see cref="IAuditWriteFailureCounter"/> registered by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.ServiceCollectionExtensions.AddAuditLog"/> on
/// every node. Bundle G replaces this binding with a real counter that bridges
/// every node. This binding will be replaced with a real counter that bridges
/// into the Site Health Monitoring report payload as
/// <c>SiteAuditWriteFailures</c> — until then,
/// <see cref="FallbackAuditWriter"/> emits to a silent sink rather than NRE-ing
@@ -19,7 +19,7 @@ public sealed class NoOpAuditWriteFailureCounter : IAuditWriteFailureCounter
/// <inheritdoc/>
public void Increment()
{
// Intentionally empty. Bundle G overrides this binding with the real
// Intentionally empty. This binding is replaced with the real
// counter once Site Health Monitoring is wired.
}
}
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// count at capacity, exactly one event has been dropped.
/// </para>
/// <para>
/// Per the M2 plan: the ring is the absolute-last-resort buffer for the
/// The ring is the absolute-last-resort buffer for the
/// hot-path; it is NOT a substitute for the bounded
/// <see cref="SqliteAuditWriter"/> write queue.
/// </para>
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Audit Log (#23) M6 Bundle E (T6) — site-side hosted service that
/// Site-side hosted service that
/// periodically pulls a backlog snapshot from <see cref="ISiteAuditQueue"/>
/// and pushes it into <see cref="ISiteHealthCollector"/> so the next
/// <see cref="ISiteHealthCollector.CollectReport"/> emits a fresh
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// across many reports, fine enough that the central dashboard never lags by
/// more than one health-report interval. Tunable via
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.SqliteAuditWriterOptions"/> in a follow-up
/// if ops needs a different cadence; for M6 we hard-code the value because the
/// if ops needs a different cadence; for now we hard-code the value because the
/// brief calls it out explicitly.
/// </para>
/// <para>
@@ -12,7 +12,7 @@ using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Site-side SQLite hot-path writer for Audit Log (#23) events. Mirrors the
/// Site-side SQLite hot-path writer for Audit Log events. Mirrors the
/// <see cref="ZB.MOM.WW.ScadaBridge.SiteEventLogging.SiteEventLogger"/> design — a single
/// owned <see cref="SqliteConnection"/> serialised behind a write lock, fed by a
/// bounded <see cref="Channel{T}"/> drained on a dedicated background writer
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </summary>
/// <remarks>
/// <para>
/// <b>C4 (Task 2.5) — two-table schema.</b> The site store is now two tables:
/// <b>Two-table schema.</b> The site store is now two tables:
/// the append-only canonical <c>audit_event</c> (the 10 canonical
/// <see cref="AuditEvent"/> fields stored directly — NO 24-column decompose) and
/// the mutable operational <c>audit_forward_state</c> sidecar that carries the
@@ -31,10 +31,10 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// </para>
/// <para>
/// <b>Ephemeral reset.</b> The site SQLite store is ephemeral (≈7-day retention,
/// recreated per deployment), so C4's schema change is an in-place RESET: the new
/// recreated per deployment), so this schema change is an in-place RESET: the new
/// tables are created and the old single 24-column <c>AuditLog</c> table is
/// DROP-ped if present. No SQLite data migration is performed (and none is
/// needed) — any rows in a pre-C4 <c>AuditLog</c> table are within the retention
/// needed) — any rows in the old <c>AuditLog</c> table are within the retention
/// window and are discarded by the drop.
/// </para>
/// <para>
@@ -56,7 +56,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private const int SqliteErrorConstraint = 19;
private readonly SqliteConnection _connection;
// AuditLog-005: dedicated read-only connection used by GetBacklogStatsAsync,
// Dedicated read-only connection used by GetBacklogStatsAsync,
// ReadPendingAsync, ReadPendingSinceAsync, and ReadForwardedAsync so a slow
// backlog scan (COUNT(*) over hundreds of thousands of Pending rows under a
// central outage) never parks the hot-path writer behind _writeLock.
@@ -102,7 +102,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
InitializeSchema();
// AuditLog-005: open a second connection for read-only callers
// Open a second connection for read-only callers
// (GetBacklogStatsAsync, ReadPendingAsync, ReadPendingSinceAsync,
// ReadForwardedAsync). InitializeSchema set journal_mode=WAL on the
// writer connection, which is a database-level setting that persists
@@ -128,7 +128,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
new BoundedChannelOptions(_options.ChannelCapacity)
{
// The hot-path enqueue must back-pressure if the background
// writer falls behind; a higher-level fallback (Bundle B-T4)
// writer falls behind; a higher-level fallback
// handles truly catastrophic primary failure with a drop-oldest
// ring buffer.
FullMode = BoundedChannelFullMode.Wait,
@@ -150,7 +150,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery();
}
// AuditLog-005: enable WAL so a second connection on the same file can
// Enable WAL so a second connection on the same file can
// serve read-only callers (GetBacklogStatsAsync, ReadPendingAsync,
// ReadPendingSinceAsync, ReadForwardedAsync) concurrently with the
// batched writer, decoupling those reads from _writeLock. WAL is a
@@ -185,14 +185,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery();
}
// C4 (Task 2.5) — in-place reset. The site store is EPHEMERAL (≈7-day
// In-place reset. The site store is EPHEMERAL (≈7-day
// retention, recreated per deployment), so we do NOT migrate the old
// single 24-column AuditLog table to the new two-table shape: any rows
// it holds are within the retention window and discarded. DROP it if a
// pre-C4 deployment left it behind, then CREATE the two new tables. This
// it holds are within the retention window and discarded. DROP it if an
// earlier deployment left it behind, then CREATE the two new tables. This
// is safe precisely BECAUSE the site store is ephemeral — never do this
// on a durable store (the central SQL Server side keeps its shim until
// C5 and is migrated, not reset).
// it is migrated, not reset).
using (var dropCmd = _connection.CreateCommand())
{
dropCmd.CommandText = "DROP TABLE IF EXISTS AuditLog;";
@@ -442,10 +442,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
}
// AuditLog-001: cached-lifecycle audit kinds that ride the combined-telemetry
// Cached-lifecycle audit kinds that ride the combined-telemetry
// drain (joined with the operational tracking row + pushed via
// IngestCachedTelemetryAsync into the central dual-write transaction).
// C4: this is the SAME set the pre-C4 ReadPendingCachedTelemetryAsync query
// This is the SAME set the earlier ReadPendingCachedTelemetryAsync query
// filtered on (Kind IN (...)); it is now precomputed into the sidecar's
// IsCachedKind flag at INSERT (see IsCachedKind) so the read split is a cheap
// integer predicate, not a JSON parse. ReadPendingAsync drains everything
@@ -459,7 +459,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
};
/// <summary>
/// C4: precomputes the sidecar's <c>IsCachedKind</c> flag from a canonical
/// Precomputes the sidecar's <c>IsCachedKind</c> flag from a canonical
/// row's <c>DetailsJson</c>. Parses the <see cref="AuditDetails.Kind"/>
/// discriminator via <see cref="AuditDetailsCodec"/> and returns <c>true</c>
/// iff it is one of the cached-lifecycle kinds
@@ -489,14 +489,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// AuditLog-005: read via the dedicated _readConnection so this scan
// Read via the dedicated _readConnection so this scan
// (which can be expensive when the backlog grows under a central
// outage) does not block the batched writer on _writeLock. WAL mode
// gives us a stable snapshot of the tables while writes proceed on the
// writer connection. _readLock serialises this connection across
// multiple concurrent read callers since SqliteConnection itself is
// not thread-safe.
// C4: JOIN the sidecar and filter on IsCachedKind=0 — the cached-
// JOIN the sidecar and filter on IsCachedKind=0 — the cached-
// lifecycle kinds (IsCachedKind=1) flow through
// ReadPendingCachedTelemetryAsync + the combined-telemetry drain. The
// split is a precomputed integer predicate on the indexed sidecar, not
@@ -539,7 +539,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// AuditLog-001 / C4: dedicated read surface for the cached-call lifecycle
// Dedicated read surface for the cached-call lifecycle
// drain — symmetric to ReadPendingAsync but filtered to IsCachedKind=1.
// Same _readConnection + _readLock pattern so the hot-path writer is not
// contended.
@@ -586,9 +586,9 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection /
// Mirror ReadPendingAsync — read via _readConnection /
// _readLock so this query never contends with the batched writer on
// _writeLock. C4: JOIN the sidecar and filter on ForwardState='Forwarded'
// _writeLock. JOIN the sidecar and filter on ForwardState='Forwarded'
// (no IsCachedKind split — both cached and non-cached Forwarded rows are
// returned, as before).
lock (_readLock)
@@ -626,7 +626,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
// C4: flip the sidecar — UPDATE audit_forward_state, not the canonical
// Flip the sidecar — UPDATE audit_forward_state, not the canonical
// audit_event (which is append-only / write-once). Bump AttemptCount +
// stamp LastAttemptUtc so operators can see how many drain passes a row
// took to forward. Build a single IN (...) parameter list so we issue
@@ -667,7 +667,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// <summary>
/// Returns up to <paramref name="batchSize"/> pending or forwarded audit events
/// with <see cref="AuditEvent.OccurredAtUtc"/> &gt;= <paramref name="sinceUtc"/>, oldest first.
/// Used by the M6 reconciliation-pull handler.
/// Used by the reconciliation-pull handler.
/// </summary>
/// <param name="sinceUtc">Lower bound timestamp (UTC) for event occurrence.</param>
/// <param name="batchSize">Maximum number of rows to return.</param>
@@ -681,8 +681,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(batchSize), "batchSize must be > 0.");
}
// AuditLog-005: read via _readConnection / _readLock — same lock-
// decoupling as ReadPendingAsync. C4: JOIN the sidecar; the range scan
// Read via _readConnection / _readLock — same lock-
// decoupling as ReadPendingAsync. JOIN the sidecar; the range scan
// is on the sidecar's duplicated OccurredAtUtc so it stays on IX_fwd.
// Both Pending and Forwarded rows are returned (the central reconciliation
// puller dedups on EventId; re-shipping a Forwarded-but-not-yet-ingested
@@ -729,7 +729,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
// C4: flip the sidecar from Pending/Forwarded → Reconciled. Rows
// Flip the sidecar from Pending/Forwarded → Reconciled. Rows
// already Reconciled are left untouched (idempotent re-call), and the
// canonical audit_event row is never modified.
var sb = new System.Text.StringBuilder();
@@ -759,13 +759,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
int pendingCount;
DateTime? oldestPending;
// AuditLog-005: read via the dedicated _readConnection (under
// Read via the dedicated _readConnection (under
// _readLock) so this probe — polled every 30 s by SiteAuditBacklogReporter
// — never blocks the batched hot-path writer on _writeLock. Under a
// central outage the Pending backlog can grow to hundreds of thousands
// of rows and the COUNT(*) scan correspondingly stretches; that no
// longer adds tail latency to user-facing audit writes.
// C4: count over the sidecar (audit_forward_state) — the canonical
// Count over the sidecar (audit_forward_state) — the canonical
// audit_event table carries no ForwardState. The IX_fwd index makes both
// aggregates cheap (count is a covering scan, min is the first key).
lock (_readLock)
@@ -843,7 +843,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
}
/// <summary>
/// C4: builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10
/// Builds the canonical <see cref="AuditEvent"/> DIRECTLY from the 10
/// stored <c>audit_event</c> columns — no 24-column <c>Recompose</c>, because
/// <c>audit_event</c> already holds the canonical fields + <c>DetailsJson</c>.
/// <c>Outcome</c> is stored as the enum's name; the safe
@@ -875,7 +875,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
/// Disposes the audit writer and releases resources.
/// </summary>
/// <remarks>
/// AuditLog-006: prefer <see cref="DisposeAsync"/> when possible (DI honours
/// Prefer <see cref="DisposeAsync"/> when possible (DI honours
/// <see cref="IAsyncDisposable"/> on singletons). The sync path remains for
/// callers that only know about <see cref="IDisposable"/> (e.g. legacy
/// composition roots, <c>using</c> statements without <c>await</c>). To
@@ -938,7 +938,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
_connection.Dispose();
}
// AuditLog-005: dispose the dedicated read connection after the writer
// Dispose the dedicated read connection after the writer
// is fully drained and closed. _readLock is taken to fence out any
// in-flight read caller that grabbed the lock before _disposed flipped
// — they observe ObjectDisposedException on the next attempt.
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// <summary>
/// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt
/// Translates per-attempt
/// notifications from the store-and-forward retry loop into one (or two)
/// <see cref="CachedCallTelemetry"/> packets and pushes them through
/// <see cref="ICachedCallTelemetryForwarder"/>.
@@ -40,7 +40,7 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
private readonly ILogger<CachedCallLifecycleBridge> _logger;
/// <summary>
/// SourceNode-stamping (Task 14): the local node identity provider used to
/// SourceNode-stamping: the local node identity provider used to
/// stamp <c>SiteCallOperational.SourceNode</c> on every cached-call
/// lifecycle row this bridge emits. Optional — when null (legacy hosts /
/// tests that don't register the provider) SourceNode stays null and
@@ -93,8 +93,8 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
// Per-attempt row: kind discriminates channel; status is always
// Attempted regardless of outcome (success vs. failure is captured
// by the companion HttpStatus / ErrorMessage fields, NOT by flipping
// the status — CachedResolve carries the terminal Status). Per the
// M3 brief and alog.md §4.
// the status — CachedResolve carries the terminal Status). Per
// alog.md §4.
var kind = ChannelToAttemptKind(context.Channel);
var status = AuditStatus.Attempted;
@@ -148,22 +148,22 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
occurredAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc),
target: context.Target,
correlationId: context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script
// The originating script
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
// buffer; null on rows buffered before ExecutionId support was added (back-compat).
executionId: context.ExecutionId,
// Audit Log #23 (ParentExecutionId Task 6): the spawning
// The spawning
// inbound-API request's ExecutionId, threaded through the S&F
// buffer alongside ExecutionId so the retry-loop cached rows
// correlate back to the cross-execution chain. Null for a
// non-routed run and on rows buffered before Task 6.
// non-routed run and on rows buffered before ParentExecutionId support was added.
parentExecutionId: context.ParentExecutionId,
sourceSiteId: string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
sourceInstanceId: context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
// SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows.
// script-side cached rows do. Null on rows buffered before ExecutionId support was added.
sourceScript: context.SourceScript,
httpStatus: httpStatus,
durationMs: context.DurationMs,
@@ -173,7 +173,7 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
Channel: context.Channel,
Target: context.Target,
SourceSite: context.SourceSite,
// SourceNode-stamping (Task 14): the local cluster node name
// SourceNode-stamping: the local cluster node name
// (node-a/node-b on a site). Stamped from the injected
// INodeIdentityProvider; null when no provider was wired so
// central persists SiteCalls.SourceNode as NULL.
@@ -9,15 +9,15 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// <summary>
/// Site-side dual emitter for cached-call lifecycle telemetry (Audit Log #23 /
/// M3). Sister to <see cref="SiteAuditTelemetryActor"/>: where the M2 actor
/// Site-side dual emitter for cached-call lifecycle telemetry. Sister to
/// <see cref="SiteAuditTelemetryActor"/>: where that actor
/// drains audit-only events, this forwarder takes a combined
/// <see cref="CachedCallTelemetry"/> packet and fans it out to the two
/// site-local stores in a single call:
/// <list type="bullet">
/// <item><description>The <see cref="AuditEvent"/> row is written via
/// <see cref="IAuditWriter"/> (the site <c>FallbackAuditWriter</c> +
/// <c>SqliteAuditWriter</c> chain established in M2).</description></item>
/// <c>SqliteAuditWriter</c> chain).</description></item>
/// <item><description>The operational <see cref="SiteCallOperational"/> half
/// updates the site-local <c>OperationTracking</c> SQLite store via
/// <see cref="IOperationTrackingStore"/>, with the per-lifecycle method
@@ -54,7 +54,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
private readonly ILogger<CachedCallTelemetryForwarder> _logger;
/// <summary>
/// SourceNode-stamping (Task 14): local node identity provider used to
/// SourceNode-stamping: local node identity provider used to
/// stamp the tracking-store row's <c>SourceNode</c> column on
/// <c>RecordEnqueueAsync</c>. Optional — when null (legacy / test hosts)
/// the column stays NULL on the tracking row.
@@ -64,11 +64,11 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
/// <summary>
/// Construct the forwarder. <paramref name="trackingStore"/> is optional —
/// when null only the audit half of the packet is emitted, which matches
/// the M3 Bundle F composition-root contract on Central nodes: the
/// the composition-root contract on Central nodes: the
/// AuditLog DI surface registers the forwarder unconditionally (mirroring
/// the IAuditWriter chain) but the site-only tracking store has no central
/// registration. Production site nodes wire both — the central lazy
/// resolution is a no-op path kept symmetric with the M2 writer chain.
/// resolution is a no-op path kept symmetric with the writer chain.
/// </summary>
/// <param name="auditWriter">Writer used to persist audit events from the telemetry packet.</param>
/// <param name="trackingStore">Optional store for updating operation tracking state; null on central nodes.</param>
@@ -111,7 +111,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// FallbackAuditWriter) handles transient writer failures upstream;
// a throw bubbling up here means the writer's own swallow contract
// failed, which is itself best-effort-handled.
// C3: Kind/Status are domain fields carried in DetailsJson — decompose to log them.
// Kind/Status are domain fields carried in DetailsJson — decompose to log them.
var d = AuditRowProjection.Decompose(telemetry.Audit);
_logger.LogWarning(ex,
"CachedCallTelemetryForwarder: audit emission threw for EventId {EventId} (Kind {Kind}, Status {Status})",
@@ -130,7 +130,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
return;
}
// C3: the audit half's domain fields (Kind/SourceInstanceId/SourceScript)
// The audit half's domain fields (Kind/SourceInstanceId/SourceScript)
// ride inside DetailsJson — decompose once for this packet.
var audit = AuditRowProjection.Decompose(telemetry.Audit);
try
@@ -141,7 +141,7 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
// Enqueue — insert-if-not-exists with the operational
// channel as the kind discriminator. RetryCount is fixed
// at 0 by the tracking store's INSERT contract.
// SourceNode-stamping (Task 14): stamp the local node
// SourceNode-stamping: stamp the local node
// name (node-a/node-b) from the injected
// INodeIdentityProvider; null when no provider was wired
// so the tracking row's SourceNode column stays NULL.
@@ -24,8 +24,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// The <see cref="SiteAuditTelemetryActor"/> drain loop treats a thrown
/// exception as transient and leaves the rows <c>Pending</c> for the next tick.
/// Swallowing the fault into an empty ack would be indistinguishable from "zero
/// rows accepted" and would silently lose the retry signal. Task 1 confirmed
/// the central receiving end does not collapse an ingest fault into an empty
/// rows accepted" and would silently lose the retry signal. It has been confirmed
/// that the central receiving end does not collapse an ingest fault into an empty
/// ack either, so a site-side Ask through the whole path faults cleanly on a
/// central-side timeout.
/// </para>
@@ -26,7 +26,7 @@ public interface ISiteStreamAuditClient
Task<IngestAck> IngestAuditEventsAsync(AuditEventBatch batch, CancellationToken ct);
/// <summary>
/// Forwards the combined <see cref="CachedTelemetryBatch"/> (Audit Log #23)
/// Forwards the combined <see cref="CachedTelemetryBatch"/>
/// to the central cached-telemetry ingest path. Each packet carries both the
/// audit row and the operational <c>SiteCalls</c> upsert; central writes both
/// in a single MS SQL transaction. Returns the same <see cref="IngestAck"/>
@@ -45,7 +45,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// which case the cached-drain scheduler is never armed.
/// </para>
/// <para>
/// Per Bundle D's brief, audit-write paths must be fail-safe — a thrown
/// Audit-write paths must be fail-safe — a thrown
/// exception inside the actor MUST NOT crash it. Both Drain handlers wrap
/// their pipelines in a top-level try/catch that logs and re-schedules; the
/// actor's <see cref="SupervisorStrategy"/> defaults to
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// matters.
/// </para>
/// <para>
/// AuditLog-001: wires the previously-unreachable combined-telemetry transport.
/// Wires the previously-unreachable combined-telemetry transport.
/// Prior to this the cached audit rows flowed through the audit-only drain via
/// <c>IngestAuditEventsAsync</c> and the central <c>OnCachedTelemetryAsync</c>
/// dual-write handler was dead production code; the operational <c>SiteCalls</c>
@@ -70,7 +70,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
private readonly ILogger<SiteAuditTelemetryActor> _logger;
private ICancelable? _pendingTick;
private ICancelable? _pendingCachedTick;
// AuditLog-010: per-actor lifecycle CTS so an in-flight drain (queue read,
// Per-actor lifecycle CTS so an in-flight drain (queue read,
// gRPC push, mark-forwarded write) is actually cancelled when the actor is
// stopped — without it, a stuck IngestAuditEventsAsync would hold the
// continuation through CoordinatedShutdown's actor-system terminate window.
@@ -132,7 +132,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
{
_pendingTick?.Cancel();
_pendingCachedTick?.Cancel();
// AuditLog-010: cancel any in-flight drain so a stuck queue read or
// Cancel any in-flight drain so a stuck queue read or
// gRPC push does not hold the continuation past actor stop.
try
{
@@ -149,7 +149,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
private async Task OnDrainAsync()
{
var nextDelay = TimeSpan.FromSeconds(_options.BusyIntervalSeconds);
// AuditLog-010: route every async dependency call through the
// Route every async dependency call through the
// per-actor lifecycle token so PostStop cancellation actually
// propagates into the queue read, the gRPC push, and the
// mark-forwarded write. OperationCanceledException is swallowed by
@@ -178,7 +178,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
catch (Exception ex)
{
// gRPC fault — leave the rows in Pending so the next drain
// retries. Bundle D's brief: "On gRPC exception (any), log
// retries "On gRPC exception (any), log
// Warning, schedule next Drain in BusyIntervalSeconds."
_logger.LogWarning(ex,
"IngestAuditEvents push failed for {Count} pending events; will retry next drain.",
@@ -201,7 +201,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
}
finally
{
// AuditLog-010: if the actor is already shutting down, do not
// If the actor is already shutting down, do not
// arm another tick — the scheduler would fire after PostStop and
// the message would land in dead letters.
if (!_lifecycleCts.IsCancellationRequested)
@@ -212,7 +212,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
}
/// <summary>
/// AuditLog-001: combined-telemetry drain. Reads cached-lifecycle audit
/// Combined-telemetry drain. Reads cached-lifecycle audit
/// rows, joins each with the matching <see cref="IOperationTrackingStore"/>
/// snapshot, builds a <see cref="CachedTelemetryBatch"/>, and pushes via
/// <see cref="ISiteStreamAuditClient.IngestCachedTelemetryAsync"/>. Rows
@@ -354,7 +354,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
}
/// <summary>
/// AuditLog-001: build the combined wire packet from one cached audit row
/// Build the combined wire packet from one cached audit row
/// + its matching operational tracking snapshot. The operational state
/// reflects the latest tracking row at emission time (not the per-event
/// status the audit row implies) because central's <c>SiteCalls</c>
@@ -364,7 +364,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
private static CachedTelemetryPacket BuildCachedPacket(
AuditEvent auditRow, TrackingStatusSnapshot snapshot)
{
// C3: SourceSiteId + Channel ride inside the canonical record's
// SourceSiteId + Channel ride inside the canonical record's
// DetailsJson — decompose to read them.
var audit = AuditRowProjection.Decompose(auditRow);
var sourceSite = audit.SourceSiteId ?? string.Empty;
@@ -457,7 +457,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
/// <summary>
/// Self-tick message that triggers a combined-telemetry drain cycle.
/// AuditLog-001: introduced alongside the cached-drain to keep the two
/// Introduced alongside the cached-drain to keep the two
/// paths' cadences independent — a stall on one does not block the other.
/// </summary>
private sealed class CachedDrain
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site.Telemetry;
/// <summary>
/// Tuning knobs for the site-side <see cref="SiteAuditTelemetryActor"/> drain
/// loop. Defaults mirror Bundle D's plan: drain every 5 s while rows are
/// loop. Defaults: drain every 5 s while rows are
/// flowing (busy), every 30 s when the queue is empty (idle).
/// </summary>
public sealed class SiteAuditTelemetryOptions
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Bundle D D1: SiteAuditTelemetryActor + (D2) AuditLogIngestActor live
<!-- SiteAuditTelemetryActor + AuditLogIngestActor live
in this project, so Akka is an explicit dependency. -->
<PackageReference Include="Akka" />
<PackageReference Include="Microsoft.Data.Sqlite" />