From 809e7886d854f690d147a2bf820598e6f0da201f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 08:38:13 -0400 Subject: [PATCH 1/2] test(resilience): prove ResilienceConfig survives the real ConfigComposer round-trip (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arch-review #10 sub-gap worried that per-instance ResilienceConfig never reaches the runtime pipeline. The threading is in fact wired end-to-end (task #13): ConfigComposer serialises the whole DriverInstance entity, so ResilienceConfig rides the artifact into DriverInstanceSpec, which DriverHostActor layers onto the driver's Polly pipeline. The only leg with no test was the real composer->artifact serialization — the existing DeploymentArtifactTests hand-build the JSON. Adds ResilienceConfig_survives_ConfigComposer_to_ParseDriverInstances_round_trip (mirrors the DeviceHost round-trip guard): seeds a DriverInstance WITH a non-default override + one WITHOUT, runs the real SnapshotAndFlattenAsync, and asserts ParseDriverInstances recovers the override byte-for-byte (and null stays null). Guards against a future projection / [JsonIgnore] silently reverting authored resilience policy to tier defaults while hand-built artifact tests stay green. ControlPlane.Tests ConfigComposerTests 6/6 green. --- .../ConfigComposerTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs index 39ead635..f2d2c9c9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigComposerTests.cs @@ -138,6 +138,59 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase node.DeviceHost.ShouldBe("10.9.9.9:8193"); } + /// + /// Verifies that a 's per-instance ResilienceConfig override + /// survives the real → + /// seam (arch-review + /// follow-up #10 sub-gap). The composer serialises the whole entity, so the override rides the + /// artifact into DriverInstanceSpec.ResilienceConfig — which DriverHostActor layers + /// onto the driver's Polly pipeline. Guards the one leg the hand-rolled + /// DeploymentArtifactTests.ParseDriverInstances_carries_ResilienceConfig_onto_the_spec + /// cannot: if the composer ever stopped emitting the column (a projection, a [JsonIgnore]), + /// authored resilience policy would silently revert to tier defaults in production while the + /// hand-built artifact tests stayed green. + /// + [Fact] + public async Task ResilienceConfig_survives_ConfigComposer_to_ParseDriverInstances_round_trip() + { + const string resilienceJson = + "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":5,\"retryCount\":7,\"breakerFailureThreshold\":3}}}"; + + var f = NewInMemoryDbFactory(); + await using (var db = f.CreateDbContext()) + { + db.ServerClusters.Add(NewCluster("c1")); + db.Namespaces.Add(new Namespace + { + NamespaceId = "ns-eq", ClusterId = "c1", + Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "drv-1", ClusterId = "c1", NamespaceId = "ns-eq", + Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", + ResilienceConfig = resilienceJson, + }); + // A second instance with no override proves the null case rides the same real composer path. + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "drv-2", ClusterId = "c1", NamespaceId = "ns-eq", + Name = "Modbus2", DriverType = "Modbus", DriverConfig = "{}", + }); + await db.SaveChangesAsync(); + } + + await using var readDb = f.CreateDbContext(); + var artifact = await ConfigComposer.SnapshotAndFlattenAsync(readDb); + var specs = DeploymentArtifact.ParseDriverInstances(artifact.Blob); + + var withOverride = specs.Single(s => s.DriverInstanceId == "drv-1"); + withOverride.ResilienceConfig.ShouldBe(resilienceJson, "the authored override must ride the artifact byte-for-byte"); + + var withoutOverride = specs.Single(s => s.DriverInstanceId == "drv-2"); + withoutOverride.ResilienceConfig.ShouldBeNull("an unset override stays null ⇒ tier defaults at spawn"); + } + private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static ServerCluster NewCluster(string id) => new() From 0c005c9a72fad40654f8b2277e357fd25d611017 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 08:49:17 -0400 Subject: [PATCH 2/2] docs(archreview): live proof authored ResilienceConfig reaches the runtime pipeline (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the docker-dev live verification for follow-up #10 acceptance item 2: a non-default per-instance ResilienceConfig (Subscribe.retryCount:999) authored on MAIN rode the deploy artifact into BOTH central-1/central-2 runtimes, which parsed and clamped it — the authored value appears verbatim in the spawn-time diagnostic. Confirms the artifact bytes carry ResilienceConfig and the runtime pipeline applies authored (not tier-default) policy. Item 1 (raw retry/breaker-on-fault log line) remains, with the connect-then-idempotent-write recipe documented. --- ...resilience-config-live-proof-2026-07-15.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 archreview/plans/artifacts/456-resilience-config-live-proof-2026-07-15.md diff --git a/archreview/plans/artifacts/456-resilience-config-live-proof-2026-07-15.md b/archreview/plans/artifacts/456-resilience-config-live-proof-2026-07-15.md new file mode 100644 index 00000000..f0371fe9 --- /dev/null +++ b/archreview/plans/artifacts/456-resilience-config-live-proof-2026-07-15.md @@ -0,0 +1,62 @@ +# Follow-up #10 / issue #456 — live proof that authored ResilienceConfig reaches the runtime pipeline + +**Date:** 2026-07-15 · **Rig:** docker-dev (central-1 + central-2, MAIN cluster, Warm/2-node) built from source. + +## What this proves + +The #456 "sub-gap" worried that per-instance `DriverInstance.ResilienceConfig` never reaches the +runtime resilience pipeline (so it runs tier-defaults only). This is **disproven** — both by a new +regression test and by this live capture. + +## Setup + +Seeded on MAIN (SQL): an Equipment namespace `ns-res` + an `OpcUaClient` driver `drv-res` carrying a +**non-default** override: + +```json +"ResilienceConfig": {"capabilityPolicies":{"Subscribe":{"retryCount":999}}} +``` + +`retryCount:999` is deliberately over the cap (100) so the parser emits an unmistakable clamp +diagnostic that echoes the authored value. Deployed headless: `POST /api/deployments` → Accepted +(deploymentId 80b4be3c…). + +## Evidence + +**1. The persisted deployment artifact bytes carry it** (`dbo.Deployment.ArtifactBlob`, 4055 bytes): +`CHARINDEX('ResilienceConfig') > 0` = YES, `CHARINDEX('999') > 0` = YES; the `drv-res` object slice +shows `…"DriverType":"OpcUaClient"…"DriverConfig":"…"…"Resil…"`. + +**2. The runtime parsed + applied it — on BOTH redundancy nodes:** + +``` +central-1 [WRN] Driver resilience config for instance=drv-res type=OpcUaClient: Subscribe.retryCount 999 exceeds the cap (100); clamped. +central-1 [INF] DriverHost central-1:4053: spawned OpcUaClient driver drv-res (stub=False) +central-2 [WRN] Driver resilience config for instance=drv-res type=OpcUaClient: Subscribe.retryCount 999 exceeds the cap (100); clamped. +central-2 [INF] DriverHost central-2:4053: spawned OpcUaClient driver drv-res (stub=False) +``` + +The authored value `999` appears verbatim in the runtime log. If the artifact did not carry +`ResilienceConfig` (the feared sub-gap), `spec.ResilienceConfig` would be null, the parser would take +pure tier-defaults, and **no clamp diagnostic would fire**. The diagnostic firing = the authored +policy rode DB → ConfigComposer → artifact → DriverHostActor → DriverCapabilityInvokerFactory.Create → +DriverResilienceOptionsParser. This is arch-review follow-up #10 acceptance item 2 ("re-verify the +behavioral gate with a non-default policy"), verified live on both nodes. + +## Regression guard + +`ConfigComposerTests.ResilienceConfig_survives_ConfigComposer_to_ParseDriverInstances_round_trip` +(master 809e7886) proves the same composer→artifact→parse leg deterministically, guarding against a +future projection / `[JsonIgnore]` silently reverting authored policy to tier defaults. + +## Still open (acceptance item 1) + +The raw retry/breaker log line **on an actual driver fault** was NOT captured. It requires a +connect-then-fault-mid-flight trigger: the wrapped capability calls (`Subscribe`/`Discover`/`Write`) +only fire after a driver reaches **Connected** (`DriverInstanceActor` `ResubscribeDesired` runs on the +`Become(Connected)` transition), so a driver at a dead endpoint stays in Reconnecting and never +exercises the wrapped path. The reliable trigger is an **idempotent Write to a Connected-then-killed +driver** (LDAP-gated Client.CLI write) or an S7 dead-socket reconnect. The retry/breaker **behaviour** +is already proven deterministically (`CapabilityInvokerTests` retry, the pipeline-builder logging test, +`DriverCapabilityInvokerFactoryTests` override-applies-to-behaviour). This remaining line is +operator-confidence only.