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);
+ }
+}