34 KiB
Design + Implementation Plan — R2-10 Resilience Observability: an operator-facing reader for DriverResilienceStatusTracker
Status (2026-07-12): PLANNED — not started.
- Source reports:
archreview/03-server-runtime.md(U10) ·archreview/04-adminui.md(U-5) ·archreview/00-OVERALL.mdprioritized action list item #10 ("Resilience observability: a/hostsreader forDriverResilienceStatusTracker(Stream E.2/E.3)" — 03 U10 + 04 U-5, Medium, "operators currently blind"). - History:
archreview/plans/FOLLOWUP-10-resilience-dispatch-gap.md— #10 (bacea1a4) wiredCapabilityInvokerinto all 6 production dispatch sites; #13 (75403caa) made AdminUI-authored per-instanceResilienceConfigoverrides live. The Phase 6.1 "Stream E.2" (snapshot-persisting HostedService) and "E.3" (SignalR push + Blazor/hostspanel) readers were never built; the interim mitigation was retry/breaker structured logging inDriverResiliencePipelineBuilder(added with #10). - Plan verified against tree at:
f6eaa267(master, clean) — every anchor below was opened and re-read at this commit. - Scope:
Core/Resilience(tracker + pipeline builder + invoker — additive only),Commons/Messages/Drivers(one new message),Host/Drivers(one new publisher hosted service + DI),AdminUI/Hubs(one new store + one new bridge actor),AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor(one new section).
Verification summary
Every claim in U10/U-5 was re-verified at f6eaa267. All confirmed — none stale.
- The tracker has no reader.
grep -rn "DriverResilienceStatusTracker" src --include="*.cs"matches onlyCore/Resilience/{DriverResilienceStatusTracker, DriverResiliencePipelineBuilder, CapabilityInvoker, DriverCapabilityInvokerFactory}.csandHost/Drivers/DriverFactoryBootstrap.cs(the DI registration,:57). Zero AdminUI/Runtime references.grep -rn Resilience src/Server/ZB.MOM.WW.OtOpcUa.AdminUImatches only the authoring surface (DriverResilienceSection.razor,ResilienceFormModel.cs, the 8 driver pages). The only production consumers of tracker state are its own writers. - What the tracker holds (
DriverResilienceStatusTracker.cs): aConcurrentDictionary<(DriverInstanceId, HostName), ResilienceStatusSnapshot>—ConsecutiveFailures,LastBreakerOpenUtc,LastRecycleUtc, footprint bytes,CurrentInFlight,LastSampledUtc. Thread-safe (immutable record values,AddOrUpdate);Snapshot()returns a copy safe under concurrent writes — ideal for a periodic reader. Note the key: the tracker aggregates per(instance, host), NOT per capability — pipelines are per(instance, host, capability)(DriverResiliencePipelineBuilder.PipelineKey) but all telemetry callbacks fold into the(instance, host)counter. The read model below therefore keys per instance × host (mirroring the tracker) rather than instance × capability; adding capability granularity would mean re-keying the tracker and every invoker call site — rejected as not the simplest thing (capability is already visible in the #10 log lines when drill-down is needed). - Feeding is real and live:
CapabilityInvokercallsRecordCallStart/Completearound every wrapped dispatch (CapabilityInvoker.cs:70/80/94/104…);DriverResiliencePipelineBuilder.BuildwiresOnRetry → RecordFailure(:131),OnOpened → RecordBreakerOpen(:155),OnClosed → RecordSuccess(:165). Registered singleton + threaded into the builder and invoker factory inDriverFactoryBootstrap.AddOtOpcUaDriverFactories(:57-71). - Two truthfulness gaps found in the tracker itself (must fix for a display to be honest):
- No breaker-close marker.
RecordBreakerOpenstampsLastBreakerOpenUtcbut nothing ever records "closed" —OnClosedonly callsRecordSuccess(resetsConsecutiveFailures). "Is the breaker open now?" is underivable from the snapshot. RecordSuccesshas exactly one caller — the breaker'sOnClosed. A successful capability call never resetsConsecutiveFailures, so after a transient blip with 2 retries the counter reads 2 forever (until a full breaker open/close cycle). The tracker's own xmldoc ("Reset … on a successful pipeline execution") shows the intended-but-unwired call from the invoker's success path — another edge of the house "built-but-never-wired" pattern (00-OVERALL theme #1).
- No breaker-close marker.
- Topology determination (where the tracker lives vs where AdminUI renders):
Host/Program.csgates by role: thehasDriverblock (:104-262) callsAddOtOpcUaDriverFactories()(:195) — the tracker singleton exists only in driver-role processes. ThehasAdminblock mounts the AdminUI (:278-293) and spawns the DPS→SignalR bridge actors viaWithOtOpcUaSignalRBridges()inside the Akka configurator (:269-273). In the split-role MAIN cluster (admin-a/admin-b vs driver nodes) tracker and AdminUI are different processes — state must travel over the cluster. Even on the fused docker-dev rig (central-1/central-2 bothadmin,driver),:9200round-robins the two containers while a givenDriverHostActorchild runs on one of them — so a direct in-process tracker read from the Blazor circuit shows the wrong (often empty) node's state. A cluster transport is mandatory in every deployed topology. - The precedent to mirror (found, verified end-to-end): driver-health already makes exactly this trip.
DriverInstanceActor:994→AkkaDriverHealthPublisher.Publish→ DPS topicdriver-health(DriverHealthChangedrecord inCommons/Messages/Drivers,TopicNameconst on the contract) → per-admin-nodeDriverStatusSignalRBridge(spawned byWithOtOpcUaSignalRBridges,HubServiceCollectionExtensions.cs:69-72) →IDriverStatusSnapshotStoresingleton upsert +SnapshotChangedevent →DriverStatusPanel.razorreads the store in-process (:190-198), explicitly not via a self-targeted SignalRHubConnection(the house ban — a server-side circuit behind Traefik cannot dial its own public URL; documented atIDriverStatusSnapshotStore.cs:28-36andDriverStatusPanel.razor:182-189). This plan mirrors that flow one-for-one. - UI placement facts:
DriverStatusPanelis embedded on all 8 driver pages (Components/Pages/Clusters/Drivers/*DriverPage.razor) — the same pages whereDriverResilienceSectionauthors theResilienceConfigoverrides that #13 made live./hosts(Hosts.razor) shows cluster members + a cluster-scoped driver-health table built byHostsDriverView.Build(DriverStore.GetAll(), …)(:291). - Test landscape: tracker/builder/invoker suites exist at
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/(incl.DriverResilienceStatusTrackerTests.cs,DriverResiliencePipelineBuilderTests.cs,InFlightCounterTests.cs); the DI wiring-guard template istests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceInvokerFactoryRegistrationTests.cs(pure-DI, no Docker fixture); the store-test template istests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverStatusSnapshotStoreTests.cs. AdminUI has no bUnit — razor rendering changes are verified only by the live-/runpass (docker-dev:9200round-robins central-1/central-2; rebuild BOTH).
Restatement
DriverResilienceStatusTracker is registered, fed by every wrapped capability call, retry, and breaker transition — and consumed by nothing. Since #10/#13, retry/breaker/bulkhead behavior is production-live and operator-authorable (per-instance ResilienceConfig on all 8 driver pages), yet the only way to see a breaker reject calls (BrokenCircuitException) or a retry storm is to grep server logs. Operators author live resilience config completely blind. Ship the smallest reader that gives them sight of breaker-open and retry storms, on the page where they author the config.
Verification
Confirmed at f6eaa267 — see Verification summary. Anchors: src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs (no non-writer reference anywhere), DriverResiliencePipelineBuilder.cs:124-170 (the interim log-only surface), src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:57 (singleton registration, driver nodes only).
Root cause
Phase 6.1 shipped Stream E.1 (the tracker + telemetry hooks) and deferred E.2 (snapshot persistence) / E.3 (push + panel) to "a follow-up visual-review PR" (tracker xmldoc, :12-16) that never happened. #10 wired the writer side into production and added log lines as an interim reader; the review (03/U10, 04/U-5) now tracks the missing operator surface. Two smaller instances of the same pattern hide inside the tracker itself (no breaker-close marker; RecordSuccess unwired from the invoker success path).
Proposed design
Mirror the driver-health flow end-to-end. Four pieces, smallest-correct at each hop:
(a) Read model — ResilienceStatusSnapshot gains LastBreakerClosedUtc + a derived IsBreakerOpen (LastBreakerOpenUtc is set and later than any close). One new wire message in Commons:
// Commons/Messages/Drivers/DriverResilienceStatusChanged.cs
public sealed record DriverResilienceStatusChanged(
string DriverInstanceId,
string HostName,
bool BreakerOpen,
int ConsecutiveFailures,
int CurrentInFlight,
DateTime? LastBreakerOpenUtc,
DateTime LastSampledUtc,
DateTime PublishedUtc)
{
public const string TopicName = "driver-resilience-status";
}
Keyed per (instance, host) — matches the tracker key (see Verification summary for why not per-capability). No ClusterId: DriverInstanceId is globally unique and the panel filters on it alone (same as DriverStatusPanel today). Tracker truthfulness fixes ride along: RecordBreakerClose (called from the builder's OnClosed beside the existing RecordSuccess) and RecordSuccess on the invoker's success paths so ConsecutiveFailures is a true consecutive counter (nonzero only during an active storm).
Known display caveat (documented, accepted): Polly half-opens the breaker after BreakDuration without firing OnClosed; OnClosed fires only when a probe call succeeds. An idle driver therefore shows "breaker OPEN" until the next call — truthful enough (the next call is at best a probe) and self-corrects on traffic.
(b) Transport (driver node → admin surface) — a periodic full-snapshot publisher on driver nodes: DriverResilienceStatusPublisherService : BackgroundService in Host/Drivers/, ctor (DriverResilienceStatusTracker, Func<ActorSystem>, ILogger<…>, TimeSpan? interval = null /* default 5 s */). Every tick: tracker.Snapshot() → map each triple to a DriverResilienceStatusChanged (PublishedUtc = now) → DistributedPubSub.Get(system).Mediator.Tell(new Publish(TopicName, msg)) per entry. Loop body in try/catch (a transient DPS/actor-system hiccup must never kill the service); skip empty snapshots. Publishing the full set every tick (not deltas) deliberately self-heals late-subscribing admin nodes — the known redundancy-state "late-subscriber" gotcha — and gives staleness detection for free. Volume is negligible (entries = active instances × hosts, typically < a few dozen, every 5 s). Registered in DriverFactoryBootstrap.AddOtOpcUaDriverFactories next to the tracker it reads (driver nodes only, exactly where the tracker exists), with a TryAddSingleton<Func<ActorSystem>> for self-containedness (idempotent with Program.cs:260).
Why Host, not Runtime: Runtime is deliberately Polly-free — it references Core.Abstractions, not Core where the tracker lives (the exact boundary FOLLOWUP-10 discovered the hard way). Host already references Core, Commons, and Akka (OtOpcUaServerHostedService resolves the DPS mediator via the same lazy Func<ActorSystem> pattern). No new seam needed.
Rejected transport alternatives:
- Direct in-process tracker read from the AdminUI/Blazor circuit — wrong in every deployed topology (split-role MAIN cluster: different process; fused docker-dev:
:9200round-robin lands circuits on a node that isn't running the driver instance). Rejected. - Persist snapshots to a
DriverInstanceResilienceStatustable (the original Stream E.2 shape, still named in the tracker xmldoc) — needs a migration, a poller, and row cleanup; the house idiom has since evolved to DPS → in-memory store, and 04/U-5's own recommendation names that idiom. Rejected; fix the stale xmldoc instead. - Event-driven publish from inside the tracker (hook every
RecordX) — would thread an Akka publisher seam into Polly-free/Akka-free Core, and is chattier under a retry storm exactly when the cluster is stressed. Periodic snapshot is simpler and bounded. Rejected. - Extend
DriverHealthChanged— mutates an existing wire contract and conflates two feeds with different cadences (event-driven vs periodic). Separate topic = zero blast radius. Rejected. - Pull via
AdminOperationsActorask — request/response through the admin-pinned singleton plus panel-side polling; more moving parts than the established push idiom, and the singleton is one node while the store idiom feeds every admin replica (which the round-robin requires). Rejected.
(c) Admin surface — mirror the driver-status pair:
IDriverResilienceStatusStore+InMemoryDriverResilienceStatusStoreinAdminUI/Hubs/: keyed(DriverInstanceId, HostName),Upsert,GetForInstance(driverInstanceId)(per-host list),GetAll(),SnapshotChangedevent (capture-then-invoke, same asInMemoryDriverStatusSnapshotStore). Registered inHubServiceCollectionExtensions.AddOtOpcUaDriverStatusServices.DriverResilienceStatusBridge(ReceiveActor,AdminUI/Hubs/):PreStartsubscribes to the DPS topic; on message,store.Upsert(msg). Spawned per admin node inWithOtOpcUaSignalRBridgesbeside the other four bridges. No new SignalR hub — the only consumer is the server-side panel, which reads the store in-process per the house ban on self-HubConnections;DriverStatusHubexists for browser-JS clients and resilience has none. Add a hub later only if an external consumer appears (rejected for v1 as speculative surface).
(d) UI placement — extend DriverStatusPanel.razor (recommended; embedded on all 8 driver pages — the exact pages where DriverResilienceSection authors the config, closing the authoring feedback loop). New "Resilience" block under the existing status chips: one row per host with
Breaker OPENchip (chip-bad) with "opened Xs ago" whenBreakerOpen, else nothing (healthy is quiet);N consecutive failureschip (chip-warn) whenConsecutiveFailures > 0(the retry-storm signal);in-flight N(chip-idle) whenCurrentInFlight > 0;- staleness: row dims + shows a
stalechip whenPublishedUtcis older than 15 s (3× publish interval) — covers driver-node death, where entries freeze; reuses the panel's existing 5 s re-render timer; - muted "No resilience activity recorded yet" when the store has no entries for this instance (the tracker only populates once wrapped capability calls run).
Subscription lifecycle copies the existing pattern exactly: inject the store, subscribe SnapshotChanged in OnInitializedAsync, filter by DriverInstanceId, marshal via InvokeAsync(StateHasChanged), unsubscribe first in DisposeAsync.
Rejected UI alternatives: a /hosts column (fleet-wide join of a second store into HostsDriverView; more code, and /hosts rows are cluster-scoped health — the operator authoring overrides is on the driver page; fine follow-on once the store exists) · a dedicated /resilience page (new nav surface for a v1 that needs two chips). The store makes both trivially addable later.
Implementation steps (exact paths)
- Core read-model truthfulness —
src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs: addLastBreakerClosedUtctoResilienceStatusSnapshot, derivedIsBreakerOpen, andRecordBreakerClose(driverInstanceId, hostName, utcNow). Update the stale class xmldoc (:5-16still promises the never-built snapshot-persisting HostedService/table). - Builder close-hook —
src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs(OnClosed,:161-170): addtracker?.RecordBreakerClose(...)beside the existingRecordSuccess. Refresh the ctorloggerxmldoc (:38-42, "until the Admin /hosts reader ships" becomes stale once this lands). - Invoker success-reset —
src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs: on eachExecute*success path (afterpipeline.ExecuteAsyncreturns, before thefinally), call_statusTracker?.RecordSuccess(_driverInstanceId, hostName, utcNow). (Clock: reuse the invoker's existing time source if present, elseDateTime.UtcNowmatching current tracker call sites.) - Wire message — new
src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverResilienceStatusChanged.cs(record above). Plain Commons record = same DPS serialization pathDriverHealthChangedalready rides. - Publisher — new
src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverResilienceStatusPublisherService.cswithpublic static IReadOnlyList<DriverResilienceStatusChanged> BuildMessages(DriverResilienceStatusTracker tracker, DateTime publishedUtc)(pure, unit-testable) + theBackgroundServicetimer/publish/try-catch shell. - Publisher DI —
src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs(AddOtOpcUaDriverFactories):services.TryAddSingleton<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>());+services.AddHostedService<DriverResilienceStatusPublisherService>();with a comment naming this the tracker's production reader. - Admin store — new
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs+InMemoryDriverResilienceStatusStore.cs; register inHubServiceCollectionExtensions.AddOtOpcUaDriverStatusServices. - Bridge actor — new
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs; spawn +ActorRegistrykey inHubServiceCollectionExtensions.WithOtOpcUaSignalRBridges(name constdriver-resilience-status-bridge). - Panel —
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor: inject the store, resilience section per (d), subscribe/unsubscribe per the existing pattern. - Docs/ledger — update
archreview/03-server-runtime.md(U10),archreview/04-adminui.md(U-5),archreview/plans/STATUS.md, and FOLLOWUP-10's "no reader" notes once merged;docs/has no resilience guide to touch (the tracker/builder xmldocs updated in steps 1-2 are the canonical doc surface).
Tests
- Unit — read model (Core.Tests, existing suites):
DriverResilienceStatusTrackerTests:RecordBreakerClosestamps the close time;IsBreakerOpenis false initially, true after open, false after later close, true again after re-open.DriverResiliencePipelineBuilderTests: driving a pipeline to breaker-open then recovery records open and close on the tracker (extend the existing breaker test).CapabilityInvokerTests(orInFlightCounterTestshome): a successfulExecuteAsyncresetsConsecutiveFailurespreviously raised by a failure.
- Unit — publisher mapping (Host.IntegrationTests, pure-DI/no fixture):
BuildMessagesmaps a fed tracker to one message per (instance, host) with correctBreakerOpen/counter/PublishedUtc; empty tracker → empty list. - Wiring guard — the tracker now HAS a production reader (the built-but-never-wired class of bug, mirroring
ResilienceInvokerFactoryRegistrationTests): assertAddOtOpcUaDriverFactories()registers anIHostedServicedescriptor whose implementation type isDriverResilienceStatusPublisherService— a regression that unregisters the reader fails this test, not production. - Unit — store (AdminUI.Tests, mirroring
DriverStatusSnapshotStoreTests): upsert keyed by (instance, host) with last-write-wins;GetForInstancereturns all hosts for one instance only;SnapshotChangedraised per upsert. - Bridge actor: logic is one line (subscribe → upsert), same as the untested sibling bridges; covered by the live gate.
- Razor (no bUnit — live-
/runis the verification): docker-dev rig, rebuild BOTH central-1 and central-2 (:9200round-robins them):docker compose build central-1 central-2 && docker compose up -dindocker-dev/.- On
http://localhost:9200, open (or author) an S7 driver and point its endpoint at a dead port (e.g.10.100.0.35:9999) — per FOLLOWUP-10, S7's connect path throws on a dead socket, unlike Modbus's cheap paths; alternativelylmxopcua-fix down s7a live fixture. InDriverResilienceSection, author overridesretryCount: 2,breakerFailureThreshold: 2(small numbers so the breaker opens fast). Deploy. - On the driver page, watch the panel: consecutive-failures chip climbs, then
Breaker OPENappears; correlate withdocker logscentral grepcircuit-breaker OPENED(the #10 log line — first-ever live observation of it, closing FOLLOWUP-10's deferred behavioral gate as a bonus). - Refresh until the circuit lands on the other central (round-robin) — chips must show there too (proves the per-node DPS bridge/store, not a lucky co-located read).
- Restore the endpoint (
lmxopcua-fix up s7 s7_1500); on the next successful call the breaker closes → chips clear. - Stop the driver node (fused rig: stop central-2 while circuit is on central-1): rows dim to
stalewithin ~15 s.
Effort + Risk
Effort: Medium-small — ~10 bite-sized tasks; one focused day including the live gate. Risk: Low. Everything is additive: three small Core additions (new snapshot field, one hook call, one success-reset call — the only behavior deltas, all confined to telemetry counters), a new message type, a new hosted service, a new store + bridge, and one razor section. No dispatch-path, artifact, or config-shape changes. Blast radius of a publisher bug = missing/wrong chips, never driver behavior. The main live-verify hazard is the known one: razor binding errors that unit tests can't see — hence the mandatory live-/run pass on both centrals.
Task breakdown
Branch:
fix/archreview-r2-10-resilience-observabilityoffmaster. TDD throughout: write the failing test, see FAIL, implement, see PASS. Commit per task (or per labeled group).
T1 — Tracker: breaker-close marker + IsBreakerOpen
Classification: Core read model (unit-testable) · Estimated implement time: 5 min · Parallelizable with: T4, T7 · Files: src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs, tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResilienceStatusTrackerTests.cs
- Add tests:
RecordBreakerClose_StampsCloseTime,IsBreakerOpen_TracksOpenCloseReopen→dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverResilienceStatusTrackerTests"→ FAIL (no such members). - Add
LastBreakerClosedUtcinit-prop + computedIsBreakerOpentoResilienceStatusSnapshot; addRecordBreakerClose. Fix the stale class xmldoc (:5-16). - Re-run filter → PASS. Commit:
feat(resilience): breaker-close marker + IsBreakerOpen on ResilienceStatusSnapshot (R2-10).
T2 — Builder: record breaker-close in OnClosed
Classification: Core wiring (unit-testable) · Estimated implement time: 5 min · Parallelizable with: — (needs T1) · blockedBy: T1 · Files: src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs, tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs
- Extend the existing breaker-telemetry test: after recovery, tracker snapshot has
IsBreakerOpen == falsewith a close stamp →dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DriverResiliencePipelineBuilderTests"→ FAIL. - Add
tracker?.RecordBreakerClose(...)inOnClosed(:161-170); refresh the ctor logger xmldoc (:38-42). - → PASS. Commit:
feat(resilience): OnClosed records breaker-close on the status tracker (R2-10).
T3 — Invoker: reset consecutive failures on success
Classification: Core wiring (unit-testable) · Estimated implement time: 5 min · Parallelizable with: T2 (after T1) · blockedBy: T1 · Files: src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs, tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/CapabilityInvokerTests.cs
- Test:
ExecuteAsync_OnSuccess_ResetsConsecutiveFailures(record a failure, run a succeeding call, assert 0) →dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~CapabilityInvokerTests"→ FAIL. - Call
_statusTracker?.RecordSuccess(...)on eachExecute*success path. - → PASS. Commit:
fix(resilience): successful capability calls reset ConsecutiveFailures (R2-10).
T4 — Commons: DriverResilienceStatusChanged wire message
Classification: Contract (compile-verified; consumed by T5/T8 tests) · Estimated implement time: 3 min · Parallelizable with: T1, T7 · Files: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Drivers/DriverResilienceStatusChanged.cs
- Add the record +
TopicName = "driver-resilience-status"const, xmldoc naming publisher + bridge (mirrorDriverHealthChanged's single-source-of-truth note). Commit:feat(resilience): DriverResilienceStatusChanged DPS contract (R2-10).
T5 — Host: publisher service (BuildMessages + timer shell)
Classification: Transport (mapping unit-testable; shell live-verified) · Estimated implement time: 5 min · Parallelizable with: T7 · blockedBy: T1, T4 · Files: src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverResilienceStatusPublisherService.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverResilienceStatusPublisherServiceTests.cs
- Tests (pure, no fixture):
BuildMessages_MapsTrackerSnapshot(fed tracker → one msg per (instance,host), correct BreakerOpen/counters/PublishedUtc),BuildMessages_EmptyTracker_ReturnsEmpty→dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~DriverResilienceStatusPublisherServiceTests"→ FAIL. - Implement
public static BuildMessages(tracker, publishedUtc)+BackgroundService(PeriodicTimer5 s default via ctor param, per-tick try/catch, skip-empty, DPSPublishper message viaFunc<ActorSystem>). - → PASS. Commit:
feat(resilience): periodic DPS publisher for tracker snapshots (R2-10).
T6 — Host: DI registration + reader wiring guard
Classification: Wiring + guard (the built-but-never-wired class) · Estimated implement time: 5 min · blockedBy: T5 · Files: src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ResilienceStatusReaderRegistrationTests.cs
- Guard test (mirror
ResilienceInvokerFactoryRegistrationTests):AddOtOpcUaDriverFactoriesyields anIHostedServicedescriptor with implementation typeDriverResilienceStatusPublisherService— the assertion that the tracker now has a production reader →dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~ResilienceStatusReaderRegistrationTests"→ FAIL. - Register
TryAddSingleton<Func<ActorSystem>>+AddHostedService<DriverResilienceStatusPublisherService>()inAddOtOpcUaDriverFactories, with a comment naming it the tracker's reader. - → PASS. Commit:
feat(resilience): register the tracker's production reader on driver nodes + wiring guard (R2-10).
T7 — AdminUI: resilience status store
Classification: Admin read model (unit-testable) · Estimated implement time: 5 min · Parallelizable with: T1-T6 (needs only T4) · blockedBy: T4 · Files: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs, .../InMemoryDriverResilienceStatusStore.cs, .../HubServiceCollectionExtensions.cs, tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverResilienceStatusStoreTests.cs
- Tests (mirror
DriverStatusSnapshotStoreTests): upsert last-write-wins per (instance,host);GetForInstancefilters;SnapshotChangedfires per upsert →dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~DriverResilienceStatusStoreTests"→ FAIL. - Implement + register in
AddOtOpcUaDriverStatusServices. - → PASS. Commit:
feat(adminui): in-process resilience status store (R2-10).
T8 — AdminUI: DPS bridge actor + spawn
Classification: Admin wiring (one-line logic; live-verified) · Estimated implement time: 5 min · blockedBy: T7 · Files: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs, .../HubServiceCollectionExtensions.cs
- Implement
ReceiveActor(PreStart DPSSubscribe(DriverResilienceStatusChanged.TopicName),Receive<DriverResilienceStatusChanged>→store.Upsert, swallowSubscribeAck); spawn inWithOtOpcUaSignalRBridges+DriverResilienceStatusBridgeKey. Build-verify (dotnet buildAdminUI project); behavior covered by the live gate. Commit:feat(adminui): driver-resilience-status DPS bridge (R2-10).
T9 — AdminUI: DriverStatusPanel resilience section
Classification: Razor (NO bUnit — live-/run is the only verification for this task) · Estimated implement time: 5 min (code) + live pass in T10 · blockedBy: T7 · Files: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverStatusPanel.razor
- Inject
IDriverResilienceStatusStore; add the per-host chip rows (Breaker OPEN / consecutive failures / in-flight / stale-after-15 s / "No resilience activity recorded yet"); subscribe + filter byDriverInstanceId,InvokeAsync(StateHasChanged), unsubscribe inDisposeAsync— copy the existingSnapshotChangedpattern in the same file verbatim. Remember the string-component-param-needs-@gotcha. Build-verify. Commit:feat(adminui): breaker/retry chips on the driver status panel (R2-10).
T10 — Live-/run gate (decisive for T8/T9)
Classification: Live verification (mandatory — razor + DPS delivery are invisible to unit tests) · Estimated implement time: 30-45 min · blockedBy: T6, T8, T9
docker-dev: rebuild BOTH central-1 + central-2 from source,up -d.- Author/point an S7 driver at a dead endpoint (or
lmxopcua-fix down s7); set overridesretryCount: 2,breakerFailureThreshold: 2; deploy. - Observe: failures chip climbs →
Breaker OPENchip; grep central logs forcircuit-breaker OPENEDto correlate (also the first live observation of the #10 log line — note it against FOLLOWUP-10's deferred behavioral gate). - Refresh onto the other central (round-robin) — chips present there too.
- Restore endpoint → breaker closes → chips clear. Stop one central → rows go
stalein ~15 s. - Commit any live-found fixes; record the evidence in the PR/plan status line.
T11 — Docs + review ledger
Classification: Docs · Estimated implement time: 5 min · blockedBy: T10 · Files: archreview/03-server-runtime.md, archreview/04-adminui.md, archreview/plans/STATUS.md, archreview/plans/FOLLOWUP-10-resilience-dispatch-gap.md
- Mark U10/U-5 remediated with the branch/commit; update STATUS.md; annotate FOLLOWUP-10 that the reader shipped and (if observed in T10) the live behavioral log-line gate closed. Commit:
docs(archreview): R2-10 resilience observability shipped.
Full-suite gate before merge: dotnet test ZB.MOM.WW.OtOpcUa.slnx green (integration suites need their fixtures per CLAUDE.md; the new tests themselves are fixture-free).
Execution deviations (R2-10)
Executed on branch r2/10-resilience-observability (off master 46fedda3). T1–T9 + T11 completed; T10 deferred-live.
- T2 breaker-close test uses a controllable
TimeProvider(ManualTimeProvider), not wall-clock waits. Driving the real Polly breaker toOnClosedrequires elapsing the 15 sBreakDurationthen a successful half-open probe. Rather than a 15 sTask.Delay, the test injects aTimeProviderthat advances bothGetUtcNowandGetTimestampoff one tick counter (Advance(16 s)), making the close deterministic and instant. Same self-contained-fake style the repo already uses (TriePermissionEvaluatorTests); no new package. This is the one deviation from the plan's "extend the existing breaker test" wording — the existing test only reaches breaker-open, and open→close needs time control. - T9 panel renders healthy hosts quietly rather than one always-visible row per host. Per the plan's "healthy is quiet" intent, the resilience section shows:
No resilience activity recorded yet.when the store has no entries for the instance;All targets healthy.when entries exist but none has an active signal; and a chip row only for hosts with breaker-OPEN / consecutive-failures>0 / in-flight>0 / stale. Keeps the panel silent under normal operation (the tracker populates an entry for every host that has ever had a successful call, so an always-on per-host row would be noisy). - T10 (live-
/rungate) deferred-live. Code-complete + offline-verified (unit + full-solution build). The docker-dev breaker-open observation on both centrals + recovery-clear + staleness-dim is a heavy serial pass left for the consolidated live session (also the first live sighting of the #10circuit-breaker OPENEDlog line, closing FOLLOWUP-10's behavioral gate). Markeddeferred-livein.tasks.json. - T6/T8 registration: the publisher's
Func<ActorSystem>is registered viaTryAddSingleton(idempotent with the oneProgram.csregisters for the alarm-command router, per plan). The resilience bridge intentionally has no SignalR hub (the panel reads the store in-process; resilience has no browser-JS consumer) — a deliberate simplification the plan calls for.