diff --git a/docs/plans/2026-07-08-deferred-work-register.md b/docs/plans/2026-07-08-deferred-work-register.md index 35b5426c..1a92e59f 100644 --- a/docs/plans/2026-07-08-deferred-work-register.md +++ b/docs/plans/2026-07-08-deferred-work-register.md @@ -27,7 +27,6 @@ them and are removed from this table when that plan's task lands. | 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports | | 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) | | 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs | -| 21 | SiteAuditBacklogReporter threshold consolidation | SiteAuditBacklogReporter.cs:28 | Config-shape cleanup only | Next SqliteAuditWriterOptions change | | 22 | KPI history hourly rollups | Component-KpiHistory.md | YAGNI; 90-day retention bounds table | KpiSample query latency on dashboards | ## Resolved (verified against the code 2026-07-10) @@ -41,6 +40,7 @@ Rows removed from the Deferred table above once confirmed shipped. Kept here for | 15 | BrowseNext final-page signal not surfaced | Already surfaced (M7 browse work): `RealOpcUaClient` sets `Truncated=false`/`ContinuationToken=null` on the last page; `BrowseNodeResult` carries both; `TreeRow.razor` renders "Load more" only when a continuation token remains — no wasted BrowseNext. | | 16 | StubOpcUaClient throws on browse | Already resolved: `StubOpcUaClient` supports browse + address-space search, covered by `StubOpcUaClientBrowseTests`/`StubOpcUaClientSearchTests`. | | 20 | Deployment EXPIRED-row purge | Already resolved (PLAN-04): `PendingDeploymentPurgeActor` central singleton (spawned in `AkkaHostedService`) ticks `IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync` every `CommunicationOptions.PendingDeploymentPurgeInterval` (default 1h), options-validated, tested. | +| 21 | SiteAuditBacklogReporter threshold consolidation | Shipped 2026-07-10: `SqliteAuditWriterOptions.BacklogPollIntervalSeconds` (default 30) now drives the reporter's poll cadence; explicit ctor override still wins (tests), non-positive falls back to the 30 s default. Cadence tests added; stale "hard-code / follow-up" class-doc corrected. | ## New deferrals from review 08 (this plan) | Item | Rationale | Revisit trigger | diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditBacklogReporter.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditBacklogReporter.cs index 6e597e55..9a09a81f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditBacklogReporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditBacklogReporter.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; @@ -25,9 +26,8 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site; /// Cadence. 30 s by default — coarse enough to amortise the SQL probe /// across many reports, fine enough that the central dashboard never lags by /// more than one health-report interval. Tunable via -/// in a follow-up -/// if ops needs a different cadence; for now we hard-code the value because the -/// brief calls it out explicitly. +/// +/// (a non-positive value falls back to the 30 s default). /// /// /// Failure containment. The probe call is wrapped in a try/catch so a @@ -56,17 +56,42 @@ public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable /// The site audit queue used to probe the backlog count. /// The site health collector that receives the backlog snapshot. /// Logger instance. - /// Poll interval override; defaults to (30 s). + /// + /// Explicit poll-interval override (used by tests). When null, the cadence comes from + /// , falling back to + /// (30 s) when that is unset or non-positive. + /// + /// + /// SQLite audit-writer options supplying the configurable poll cadence + /// (). Optional so + /// direct construction in tests need not build the options graph. + /// public SiteAuditBacklogReporter( ISiteAuditQueue queue, ISiteHealthCollector collector, ILogger logger, - TimeSpan? refreshInterval = null) + TimeSpan? refreshInterval = null, + IOptions? options = null) { _queue = queue ?? throw new ArgumentNullException(nameof(queue)); _collector = collector ?? throw new ArgumentNullException(nameof(collector)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _refreshInterval = refreshInterval ?? DefaultRefreshInterval; + // Precedence: explicit override (tests) → configured cadence → 30 s default. + _refreshInterval = refreshInterval ?? ResolveInterval(options) ?? DefaultRefreshInterval; + } + + /// The resolved poll cadence (exposed for tests). + internal TimeSpan RefreshInterval => _refreshInterval; + + /// + /// Maps to a + /// , or null when unconfigured / non-positive (caller then + /// falls back to ). + /// + private static TimeSpan? ResolveInterval(IOptions? options) + { + var seconds = options?.Value.BacklogPollIntervalSeconds ?? 0; + return seconds > 0 ? TimeSpan.FromSeconds(seconds) : null; } /// Starts the background polling loop, running an immediate first probe before entering the timed cycle. diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriterOptions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriterOptions.cs index f4253f55..3f558a54 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriterOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriterOptions.cs @@ -24,4 +24,13 @@ public sealed class SqliteAuditWriterOptions /// Soft flush interval the writer enforces when fewer than BatchSize events are queued. public int FlushIntervalMs { get; set; } = 50; + + /// + /// Poll cadence, in seconds, for the SiteAuditBacklogReporter hosted service + /// that probes the SQLite audit backlog and pushes the snapshot onto the site health + /// report. Half a typical 60 s health-report interval keeps the snapshot fresh without + /// spinning the SQL probe more often than necessary. A non-positive value falls back to + /// the reporter's 30 s default. + /// + public int BacklogPollIntervalSeconds { get; set; } = 30; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditBacklogReporterCadenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditBacklogReporterCadenceTests.cs new file mode 100644 index 00000000..87a97118 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SiteAuditBacklogReporterCadenceTests.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.AuditLog.Site; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.HealthMonitoring; + +namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; + +/// +/// Deferred-work #21: the backlog reporter's poll cadence is configurable via +/// instead of the +/// old hard-coded 30 s constant. +/// +public class SiteAuditBacklogReporterCadenceTests +{ + private static SiteAuditBacklogReporter Create( + IOptions? options, TimeSpan? explicitInterval = null) => + new( + Substitute.For(), + Substitute.For(), + NullLogger.Instance, + explicitInterval, + options); + + [Fact] + public void Cadence_ComesFromOptions_WhenConfigured() + { + var options = Options.Create(new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = 12 }); + + var reporter = Create(options); + + Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval); + } + + [Fact] + public void Cadence_FallsBackToDefault_WhenOptionsNonPositive() + { + var options = Options.Create(new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = 0 }); + + var reporter = Create(options); + + Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval); + } + + [Fact] + public void Cadence_FallsBackToDefault_WhenNoOptions() + { + var reporter = Create(options: null); + + Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval); + } + + [Fact] + public void ExplicitInterval_WinsOverOptions() + { + var options = Options.Create(new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = 12 }); + + var reporter = Create(options, explicitInterval: TimeSpan.FromSeconds(3)); + + Assert.Equal(TimeSpan.FromSeconds(3), reporter.RefreshInterval); + } +}