Compare commits

...

22 Commits

Author SHA1 Message Date
Joseph Doherty
4ab587707f AB CIP PR 1 — extract shared PollGroupEngine into Core.Abstractions so the AB CIP driver (and any other poll-based driver — S7, FOCAS, AB Legacy) can reuse the subscription loop instead of reimplementing it. Behaviour-preserving refactor of ModbusDriver: SubscriptionState + PollLoopAsync + PollOnceAsync + ModbusSubscriptionHandle lifted verbatim into a new PollGroupEngine class, ModbusDriver's ISubscribable surface now delegates Subscribe/Unsubscribe into the engine and ShutdownAsync calls engine DisposeAsync. Interval floor (100 ms default) becomes a PollGroupEngine constructor knob so per-driver tuning is possible without re-shipping the loop. Initial-data push semantics preserved via forceRaise=true on the first poll. Exception-tolerant loop preserved — reader throws are swallowed, loop continues, driver's health surface remains the single reporting path. Placement in Core.Abstractions (not Core) because driver projects only reference Core.Abstractions by convention (matches OpcUaClient / Modbus / S7 csproj shape); putting the engine in Core would drag EF Core + Serilog + Polly into every driver. Module has no new dependencies beyond System.Collections.Concurrent + System.Threading, so Core.Abstractions stays lightweight. Modbus ctor converted from primary to explicit so the engine field can capture this for the reader + on-change bridge. All 177 ModbusDriver.Tests pass unmodified (Modbus subscription suite, probe suite, cap suite, exception mapper, reconnect, TCP). 10 new direct engine tests in Core.Abstractions.Tests covering: initial force-raise, unchanged-value single-raise, change-between-polls, unsubscribe halts loop, interval-floor clamp, independent subscriptions, reader-exception tolerance, unknown-handle returns false, ActiveSubscriptionCount lifecycle, DisposeAsync cancels all. No changes to driver-specs.md nor to the server Hosting layer — engine is a pure internal building block at this stage. Unblocks AB CIP PR 7 (ISubscribable consumes the engine); also sets up S7 + FOCAS to drop their own poll loops when they re-base.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:34:44 -04:00
2172d49d2e Merge pull request (#107) - in-flight counter 2026-04-19 15:04:29 -04:00
Joseph Doherty
ae8f226e45 Phase 6.1 Stream E.3 partial — in-flight counter feeds CurrentBulkheadDepth
Closes the observer half of #162 that was flagged as "persisted as 0 today"
in PR #105. The Admin /hosts column refresh + FleetStatusHub SignalR push
+ red-badge visual still belong to the visual-compliance pass.

Core.Resilience:
- DriverResilienceStatusTracker gains RecordCallStart + RecordCallComplete
  + CurrentInFlight field on the snapshot record. Concurrent-safe via the
  same ConcurrentDictionary.AddOrUpdate pattern as the other recorder methods.
  Clamps to zero on over-decrement so a stray Complete-without-Start can't
  drive the counter negative.
- CapabilityInvoker gains an optional statusTracker ctor parameter. When
  wired, every ExecuteAsync / ExecuteAsync(void) wraps the pipeline call
  in try / finally that records start/complete — so the counter advances
  cleanly whether the call succeeds, cancels, or throws. Null tracker keeps
  the pre-Phase-6.1 Stream E.3 behaviour exactly.

Server.Hosting:
- ResilienceStatusPublisherHostedService persists CurrentInFlight as the
  DriverInstanceResilienceStatus.CurrentBulkheadDepth column (was 0 before
  this PR). One-line fix on both the insert + update branches.

The in-flight counter is a pragmatic proxy for Polly's internal bulkhead
depth — a future PR wiring Polly telemetry would replace it with the real
value. The shape of the column + the publisher + the Admin /hosts query
doesn't change, so the follow-up is invisible to consumers.

Tests (8 new InFlightCounterTests, all pass):
- Start+Complete nets to zero.
- Nested starts sum; Complete decrements.
- Complete-without-Start clamps to zero.
- Different hosts track independently.
- Concurrent starts (500 parallel) don't lose count.
- CapabilityInvoker observed-mid-call depth == 1 during a pending call.
- CapabilityInvoker exception path still decrements (try/finally).
- CapabilityInvoker without tracker doesn't throw.

Full solution dotnet test: 1243 passing (was 1235, +8). Pre-existing
Client.CLI Subscribe flake unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:02:34 -04:00
e032045247 Merge pull request (#106) - Phase 6.4 Stream B staging tables 2026-04-19 14:57:39 -04:00
Joseph Doherty
ad131932d3 Phase 6.4 Stream B.2-B.4 server-side — EquipmentImportBatch staging + FinaliseBatch transaction
Closes the server-side/data-layer piece of Phase 6.4 Stream B.2-B.4. The
CSV-import preview + modal UI (Stream B.3/B.5) still belongs to the Admin
UI follow-up — this PR owns the staging tables + atomic finalise alone.

Configuration:
- New EquipmentImportBatch entity (Id, ClusterId, CreatedBy, CreatedAtUtc,
  RowsStaged/Accepted/Rejected, FinalisedAtUtc?). Composite index on
  (CreatedBy, FinalisedAtUtc) powers the Admin preview modal's "my open
  batches" query.
- New EquipmentImportRow entity — one row per CSV row, 8 required columns
  from decision #117 + 9 optional from decision #139 + IsAccepted flag +
  RejectReason. FK to EquipmentImportBatch with cascade delete so
  DropBatch collapses the whole tree.
- EF migration 20260419_..._AddEquipmentImportBatch.
- SchemaComplianceTests expected tables list gains the two new tables.

Admin.Services.EquipmentImportBatchService:
- CreateBatchAsync — new header row, caller-supplied ClusterId + CreatedBy.
- StageRowsAsync(batchId, acceptedRows, rejectedRows) — bulk-inserts the
  parsed CSV rows into staging. Rejected rows carry LineNumberInFile +
  RejectReason for the preview modal. Throws when the batch is finalised.
- DropBatchAsync — removes batch + cascaded rows. Throws when the batch
  was already finalised (rollback via staging is not a time machine).
- FinaliseBatchAsync(batchId, generationId, driverInstanceId, unsLineId) —
  atomic apply. Opens an EF transaction when the provider supports it
  (SQL Server in prod; InMemory in tests skips the tx), bulk-inserts
  every accepted staging row into Equipment, stamps
  EquipmentImportBatch.FinalisedAtUtc, commits. Failure rolls back so
  Equipment never partially mutates. Idempotent-under-double-call:
  second finalise throws ImportBatchAlreadyFinalisedException.
- ListByUserAsync(createdBy, includeFinalised) — the Admin preview modal's
  backing query. OrderByDescending on CreatedAtUtc so the most-recent
  batch shows first.
- Two exception types: ImportBatchNotFoundException +
  ImportBatchAlreadyFinalisedException.

ExternalIdReservation merging (ZTag + SAPID fleet-wide uniqueness) is NOT
done here — a narrower follow-up wires it once the concurrent-insert test
matrix is green.

Tests (10 new EquipmentImportBatchServiceTests, all pass):
- CreateBatch populates Id + CreatedAtUtc + zero-ed counters.
- StageRows accepted + rejected both persist; counters advance.
- DropBatch cascades row delete.
- DropBatch after finalise throws.
- Finalise translates accepted staging rows → Equipment under the target
  GenerationId + DriverInstanceId + UnsLineId.
- Finalise twice throws.
- Finalise of unknown batch throws.
- Stage after finalise throws.
- ListByUserAsync filters by creator + finalised flag.
- Drop of unknown batch is a no-op (idempotent rollback).

Full solution dotnet test: 1235 passing (was 1225, +10). Pre-existing
Client.CLI Subscribe flake unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:55:39 -04:00
98b69ff4f9 Merge pull request (#105) - ResilienceStatusPublisherHostedService 2026-04-19 14:37:53 -04:00
Joseph Doherty
016122841b Phase 6.1 Stream E.2 partial — ResilienceStatusPublisherHostedService persists tracker snapshots to DB
Closes the HostedService half of Phase 6.1 Stream E.2 flagged as a follow-up
when the DriverResilienceStatusTracker shipped in PR #82. The Admin /hosts
column refresh + SignalR push + red-badge visual (Stream E.3) remain
deferred to the visual-compliance pass — this PR owns the persistence
story alone.

Server.Hosting:
- ResilienceStatusPublisherHostedService : BackgroundService. Samples the
  DriverResilienceStatusTracker every TickInterval (default 5 s) and upserts
  each (DriverInstanceId, HostName) counter pair into
  DriverInstanceResilienceStatus via EF. New rows on first sight; in-place
  updates on subsequent ticks.
- PersistOnceAsync extracted public so tests drive one tick directly —
  matches the ScheduledRecycleHostedService pattern for deterministic
  timing.
- Best-effort persistence: a DB outage logs a warning + continues; the next
  tick retries. Never crashes the app on sample failure. Cancellation
  propagates through cleanly.
- Tracks the bulkhead depth / recycle / footprint columns the entity was
  designed for. CurrentBulkheadDepth currently persisted as 0 — the tracker
  doesn't yet expose live bulkhead depth; a narrower follow-up wires the
  Polly bulkhead-depth observer into the tracker.

Tests (6 new in ResilienceStatusPublisherHostedServiceTests):
- Empty tracker → tick is a no-op, zero rows written.
- Single-host counters → upsert a new row with ConsecutiveFailures + breaker
  timestamp + sampled timestamp.
- Second tick updates the existing row in place (not a second insert).
- Multi-host pairs persist independently.
- Footprint counters (Baseline + Current) round-trip.
- TickCount advances on every PersistOnceAsync call.

Full solution dotnet test: 1225 passing (was 1219, +6). Pre-existing
Client.CLI Subscribe flake unchanged.

Production wiring (Program.cs) example:
  builder.Services.AddSingleton<DriverResilienceStatusTracker>();
  builder.Services.AddHostedService<ResilienceStatusPublisherHostedService>();
  // Tracker gets wired into CapabilityInvoker via OtOpcUaServer resolution
  // + the existing Phase 6.1 layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:36:00 -04:00
244a36e03e Merge pull request (#104) - IPerCallHostResolver + decision #144 wire-in 2026-04-19 12:33:23 -04:00
Joseph Doherty
4de94fab0d Phase 6.1 Stream A remaining — IPerCallHostResolver + DriverNodeManager per-call host dispatch (decision #144)
Closes the per-device isolation gap flagged at the Phase 6.1 Stream A wire-up
(PR #78 used driver.DriverInstanceId as the pipeline host for every call, so
multi-host drivers like Modbus with N PLCs shared one pipeline — one dead PLC
poisoned sibling breakers). Decision #144 requires per-device isolation; this
PR wires it without breaking single-host drivers.

Core.Abstractions:
- IPerCallHostResolver interface. Optional driver capability. Drivers with
  multi-host topology (Modbus across N PLCs, AB CIP across a rack, etc.)
  implement this; single-host drivers (Galaxy, S7 against one PLC, OpcUaClient
  against one remote server) leave it alone. Must be fast + allocation-free
  — called once per tag on the hot path. Unknown refs return empty so dispatch
  falls back to single-host without throwing.

Server/OpcUa/DriverNodeManager:
- Captures `driver as IPerCallHostResolver` at construction alongside the
  existing capability casts.
- New `ResolveHostFor(fullReference)` helper returns either the resolver's
  answer or the driver's DriverInstanceId (single-host fallback). Empty /
  whitespace resolver output also falls back to DriverInstanceId.
- Every dispatch site now passes `ResolveHostFor(fullRef)` to the invoker
  instead of `_driver.DriverInstanceId` — OnReadValue, OnWriteValue, all four
  HistoryRead paths. The HistoryRead Events path tolerates fullRef=null and
  falls back to DriverInstanceId for those cluster-wide event queries.
- Drivers without IPerCallHostResolver observe zero behavioural change:
  every call still keys on DriverInstanceId, same as before.

Tests (4 new PerCallHostResolverDispatchTests, all pass):
- DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver — 2 PLCs behind
  one driver; hammer the dead PLC past its breaker threshold; assert the
  healthy PLC's first call succeeds on its first attempt (decision #144).
- EmptyString / unknown-ref fallback behaviour documented via test.
- WithoutResolver_SameHost_Shares_One_Pipeline — regression guard for the
  single-host pre-existing behaviour.
- WithResolver_TwoHosts_Get_Two_Pipelines — builds the CachedPipelineCount
  assertion to confirm the shared-builder cache keys correctly.

Full solution dotnet test: 1219 passing (was 1215, +4). Pre-existing
Client.CLI Subscribe flake unchanged.

Adoption: Modbus driver (#120 follow-up), AB CIP / AB Legacy / TwinCAT
drivers (also #120) implement the interface and return the per-tag PLC host
string. Single-host drivers stay silent and pay zero cost.

Remaining sub-items of #160 still deferred:
- IAlarmSource.SubscribeAlarmsAsync + AcknowledgeAsync invoker wrapping.
  Non-trivial because alarm subscription is push-based from driver through
  IAlarmConditionSink — the wrap has to happen at the driver-to-server glue
  rather than a synchronous dispatch site.
- Roslyn analyzer asserting every capability-interface call routes through
  CapabilityInvoker. Substantial (separate analyzer project + test harness);
  noise-value ratio favors shipping this post-v2-GA once the coverage is
  known-stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:31:24 -04:00
fdd0bf52c3 Merge pull request (#103) - Phase 6.1 Stream A ResilienceConfig 2026-04-19 12:23:47 -04:00
Joseph Doherty
7b50118b68 Phase 6.1 Stream A follow-up — DriverInstance.ResilienceConfig JSON column + parser + OtOpcUaServer wire-in
Closes the Phase 6.1 Stream A.2 "per-instance overrides bound from
DriverInstance.ResilienceConfig JSON column" work flagged as a follow-up
when Stream A.1 shipped in PR #78. Every driver can now override its Polly
pipeline policy per instance instead of inheriting pure tier defaults.

Configuration:
- DriverInstance entity gains a nullable `ResilienceConfig` string column
  (nvarchar(max)) + SQL check constraint `CK_DriverInstance_ResilienceConfig_IsJson`
  that enforces ISJSON when not null. Null = use tier defaults (decision
  #143 / unchanged from pre-Phase-6.1).
- EF migration `20260419161008_AddDriverInstanceResilienceConfig`.
- SchemaComplianceTests expected-constraint list gains the new CK name.

Core.Resilience.DriverResilienceOptionsParser:
- Pure-function parser. ParseOrDefaults(tier, json, out diag) returns the
  effective DriverResilienceOptions — tier defaults with per-capability /
  bulkhead overrides layered on top when the JSON payload supplies them.
  Partial policies (e.g. Read { retryCount: 10 }) fill missing fields from
  the tier default for that capability.
- Malformed JSON falls back to pure tier defaults + surfaces a human-readable
  diagnostic via the out parameter. Callers log the diag but don't fail
  startup — a misconfigured ResilienceConfig must not brick a working
  driver.
- Property names + capability keys are case-insensitive; unrecognised
  capability names are logged-and-skipped; unrecognised shape-level keys
  are ignored so future shapes land without a migration.

Server wire-in:
- OtOpcUaServer gains two optional ctor params: `tierLookup` (driverType →
  DriverTier) + `resilienceConfigLookup` (driverInstanceId → JSON string).
  CreateMasterNodeManager now resolves tier + JSON for each driver, parses
  via DriverResilienceOptionsParser, logs the diagnostic if any, and
  constructs CapabilityInvoker with the merged options instead of pure
  Tier A defaults.
- OpcUaApplicationHost threads both lookups through. Default null keeps
  existing tests constructing without either Func unchanged (falls back
  to Tier A + tier defaults exactly as before).

Tests (13 new DriverResilienceOptionsParserTests):
- null / whitespace / empty-object JSON returns pure tier defaults.
- Malformed JSON falls back + surfaces diagnostic.
- Read override merged into tier defaults; other capabilities untouched.
- Partial policy fills missing fields from tier default.
- Bulkhead overrides honored.
- Unknown capability skipped + surfaced in diagnostic.
- Property names + capability keys are case-insensitive.
- Every tier × every capability × empty-JSON round-trips tier defaults
  exactly (theory).

Full solution dotnet test: 1215 passing (was 1202, +13). Pre-existing
Client.CLI Subscribe flake unchanged.

Production wiring (Program.cs) example:
  Func<string, DriverTier> tierLookup = type => type switch
  {
      "Galaxy" => DriverTier.C,
      "Modbus" or "S7" => DriverTier.B,
      "OpcUaClient" => DriverTier.A,
      _ => DriverTier.A,
  };
  Func<string, string?> cfgLookup = id =>
      db.DriverInstances.AsNoTracking().FirstOrDefault(x => x.DriverInstanceId == id)?.ResilienceConfig;
  var host = new OpcUaApplicationHost(..., tierLookup: tierLookup, resilienceConfigLookup: cfgLookup);

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:21:42 -04:00
eac457fa7c Merge pull request (#102) - Phase 6.4 Stream D server-side 2026-04-19 11:59:36 -04:00
Joseph Doherty
c1cab33e38 Phase 6.4 Stream D server-side — IdentificationFolderBuilder materializes OPC 40010 Machinery Identification sub-folder
Closes the server-side / non-UI piece of Phase 6.4 Stream D. The Razor
`IdentificationFields.razor` component for Admin-UI editing ships separately
when the Admin UI pass lands (still tracked under #157 UI follow-up).

Core.OpcUa additions:
- IdentificationFolderBuilder — pure-function builder that materializes the
  OPC 40010 Machinery companion-spec Identification sub-folder per decision
  #139. Reads the nine nullable columns off an Equipment row:
  Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision,
  YearOfConstruction (short → OPC UA Int32), AssetLocation, ManufacturerUri,
  DeviceManualUri. Emits one AddProperty call per non-null field; skips the
  sub-folder entirely when all nine are null so browse trees don't carry
  pointless empty folders.
- HasAnyFields(equipment) — cheap short-circuit so callers can decide
  whether to invoke Folder() at all.
- FolderName constant ("Identification") + FieldNames list exposed so
  downstream tools / tests can cross-reference without duplicating the
  decision-#139 field set.

ACL binding: the sub-folder + variables live under the Equipment node so
Phase 6.2's PermissionTrie treats them as part of the Equipment ScopeId —
no new scope level. A user with Equipment-level grant reads the
Identification fields; a user without gets BadUserAccessDenied on both the
Equipment node + its Identification variables. Documented in the class
remarks; cross-reference update to acl-design.md is a follow-up.

Tests (9 new IdentificationFolderBuilderTests):
- HasAnyFields all-null false / any-non-null true.
- Build all-null returns null + doesn't emit Folder.
- Build fully-populated emits all 9 fields in decision #139 order.
- Only non-null fields are emitted (3-of-9 case).
- YearOfConstruction short widens to DriverDataType.Int32 with int value.
- String values round-trip through AddProperty.
- FieldNames constant matches decision #139 exactly.
- FolderName is "Identification".

Full solution dotnet test: 1202 passing (was 1193, +9). Pre-existing
Client.CLI Subscribe flake unchanged.

Production integration: the component that consumes this is the
address-space-build flow that walks the live Equipment table + calls
IdentificationFolderBuilder.Build(equipmentFolder, equipment) under each
Equipment node. That integration is the remaining Stream D follow-up
alongside the Razor UI component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:57:39 -04:00
0c903ff4e0 Merge pull request (#101) - Phase 6.1 Stream B.4 hosted service 2026-04-19 11:44:16 -04:00
Joseph Doherty
c4a92f424a Phase 6.1 Stream B.4 follow-up — ScheduledRecycleHostedService drives registered schedulers on a fixed tick
Turns the Phase 6.1 Stream B.4 pure-logic ScheduledRecycleScheduler (shipped
in PR #79) into a running background feature. A Tier C driver registers its
scheduler at startup; the hosted service ticks every TickInterval (default
1 min) and invokes TickAsync on each registered scheduler.

Server.Hosting:
- ScheduledRecycleHostedService : BackgroundService. AddScheduler(s) must be
  called before StartAsync — registering post-start throws
  InvalidOperationException to avoid "some ticks saw my scheduler, some
  didn't" races. ExecuteAsync loops on Task.Delay(TickInterval, _timeProvider,
  stoppingToken) + delegates to a public TickOnceAsync method for one tick.
- TickOnceAsync extracted as the unit-of-work so tests drive it directly
  without needing to synchronize with FakeTimeProvider + BackgroundService
  timing semantics.
- Exception isolation: if one scheduler throws, the loop logs + continues
  to the next scheduler. A flaky supervisor can't take down the tick for
  every other Tier C driver.
- Diagnostics: TickCount + SchedulerCount properties for tests + logs.

Tests (7 new ScheduledRecycleHostedServiceTests, all pass):
- TickOnce before interval doesn't fire; TickCount still advances.
- TickOnce at/after interval fires the underlying scheduler exactly once.
- Multiple ticks accumulate count.
- AddScheduler after StartAsync throws.
- Throwing scheduler doesn't poison its neighbours (logs + continues).
- SchedulerCount matches registrations.
- Empty scheduler list ticks cleanly (no-op + counter advances).

Full solution dotnet test: 1193 passing (was 1186, +7). Pre-existing
Client.CLI Subscribe flake unchanged.

Production wiring (Program.cs):
  builder.Services.AddSingleton<ScheduledRecycleHostedService>();
  builder.Services.AddHostedService(sp => sp.GetRequiredService<ScheduledRecycleHostedService>());
  // During DI configuration, once Tier C drivers + their ScheduledRecycleSchedulers
  // are resolved, call host.AddScheduler(scheduler) for each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:42:08 -04:00
510e488ea4 Merge pull request (#100) - Readiness doc all blockers closed 2026-04-19 11:35:34 -04:00
8994e73a0b Merge pull request (#99) - Phase 6.3 Stream C core 2026-04-19 11:33:49 -04:00
Joseph Doherty
e71f44603c v2 release-readiness — blocker #3 closed; all three code-path blockers shut
Phase 6.3 Streams A + C core shipped (PRs #98-99):
- RedundancyCoordinator + ClusterTopologyLoader read the shared config DB +
  enforce the Phase 6.3 invariants (1-2 nodes, unique ApplicationUri, ≤1
  Primary in Warm/Hot). Startup fails fast on violation.
- RedundancyStatePublisher orchestrates topology + apply lease + recovery
  state + peer reachability through ServiceLevelCalculator. Edge-triggered
  OnStateChanged + OnServerUriArrayChanged events the OPC UA variable-node
  layer subscribes to.

Doc updates:
- Top status flips from NOT YET RELEASE-READY → RELEASE-READY (code-path).
  Remaining work is manual (client interop matrix, deployment signoff,
  OPC UA CTT pass) + hardening follow-ups that don't block v2 GA ship.
- Release-blocker #3 section struck through + CLOSED with PR links.
  Remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA
  variable-node binding, sp_PublishGeneration lease wrap, client interop)
  explicitly listed as hardening follow-ups.
- Change log: new dated entry.

All three release blockers identified at the capstone are closed:
- #1 Phase 6.2 dispatch wiring  → PR #94 (2026-04-19)
- #2 Phase 6.1 Stream D wiring  → PR #96 (2026-04-19)
- #3 Phase 6.3 Streams A/C core → PRs #98-99 (2026-04-19)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:33:37 -04:00
Joseph Doherty
c4824bea12 Phase 6.3 Stream C core — RedundancyStatePublisher + PeerReachability; orchestrates calculator inputs end-to-end
Wires the Phase 6.3 Stream B pure-logic pieces (ServiceLevelCalculator,
RecoveryStateManager, ApplyLeaseRegistry) + Stream A topology loader
(RedundancyCoordinator) into one orchestrator the runtime + OPC UA node
surface consume. The actual OPC UA variable-node plumbing (mapping
ServiceLevel Byte + ServerUriArray String[] onto the Opc.Ua.Server stack)
is narrower follow-up on top of this — the publisher emits change events
the OPC UA layer subscribes to.

Server.Redundancy additions:
- PeerReachability record + PeerReachabilityTracker — thread-safe
  per-peer-NodeId holder of the latest (HttpHealthy, UaHealthy) tuple. Probe
  loops (Stream B.1/B.2 runtime follow-up) write via Update; the publisher
  reads via Get. PeerReachability.FullyHealthy / Unknown sentinels for the
  two most-common states.
- RedundancyStatePublisher — pure orchestrator, no background timer, no OPC
  UA stack dep. ComputeAndPublish reads the 6 inputs + calls the calculator:
    * role (from coordinator.Current.SelfRole)
    * selfHealthy (caller-supplied Func<bool>)
    * peerHttpHealthy + peerUaHealthy (aggregate across all peers in
      coordinator.Current.Peers)
    * applyInProgress (ApplyLeaseRegistry.IsApplyInProgress)
    * recoveryDwellMet (RecoveryStateManager.IsDwellMet)
    * topologyValid (coordinator.IsTopologyValid)
    * operatorMaintenance (caller-supplied Func<bool>)
  Before-coordinator-init returns NoData=1 so clients never see an
  authoritative value from an un-bootstrapped server.
  OnStateChanged event fires edge-triggered when the byte changes;
  OnServerUriArrayChanged fires edge-triggered when the topology's self-first
  peer-sorted URI array content changes.
- ServiceLevelSnapshot record — per-tick output with Value + Band +
  Topology. The OPC UA layer's ServiceLevel Byte node subscribes to
  OnStateChanged; the ServerUriArray node subscribes to OnServerUriArrayChanged.

Tests (8 new RedundancyStatePublisherTests, all pass):
- Before-init returns NoData (Value=1, Band=NoData).
- Authoritative-Primary when healthy + peer fully reachable.
- Isolated-Primary (230) retains authority when peer unreachable — matches
  decision #154 non-promotion semantics.
- Mid-apply band dominates: open lease → Value=200 even with peer healthy.
- Self-unhealthy → NoData regardless of other inputs.
- OnStateChanged fires only on value transitions (edge-triggered).
- OnServerUriArrayChanged fires once per topology content change; repeat
  ticks with same topology don't re-emit.
- Standalone cluster treats healthy as AuthoritativePrimary=255.

Microsoft.EntityFrameworkCore.InMemory 10.0.0 added to Server.Tests for the
coordinator-backed publisher tests.

Full solution dotnet test: 1186 passing (was 1178, +8). Pre-existing
Client.CLI Subscribe flake unchanged.

Closes the core of release blocker #3 — the pure-logic + orchestration
layer now exists + is unit-tested. Remaining Stream C surfaces: OPC UA
ServiceLevel Byte variable wiring (binds to OnStateChanged), ServerUriArray
String[] wiring (binds to OnServerUriArrayChanged), RedundancySupport
static from RedundancyMode. Those touch the OPC UA stack directly + land
as Stream C.2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:31:50 -04:00
e588c4f980 Merge pull request (#98) - Phase 6.3 Stream A topology loader 2026-04-19 11:26:11 -04:00
Joseph Doherty
84fe88fadb Phase 6.3 Stream A — RedundancyTopology + ClusterTopologyLoader + RedundancyCoordinator
Lands the data path that feeds the Phase 6.3 ServiceLevelCalculator shipped in
PR #89. OPC UA node wiring (ServiceLevel variable + ServerUriArray +
RedundancySupport) still deferred to task #147; peer-probe loops (Stream B.1/B.2
runtime layer beyond the calculator logic) deferred.

Server.Redundancy additions:
- RedundancyTopology record — immutable snapshot (ClusterId, SelfNodeId,
  SelfRole, Mode, Peers[], SelfApplicationUri). ServerUriArray() emits the
  OPC UA Part 4 §6.6.2.2 shape (self first, peers lexicographically by
  NodeId). RedundancyPeer record with per-peer Host/OpcUaPort/DashboardPort/
  ApplicationUri so the follow-up peer-probe loops know where to probe.
- ClusterTopologyLoader — pure fn from ServerCluster + ClusterNode[] to
  RedundancyTopology. Enforces Phase 6.3 Stream A.1 invariants:
    * At least one node per cluster.
    * At most 2 nodes (decision #83, v2.0 cap).
    * Every node belongs to the target cluster.
    * Unique ApplicationUri across the cluster (OPC UA Part 4 trust pin,
      decision #86).
    * At most 1 Primary per cluster in Warm/Hot modes (decision #84).
    * Self NodeId must be a member of the cluster.
  Violations throw InvalidTopologyException with a decision-ID-tagged message
  so operators know which invariant + what to fix.
- RedundancyCoordinator singleton — holds the current topology + IsTopologyValid
  flag. InitializeAsync throws on invariant violation (startup fails fast).
  RefreshAsync logs + flips IsTopologyValid=false (runtime won't tear down a
  running server; ServiceLevelCalculator falls to InvalidTopology band = 2
  which surfaces the problem to clients without crashing). CAS-style swap
  via Volatile.Write so readers always see a coherent snapshot.

Tests (10 new ClusterTopologyLoaderTests):
- Single-node standalone loads + empty peer list.
- Two-node cluster loads self + peer.
- ServerUriArray puts self first + peers sort lexicographically.
- Empty-nodes throws.
- Self-not-in-cluster throws.
- Three-node cluster rejected with decision #83 message.
- Duplicate ApplicationUri rejected with decision #86 shape reference.
- Two Primaries in Warm mode rejected (decision #84 + runtime-band reference).
- Cross-cluster node rejected.
- None-mode allows any role mix (standalone clusters don't enforce Primary count).

Full solution dotnet test: 1178 passing (was 1168, +10). Pre-existing
Client.CLI Subscribe flake unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:24:14 -04:00
59f793f87c Merge pull request (#97) - Readiness doc blocker2 closed 2026-04-19 11:18:26 -04:00
39 changed files with 6390 additions and 119 deletions

View File

@@ -1,7 +1,7 @@
# v2 Release Readiness
> **Last updated**: 2026-04-19 (release blockers #1 + #2 closed; Phase 6.3 redundancy runtime is the last)
> **Status**: **NOT YET RELEASE-READY** — one of three release blockers remains (Phase 6.3 Streams A/C/F redundancy-coordinator + OPC UA node wiring + client interop).
> **Last updated**: 2026-04-19 (all three release blockers CLOSED — Phase 6.3 Streams A/C core shipped)
> **Status**: **RELEASE-READY (code-path)** for v2 GA — all three code-path release blockers are closed. Remaining work is manual (client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered.
@@ -52,17 +52,19 @@ Remaining follow-ups (hardening, not release-blocking):
- A `HostedService` that polls `sp_GetCurrentGenerationForCluster` periodically so peer-published generations land in this node's cache without a restart.
- Richer snapshot payload via `sp_GetGenerationContent` so fallback can serve the full generation content (DriverInstance enumeration, ACL rows, etc.) from the sealed cache alone.
### Redundancy — Phase 6.3 Streams A/C/F (tasks #145, #147, #150)
### ~~Redundancy — Phase 6.3 Streams A/C core~~ (tasks #145 + #147 — **CLOSED** 2026-04-19, PRs #9899)
`ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` exist as pure logic. **No code invokes them at runtime.** The OPC UA server still publishes a static `ServiceLevel`; `ServerUriArray` still carries only self; no coordinator reads cluster topology; no peer probing.
**Closed**. The runtime orchestration layer now exists end-to-end:
Closing this requires:
- `RedundancyCoordinator` reads `ClusterNode` + peer list at startup (Stream A shipped in PR #98). Invariants enforced: 1-2 nodes (decision #83), unique ApplicationUri (#86), ≤1 Primary in Warm/Hot (#84). Startup fails fast on violation; runtime refresh logs + flips `IsTopologyValid=false` so the calculator falls to band 2 without tearing down.
- `RedundancyStatePublisher` orchestrates topology + apply lease + recovery state + peer reachability through `ServiceLevelCalculator` + emits `OnStateChanged` / `OnServerUriArrayChanged` edge-triggered events (Stream C core shipped in PR #99). The OPC UA `ServiceLevel` Byte variable + `ServerUriArray` String[] variable subscribe to these events.
- `RedundancyCoordinator` singleton reads `ClusterNode` + peer list at startup (Stream A).
- `PeerHttpProbeLoop` + `PeerUaProbeLoop` feed the calculator.
- OPC UA node wiring: `ServiceLevel` becomes a live `BaseDataVariable` on calculator observer output; `ServerUriArray` includes self + peers; `RedundancySupport` static from `RedundancyMode` (Stream C).
- `sp_PublishGeneration` wraps in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band fires during actual publishes.
- Client interop matrix validation against Ignition / Kepware / Aveva OI Gateway (Stream F).
Remaining Phase 6.3 surfaces (hardening, not release-blocking):
- `PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices that poll the peer + write to `PeerReachabilityTracker` on each tick. Without these the publisher sees `PeerReachability.Unknown` for every peer → Isolated-Primary band (230) even when the peer is up. Safe default (retains authority) but not the full non-transparent-redundancy UX.
- OPC UA variable-node wiring layer: bind the `ServiceLevel` Byte node + `ServerUriArray` String[] node to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push. Scoped follow-up on the Opc.Ua.Server stack integration.
- `sp_PublishGeneration` wraps its apply in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band (200) fires during actual publishes (task #148 part 2).
- Client interop matrix validation — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only work; doesn't block code ship.
### Remaining drivers (task #120)
@@ -98,6 +100,7 @@ v2 GA requires all of the following:
## Change log
- **2026-04-19** — Release blocker #3 **closed** (PRs #9899). Phase 6.3 Streams A + C core shipped: `ClusterTopologyLoader` + `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker`. Code-path release blockers all closed; remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA variable-node binding, sp_PublishGeneration lease wrap, client interop matrix) are hardening follow-ups.
- **2026-04-19** — Release blocker #2 **closed** (PR #96). `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag`; `/healthz` now surfaces the stale flag. Remaining follow-ups (periodic poller + richer snapshot payload) downgraded to hardening.
- **2026-04-19** — Release blocker #1 **closed** (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
- **2026-04-19** — Phase 6.4 data layer merged (PRs #9192). Phase 6 core complete. Capstone doc created.

View File

@@ -0,0 +1,207 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Staged-import orchestrator per Phase 6.4 Stream B.2-B.4. Covers the four operator
/// actions: CreateBatch → StageRows (chunked) → FinaliseBatch (atomic apply into
/// <see cref="Equipment"/>) → DropBatch (rollback of pre-finalise state).
/// </summary>
/// <remarks>
/// <para>FinaliseBatch runs inside one EF transaction + bulk-inserts accepted rows into
/// <see cref="Equipment"/>. Rejected rows stay behind as audit evidence; the batch row
/// gains <see cref="EquipmentImportBatch.FinalisedAtUtc"/> so future writes know it's
/// archived. DropBatch removes the batch + its cascaded rows.</para>
///
/// <para>Idempotence: calling FinaliseBatch twice throws <see cref="ImportBatchAlreadyFinalisedException"/>
/// rather than double-inserting. Operator refreshes the admin page to see the first
/// finalise completed.</para>
///
/// <para>ExternalIdReservation merging (ZTag + SAPID uniqueness) is NOT done here — a
/// narrower follow-up wires it once the concurrent-insert test matrix is green.</para>
/// </remarks>
public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
{
/// <summary>Create a new empty batch header. Returns the row with Id populated.</summary>
public async Task<EquipmentImportBatch> CreateBatchAsync(string clusterId, string createdBy, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
ArgumentException.ThrowIfNullOrWhiteSpace(createdBy);
var batch = new EquipmentImportBatch
{
Id = Guid.NewGuid(),
ClusterId = clusterId,
CreatedBy = createdBy,
CreatedAtUtc = DateTime.UtcNow,
};
db.EquipmentImportBatches.Add(batch);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
return batch;
}
/// <summary>
/// Stage one chunk of rows into the batch. Caller usually feeds
/// <see cref="EquipmentCsvImporter.Parse"/> output here — each
/// <see cref="EquipmentCsvRow"/> becomes one accepted <see cref="EquipmentImportRow"/>,
/// each rejected parser error becomes one row with <see cref="EquipmentImportRow.IsAccepted"/> false.
/// </summary>
public async Task StageRowsAsync(
Guid batchId,
IReadOnlyList<EquipmentCsvRow> acceptedRows,
IReadOnlyList<EquipmentCsvRowError> rejectedRows,
CancellationToken ct)
{
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false)
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
if (batch.FinalisedAtUtc is not null)
throw new ImportBatchAlreadyFinalisedException(
$"Batch {batchId} finalised at {batch.FinalisedAtUtc:o}; no more rows can be staged.");
foreach (var row in acceptedRows)
{
db.EquipmentImportRows.Add(new EquipmentImportRow
{
Id = Guid.NewGuid(),
BatchId = batchId,
IsAccepted = true,
ZTag = row.ZTag,
MachineCode = row.MachineCode,
SAPID = row.SAPID,
EquipmentId = row.EquipmentId,
EquipmentUuid = row.EquipmentUuid,
Name = row.Name,
UnsAreaName = row.UnsAreaName,
UnsLineName = row.UnsLineName,
Manufacturer = row.Manufacturer,
Model = row.Model,
SerialNumber = row.SerialNumber,
HardwareRevision = row.HardwareRevision,
SoftwareRevision = row.SoftwareRevision,
YearOfConstruction = row.YearOfConstruction,
AssetLocation = row.AssetLocation,
ManufacturerUri = row.ManufacturerUri,
DeviceManualUri = row.DeviceManualUri,
});
}
foreach (var error in rejectedRows)
{
db.EquipmentImportRows.Add(new EquipmentImportRow
{
Id = Guid.NewGuid(),
BatchId = batchId,
IsAccepted = false,
RejectReason = error.Reason,
LineNumberInFile = error.LineNumber,
// Required columns need values for EF; reject rows use sentinel placeholders.
ZTag = "", MachineCode = "", SAPID = "", EquipmentId = "", EquipmentUuid = "",
Name = "", UnsAreaName = "", UnsLineName = "",
});
}
batch.RowsStaged += acceptedRows.Count + rejectedRows.Count;
batch.RowsAccepted += acceptedRows.Count;
batch.RowsRejected += rejectedRows.Count;
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
/// <summary>Drop the batch (pre-finalise rollback). Cascaded row delete removes staged rows.</summary>
public async Task DropBatchAsync(Guid batchId, CancellationToken ct)
{
var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false);
if (batch is null) return;
if (batch.FinalisedAtUtc is not null)
throw new ImportBatchAlreadyFinalisedException(
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}; cannot drop.");
db.EquipmentImportBatches.Remove(batch);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
/// <summary>
/// Atomic finalise. Inserts every accepted row into the live
/// <see cref="Equipment"/> table under the target generation + stamps
/// <see cref="EquipmentImportBatch.FinalisedAtUtc"/>. Failure rolls the whole tx
/// back — <see cref="Equipment"/> never partially mutates.
/// </summary>
public async Task FinaliseBatchAsync(
Guid batchId, long generationId, string driverInstanceIdForRows, string unsLineIdForRows, CancellationToken ct)
{
var batch = await db.EquipmentImportBatches
.Include(b => b.Rows)
.FirstOrDefaultAsync(b => b.Id == batchId, ct)
.ConfigureAwait(false)
?? throw new ImportBatchNotFoundException($"Batch {batchId} not found.");
if (batch.FinalisedAtUtc is not null)
throw new ImportBatchAlreadyFinalisedException(
$"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}.");
// EF InMemory provider doesn't honour BeginTransaction; SQL Server provider does.
// Tests run the happy path under in-memory; production SQL Server runs the atomic tx.
var supportsTx = db.Database.IsRelational();
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
if (supportsTx)
tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
foreach (var row in batch.Rows.Where(r => r.IsAccepted))
{
db.Equipment.Add(new Equipment
{
EquipmentRowId = Guid.NewGuid(),
GenerationId = generationId,
EquipmentId = row.EquipmentId,
EquipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid(),
DriverInstanceId = driverInstanceIdForRows,
UnsLineId = unsLineIdForRows,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = row.ZTag,
SAPID = row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
SerialNumber = row.SerialNumber,
HardwareRevision = row.HardwareRevision,
SoftwareRevision = row.SoftwareRevision,
YearOfConstruction = short.TryParse(row.YearOfConstruction, out var y) ? y : null,
AssetLocation = row.AssetLocation,
ManufacturerUri = row.ManufacturerUri,
DeviceManualUri = row.DeviceManualUri,
});
}
batch.FinalisedAtUtc = DateTime.UtcNow;
await db.SaveChangesAsync(ct).ConfigureAwait(false);
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
}
catch
{
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
throw;
}
finally
{
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
{
var query = db.EquipmentImportBatches.AsNoTracking().Where(b => b.CreatedBy == createdBy);
if (!includeFinalised)
query = query.Where(b => b.FinalisedAtUtc == null);
return await query.OrderByDescending(b => b.CreatedAtUtc).ToListAsync(ct).ConfigureAwait(false);
}
}
public sealed class ImportBatchNotFoundException(string message) : Exception(message);
public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message);

View File

@@ -27,6 +27,24 @@ public sealed class DriverInstance
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
public required string DriverConfig { get; set; }
/// <summary>
/// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
/// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
/// <code>
/// {
/// "bulkheadMaxConcurrent": 16,
/// "bulkheadMaxQueue": 64,
/// "capabilityPolicies": {
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
/// }
/// }
/// </code>
/// Parsed at startup by <c>DriverResilienceOptionsParser</c>; every key is optional +
/// unrecognised keys are ignored so future shapes land without a migration.
/// </summary>
public string? ResilienceConfig { get; set; }
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,68 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Staged equipment-import batch per Phase 6.4 Stream B.2. Rows land in the child
/// <see cref="EquipmentImportRow"/> table under a batch header; operator reviews + either
/// drops (via <c>DropImportBatch</c>) or finalises (via <c>FinaliseImportBatch</c>) in one
/// bounded transaction. The live <c>Equipment</c> table never sees partial state.
/// </summary>
/// <remarks>
/// <para>User-scoped visibility: the preview modal only shows batches where
/// <see cref="CreatedBy"/> equals the current operator. Prevents accidental
/// cross-operator finalise during concurrent imports. An admin finalise / drop surface
/// can override this — tracked alongside the UI follow-up.</para>
///
/// <para><see cref="FinalisedAtUtc"/> stamps the moment the batch promoted from staging
/// into <c>Equipment</c>. Null = still in staging; non-null = archived / finalised.</para>
/// </remarks>
public sealed class EquipmentImportBatch
{
public Guid Id { get; set; }
public required string ClusterId { get; set; }
public required string CreatedBy { get; set; }
public DateTime CreatedAtUtc { get; set; }
public int RowsStaged { get; set; }
public int RowsAccepted { get; set; }
public int RowsRejected { get; set; }
public DateTime? FinalisedAtUtc { get; set; }
public ICollection<EquipmentImportRow> Rows { get; set; } = [];
}
/// <summary>
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the decision #117
/// + decision #139 columns from the CSV importer's output + an
/// <see cref="IsAccepted"/> flag + a <see cref="RejectReason"/> string the preview modal
/// renders.
/// </summary>
public sealed class EquipmentImportRow
{
public Guid Id { get; set; }
public Guid BatchId { get; set; }
public int LineNumberInFile { get; set; }
public bool IsAccepted { get; set; }
public string? RejectReason { get; set; }
// Required (decision #117)
public required string ZTag { get; set; }
public required string MachineCode { get; set; }
public required string SAPID { get; set; }
public required string EquipmentId { get; set; }
public required string EquipmentUuid { get; set; }
public required string Name { get; set; }
public required string UnsAreaName { get; set; }
public required string UnsLineName { get; set; }
// Optional (decision #139 — OPC 40010 Identification)
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public string? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
public EquipmentImportBatch? Batch { get; set; }
}

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverInstanceResilienceConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ResilienceConfig",
table: "DriverInstance",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddCheckConstraint(
name: "CK_DriverInstance_ResilienceConfig_IsJson",
table: "DriverInstance",
sql: "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "CK_DriverInstance_ResilienceConfig_IsJson",
table: "DriverInstance");
migrationBuilder.DropColumn(
name: "ResilienceConfig",
table: "DriverInstance");
}
}
}

View File

@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddEquipmentImportBatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EquipmentImportBatch",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
RowsStaged = table.Column<int>(type: "int", nullable: false),
RowsAccepted = table.Column<int>(type: "int", nullable: false),
RowsRejected = table.Column<int>(type: "int", nullable: false),
FinalisedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EquipmentImportBatch", x => x.Id);
});
migrationBuilder.CreateTable(
name: "EquipmentImportRow",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BatchId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LineNumberInFile = table.Column<int>(type: "int", nullable: false),
IsAccepted = table.Column<bool>(type: "bit", nullable: false),
RejectReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
ZTag = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
MachineCode = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SAPID = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EquipmentUuid = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
UnsAreaName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
UnsLineName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Manufacturer = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Model = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
SerialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
HardwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SoftwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
YearOfConstruction = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true),
AssetLocation = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EquipmentImportRow", x => x.Id);
table.ForeignKey(
name: "FK_EquipmentImportRow_EquipmentImportBatch_BatchId",
column: x => x.BatchId,
principalTable: "EquipmentImportBatch",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EquipmentImportBatch_Creator_Finalised",
table: "EquipmentImportBatch",
columns: new[] { "CreatedBy", "FinalisedAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_EquipmentImportRow_Batch",
table: "EquipmentImportRow",
column: "BatchId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EquipmentImportRow");
migrationBuilder.DropTable(
name: "EquipmentImportBatch");
}
}
}

View File

@@ -413,6 +413,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ResilienceConfig")
.HasColumnType("nvarchar(max)");
b.HasKey("DriverInstanceRowId");
b.HasIndex("ClusterId");
@@ -431,6 +434,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("DriverInstance", null, t =>
{
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
});
});
@@ -599,6 +604,148 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("Equipment", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("FinalisedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<int>("RowsAccepted")
.HasColumnType("int");
b.Property<int>("RowsRejected")
.HasColumnType("int");
b.Property<int>("RowsStaged")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedBy", "FinalisedAtUtc")
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
b.ToTable("EquipmentImportBatch", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssetLocation")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<Guid>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentUuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HardwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsAccepted")
.HasColumnType("bit");
b.Property<int>("LineNumberInFile")
.HasColumnType("int");
b.Property<string>("MachineCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Manufacturer")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ManufacturerUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Model")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("RejectReason")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("SAPID")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SerialNumber")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("SoftwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsAreaName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsLineName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("YearOfConstruction")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("ZTag")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("BatchId")
.HasDatabaseName("IX_EquipmentImportRow_Batch");
b.ToTable("EquipmentImportRow", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
{
b.Property<Guid>("ReservationId")
@@ -1226,6 +1373,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Generation");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch")
.WithMany("Rows")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1325,6 +1483,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("GenerationState");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Navigation("Rows");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{
b.Navigation("Generations");

View File

@@ -30,6 +30,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -53,6 +55,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureDriverHostStatus(modelBuilder);
ConfigureDriverInstanceResilienceStatus(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
ConfigureEquipmentImportBatch(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -251,6 +254,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
{
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
"ISJSON(DriverConfig) = 1");
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson",
"ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
});
e.HasKey(x => x.DriverInstanceRowId);
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
@@ -260,6 +265,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DriverType).HasMaxLength(32);
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)");
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
@@ -565,4 +571,52 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_LdapGroupRoleMapping_Group");
});
}
private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EquipmentImportBatch>(e =>
{
e.ToTable("EquipmentImportBatch");
e.HasKey(x => x.Id);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)");
// Admin preview modal filters by user; finalise / drop both hit this index.
e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc })
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
});
modelBuilder.Entity<EquipmentImportRow>(e =>
{
e.ToTable("EquipmentImportRow");
e.HasKey(x => x.Id);
e.Property(x => x.ZTag).HasMaxLength(128);
e.Property(x => x.MachineCode).HasMaxLength(128);
e.Property(x => x.SAPID).HasMaxLength(128);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.EquipmentUuid).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.UnsAreaName).HasMaxLength(64);
e.Property(x => x.UnsLineName).HasMaxLength(64);
e.Property(x => x.Manufacturer).HasMaxLength(256);
e.Property(x => x.Model).HasMaxLength(256);
e.Property(x => x.SerialNumber).HasMaxLength(256);
e.Property(x => x.HardwareRevision).HasMaxLength(64);
e.Property(x => x.SoftwareRevision).HasMaxLength(64);
e.Property(x => x.YearOfConstruction).HasMaxLength(8);
e.Property(x => x.AssetLocation).HasMaxLength(512);
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
e.Property(x => x.RejectReason).HasMaxLength(512);
e.HasOne(x => x.Batch)
.WithMany(b => b.Rows)
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
});
}
}

View File

@@ -0,0 +1,34 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability that maps a per-tag full reference to the underlying host
/// name responsible for serving it. Drivers with a one-host topology (Galaxy on one
/// MXAccess endpoint, OpcUaClient against one remote server, S7 against one PLC) do NOT
/// need to implement this — the dispatch layer falls back to
/// <see cref="IDriver.DriverInstanceId"/> as a single-host key.
/// </summary>
/// <remarks>
/// <para>Multi-host drivers (Modbus with N PLCs, hypothetical AB CIP across a rack, etc.)
/// implement this so the Phase 6.1 resilience pipeline can be keyed on
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c> per decision #144. One
/// dead PLC behind a multi-device Modbus driver then trips only its own breaker; healthy
/// siblings keep serving.</para>
///
/// <para>Implementations must be fast + allocation-free on the hot path — <c>ReadAsync</c>
/// / <c>WriteAsync</c> call this once per tag. A simple <c>Dictionary&lt;string, string&gt;</c>
/// lookup is typical.</para>
///
/// <para>When the fullRef doesn't map to a known host (caller passes an unregistered
/// reference, or the tag was removed mid-flight), implementations should return the
/// driver's default-host string rather than throwing — the invoker falls back to a
/// single-host pipeline for that call, which is safer than tearing down the request.</para>
/// </remarks>
public interface IPerCallHostResolver
{
/// <summary>
/// Resolve the host name for the given driver-side full reference. Returned value is
/// used as the <c>hostName</c> argument to the Phase 6.1 <c>CapabilityInvoker</c> so
/// per-host breaker isolation + per-host bulkhead accounting both kick in.
/// </summary>
string ResolveHost(string fullReference);
}

View File

@@ -0,0 +1,146 @@
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Shared poll-based subscription engine for drivers whose underlying protocol has no
/// native push model (Modbus, AB CIP, S7, FOCAS). Owns one background Task per subscription
/// that periodically invokes the supplied reader, diffs each snapshot against the last
/// known value, and dispatches a change callback per changed tag. Extracted from
/// <c>ModbusDriver</c> (AB CIP PR 1) so poll-based drivers don't each re-ship the loop,
/// floor logic, and lifecycle plumbing.
/// </summary>
/// <remarks>
/// <para>The engine is read-path agnostic: it calls the supplied <c>reader</c> delegate
/// and trusts the driver to map protocol errors into <see cref="DataValueSnapshot.StatusCode"/>.
/// Callbacks fire on: (a) the first poll after subscribe (initial-data push per the OPC UA
/// Part 4 convention), (b) any subsequent poll where the boxed value or status code differs
/// from the previously-seen snapshot.</para>
///
/// <para>Exceptions thrown by the reader on the initial poll or any subsequent poll are
/// swallowed — the loop continues on the next tick. The driver's own health surface is
/// where transient poll failures should be reported; the engine intentionally does not
/// double-book that responsibility.</para>
/// </remarks>
public sealed class PollGroupEngine : IAsyncDisposable
{
private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
private readonly TimeSpan _minInterval;
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
private long _nextId;
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
/// order as the input references.</param>
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
/// <see cref="ISubscribable.OnDataChange"/> event.</param>
/// <param name="minInterval">Interval floor; anything below is clamped. Defaults to 100 ms
/// per <see cref="DefaultMinInterval"/>.</param>
public PollGroupEngine(
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
TimeSpan? minInterval = null)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(onChange);
_reader = reader;
_onChange = onChange;
_minInterval = minInterval ?? DefaultMinInterval;
}
/// <summary>Register a new polled subscription and start its background loop.</summary>
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var id = Interlocked.Increment(ref _nextId);
var cts = new CancellationTokenSource();
var interval = publishingInterval < _minInterval ? _minInterval : publishingInterval;
var handle = new PollSubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state;
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
return handle;
}
/// <summary>Cancel the background loop for a handle returned by <see cref="Subscribe"/>.</summary>
/// <returns><c>true</c> when the handle was known to the engine and has been torn down.</returns>
public bool Unsubscribe(ISubscriptionHandle handle)
{
if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{
try { state.Cts.Cancel(); } catch { }
state.Cts.Dispose();
return true;
}
return false;
}
/// <summary>Snapshot of active subscription count — exposed for driver diagnostics.</summary>
public int ActiveSubscriptionCount => _subscriptions.Count;
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
{
// Initial-data push: every subscribed tag fires once at subscribe time regardless of
// whether it has changed, satisfying OPC UA Part 4 initial-value semantics.
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* first-read error tolerated — loop continues */ }
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* transient poll error — loop continues, driver health surface logs it */ }
}
}
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{
var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
_onChange(state.Handle, tagRef, current);
}
}
}
/// <summary>Cancel every active subscription. Idempotent.</summary>
public ValueTask DisposeAsync()
{
foreach (var state in _subscriptions.Values)
{
try { state.Cts.Cancel(); } catch { }
state.Cts.Dispose();
}
_subscriptions.Clear();
return ValueTask.CompletedTask;
}
private sealed record SubscriptionState(
PollSubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"poll-sub-{Id}";
}
}

View File

@@ -0,0 +1,91 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// <summary>
/// Phase 6.4 Stream D: materializes the OPC 40010 Machinery companion-spec Identification
/// sub-folder under an Equipment node. Reads the nine decision-#139 columns off the
/// <see cref="Equipment"/> row and emits one property per non-null field.
/// </summary>
/// <remarks>
/// <para>Pure-function shape — testable without a real OPC UA node manager. The caller
/// passes the builder scoped to the Equipment node; this class handles the Identification
/// sub-folder creation + per-field <see cref="IAddressSpaceBuilder.AddProperty"/> calls.</para>
///
/// <para>ACL binding: the sub-folder + its properties inherit the Equipment scope's
/// grants (no new scope level). Phase 6.2's trie treats them as part of the Equipment
/// ScopeId — a user with Equipment-level grant reads Identification; a user without the
/// grant gets BadUserAccessDenied on both the Equipment node + its Identification variables.
/// See <c>docs/v2/acl-design.md</c> §Identification cross-reference.</para>
///
/// <para>The nine fields per decision #139 are exposed exactly when they carry a non-null
/// value. A row with all nine null produces no Identification sub-folder at all — the
/// caller can use <see cref="HasAnyFields(Equipment)"/> to skip the Folder call entirely
/// and avoid a pointless empty folder appearing in browse trees.</para>
/// </remarks>
public static class IdentificationFolderBuilder
{
/// <summary>Browse + display name of the sub-folder — fixed per OPC 40010 convention.</summary>
public const string FolderName = "Identification";
/// <summary>
/// Canonical decision #139 field set exposed in the Identification sub-folder. Order
/// matches the decision-log entry so any browse-order reader can cross-reference
/// without re-sorting.
/// </summary>
public static IReadOnlyList<string> FieldNames { get; } = new[]
{
"Manufacturer", "Model", "SerialNumber",
"HardwareRevision", "SoftwareRevision",
"YearOfConstruction", "AssetLocation",
"ManufacturerUri", "DeviceManualUri",
};
/// <summary>True when the equipment row has at least one non-null Identification field.</summary>
public static bool HasAnyFields(Equipment equipment)
{
ArgumentNullException.ThrowIfNull(equipment);
return equipment.Manufacturer is not null
|| equipment.Model is not null
|| equipment.SerialNumber is not null
|| equipment.HardwareRevision is not null
|| equipment.SoftwareRevision is not null
|| equipment.YearOfConstruction is not null
|| equipment.AssetLocation is not null
|| equipment.ManufacturerUri is not null
|| equipment.DeviceManualUri is not null;
}
/// <summary>
/// Build the Identification sub-folder under <paramref name="equipmentBuilder"/>. No-op
/// when every field is null. Returns the sub-folder builder (or null when no-op) so
/// callers can attach additional nodes underneath if needed.
/// </summary>
public static IAddressSpaceBuilder? Build(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
{
ArgumentNullException.ThrowIfNull(equipmentBuilder);
ArgumentNullException.ThrowIfNull(equipment);
if (!HasAnyFields(equipment)) return null;
var folder = equipmentBuilder.Folder(FolderName, FolderName);
AddIfPresent(folder, "Manufacturer", DriverDataType.String, equipment.Manufacturer);
AddIfPresent(folder, "Model", DriverDataType.String, equipment.Model);
AddIfPresent(folder, "SerialNumber", DriverDataType.String, equipment.SerialNumber);
AddIfPresent(folder, "HardwareRevision", DriverDataType.String, equipment.HardwareRevision);
AddIfPresent(folder, "SoftwareRevision", DriverDataType.String, equipment.SoftwareRevision);
AddIfPresent(folder, "YearOfConstruction", DriverDataType.Int32,
equipment.YearOfConstruction is null ? null : (object)(int)equipment.YearOfConstruction.Value);
AddIfPresent(folder, "AssetLocation", DriverDataType.String, equipment.AssetLocation);
AddIfPresent(folder, "ManufacturerUri", DriverDataType.String, equipment.ManufacturerUri);
AddIfPresent(folder, "DeviceManualUri", DriverDataType.String, equipment.DeviceManualUri);
return folder;
}
private static void AddIfPresent(IAddressSpaceBuilder folder, string name, DriverDataType dataType, object? value)
{
if (value is null) return;
folder.AddProperty(name, dataType, value);
}
}

View File

@@ -22,6 +22,7 @@ public sealed class CapabilityInvoker
private readonly string _driverInstanceId;
private readonly string _driverType;
private readonly Func<DriverResilienceOptions> _optionsAccessor;
private readonly DriverResilienceStatusTracker? _statusTracker;
/// <summary>
/// Construct an invoker for one driver instance.
@@ -33,11 +34,13 @@ public sealed class CapabilityInvoker
/// pipeline-invalidate can take effect without restarting the invoker.
/// </param>
/// <param name="driverType">Driver type name for structured-log enrichment (e.g. <c>"Modbus"</c>).</param>
/// <param name="statusTracker">Optional resilience-status tracker. When wired, every capability call records start/complete so Admin <c>/hosts</c> can surface <see cref="ResilienceStatusSnapshot.CurrentInFlight"/> as the bulkhead-depth proxy.</param>
public CapabilityInvoker(
DriverResiliencePipelineBuilder builder,
string driverInstanceId,
Func<DriverResilienceOptions> optionsAccessor,
string driverType = "Unknown")
string driverType = "Unknown",
DriverResilienceStatusTracker? statusTracker = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(optionsAccessor);
@@ -46,6 +49,7 @@ public sealed class CapabilityInvoker
_driverInstanceId = driverInstanceId;
_driverType = driverType;
_optionsAccessor = optionsAccessor;
_statusTracker = statusTracker;
}
/// <summary>Execute a capability call returning a value, honoring the per-capability pipeline.</summary>
@@ -59,9 +63,17 @@ public sealed class CapabilityInvoker
ArgumentNullException.ThrowIfNull(callSite);
var pipeline = ResolvePipeline(capability, hostName);
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
_statusTracker?.RecordCallStart(_driverInstanceId, hostName);
try
{
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
{
return await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_statusTracker?.RecordCallComplete(_driverInstanceId, hostName);
}
}
@@ -75,9 +87,17 @@ public sealed class CapabilityInvoker
ArgumentNullException.ThrowIfNull(callSite);
var pipeline = ResolvePipeline(capability, hostName);
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
_statusTracker?.RecordCallStart(_driverInstanceId, hostName);
try
{
await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId()))
{
await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_statusTracker?.RecordCallComplete(_driverInstanceId, hostName);
}
}

View File

@@ -0,0 +1,116 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// <summary>
/// Parses the <c>DriverInstance.ResilienceConfig</c> JSON column into a
/// <see cref="DriverResilienceOptions"/> instance layered on top of the tier defaults.
/// Every key in the JSON is optional; missing keys fall back to the tier defaults from
/// <see cref="DriverResilienceOptions.GetTierDefaults(DriverTier)"/>.
/// </summary>
/// <remarks>
/// <para>Example JSON shape per Phase 6.1 Stream A.2:</para>
/// <code>
/// {
/// "bulkheadMaxConcurrent": 16,
/// "bulkheadMaxQueue": 64,
/// "capabilityPolicies": {
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
/// }
/// }
/// </code>
///
/// <para>Unrecognised keys + values are ignored so future shapes land without a migration.
/// Per-capability overrides are layered on top of tier defaults — a partial policy (only
/// some of TimeoutSeconds/RetryCount/BreakerFailureThreshold) fills in the other fields
/// from the tier default for that capability.</para>
///
/// <para>Parser failures (malformed JSON, type mismatches) fall back to pure tier defaults
/// + surface through an out-parameter diagnostic. Callers may log the diagnostic but should
/// NOT fail driver startup — a misconfigured ResilienceConfig should never brick a
/// working driver.</para>
/// </remarks>
public static class DriverResilienceOptionsParser
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
/// Parse the JSON payload layered on <paramref name="tier"/>'s defaults. Returns the
/// effective options; <paramref name="parseDiagnostic"/> is null on success, or a
/// human-readable error message when the JSON was malformed (options still returned
/// = tier defaults).
/// </summary>
public static DriverResilienceOptions ParseOrDefaults(
DriverTier tier,
string? resilienceConfigJson,
out string? parseDiagnostic)
{
parseDiagnostic = null;
var baseDefaults = DriverResilienceOptions.GetTierDefaults(tier);
var baseOptions = new DriverResilienceOptions { Tier = tier, CapabilityPolicies = baseDefaults };
if (string.IsNullOrWhiteSpace(resilienceConfigJson))
return baseOptions;
ResilienceConfigShape? shape;
try
{
shape = JsonSerializer.Deserialize<ResilienceConfigShape>(resilienceConfigJson, JsonOpts);
}
catch (JsonException ex)
{
parseDiagnostic = $"ResilienceConfig JSON malformed; falling back to tier {tier} defaults. Detail: {ex.Message}";
return baseOptions;
}
if (shape is null) return baseOptions;
var merged = new Dictionary<DriverCapability, CapabilityPolicy>(baseDefaults);
if (shape.CapabilityPolicies is not null)
{
foreach (var (capName, overridePolicy) in shape.CapabilityPolicies)
{
if (!Enum.TryParse<DriverCapability>(capName, ignoreCase: true, out var capability))
{
parseDiagnostic ??= $"Unknown capability '{capName}' in ResilienceConfig; skipped.";
continue;
}
var basePolicy = merged[capability];
merged[capability] = new CapabilityPolicy(
TimeoutSeconds: overridePolicy.TimeoutSeconds ?? basePolicy.TimeoutSeconds,
RetryCount: overridePolicy.RetryCount ?? basePolicy.RetryCount,
BreakerFailureThreshold: overridePolicy.BreakerFailureThreshold ?? basePolicy.BreakerFailureThreshold);
}
}
return new DriverResilienceOptions
{
Tier = tier,
CapabilityPolicies = merged,
BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent ?? baseOptions.BulkheadMaxConcurrent,
BulkheadMaxQueue = shape.BulkheadMaxQueue ?? baseOptions.BulkheadMaxQueue,
};
}
private sealed class ResilienceConfigShape
{
public int? BulkheadMaxConcurrent { get; set; }
public int? BulkheadMaxQueue { get; set; }
public Dictionary<string, CapabilityPolicyShape>? CapabilityPolicies { get; set; }
}
private sealed class CapabilityPolicyShape
{
public int? TimeoutSeconds { get; set; }
public int? RetryCount { get; set; }
public int? BreakerFailureThreshold { get; set; }
}
}

View File

@@ -81,6 +81,29 @@ public sealed class DriverResilienceStatusTracker
});
}
/// <summary>
/// Record the entry of a capability call for this (instance, host). Increments the
/// in-flight counter used as the <see cref="ResilienceStatusSnapshot.CurrentInFlight"/>
/// surface (a cheap stand-in for Polly bulkhead depth). Paired with
/// <see cref="RecordCallComplete"/>; callers use try/finally.
/// </summary>
public void RecordCallStart(string driverInstanceId, string hostName)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
_ => new ResilienceStatusSnapshot { CurrentInFlight = 1 },
(_, existing) => existing with { CurrentInFlight = existing.CurrentInFlight + 1 });
}
/// <summary>Paired with <see cref="RecordCallStart"/> — decrements the in-flight counter.</summary>
public void RecordCallComplete(string driverInstanceId, string hostName)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
_ => new ResilienceStatusSnapshot { CurrentInFlight = 0 }, // start-without-complete shouldn't happen; clamp to 0
(_, existing) => existing with { CurrentInFlight = Math.Max(0, existing.CurrentInFlight - 1) });
}
/// <summary>Snapshot of a specific (instance, host) pair; null if no counters recorded yet.</summary>
public ResilienceStatusSnapshot? TryGet(string driverInstanceId, string hostName) =>
_status.TryGetValue(new StatusKey(driverInstanceId, hostName), out var snapshot) ? snapshot : null;
@@ -101,4 +124,12 @@ public sealed record ResilienceStatusSnapshot
public long BaselineFootprintBytes { get; init; }
public long CurrentFootprintBytes { get; init; }
public DateTime LastSampledUtc { get; init; }
/// <summary>
/// In-flight capability calls against this (instance, host). Bumped on call entry +
/// decremented on completion. Feeds <c>DriverInstanceResilienceStatus.CurrentBulkheadDepth</c>
/// for Admin <c>/hosts</c> — a cheap proxy for the Polly bulkhead depth until the full
/// telemetry observer lands.
/// </summary>
public int CurrentInFlight { get; init; }
}

View File

@@ -11,19 +11,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
/// </summary>
/// <remarks>
/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model;
/// subscriptions would need a polling loop over the declared tags — additive PR). Historian
/// + alarm capabilities are out of scope (the protocol doesn't express them).
/// Scope limits: Historian + alarm capabilities are out of scope (the protocol doesn't
/// express them). Subscriptions overlay a polling loop via the shared
/// <see cref="PollGroupEngine"/> since Modbus has no native push model.
/// </remarks>
public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
public sealed class ModbusDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
// Active polling subscriptions. Each subscription owns a background Task that polls the
// tags at its configured interval, diffs against _lastKnownValues, and fires OnDataChange
// per changed tag. UnsubscribeAsync cancels the task via the CTS stored on the handle.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
private long _nextSubscriptionId;
// Polled subscriptions delegate to the shared PollGroupEngine. The driver only supplies
// the reader + on-change bridge; the engine owns the loop, interval floor, and lifecycle.
private readonly PollGroupEngine _poll;
private readonly string _driverInstanceId;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -35,15 +33,28 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
private readonly ModbusDriverOptions _options = options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout, o.AutoReconnect));
private readonly ModbusDriverOptions _options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
private IModbusTransport? _transport;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
public string DriverInstanceId => driverInstanceId;
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_transportFactory = transportFactory
?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout, o.AutoReconnect));
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
public string DriverInstanceId => _driverInstanceId;
public string DriverType => "Modbus";
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
@@ -84,12 +95,7 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
_probeCts?.Dispose();
_probeCts = null;
foreach (var state in _subscriptions.Values)
{
try { state.Cts.Cancel(); } catch { }
state.Cts.Dispose();
}
_subscriptions.Clear();
await _poll.DisposeAsync().ConfigureAwait(false);
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
@@ -303,85 +309,18 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
}
}
// ---- ISubscribable (polling overlay) ----
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextSubscriptionId);
var cts = new CancellationTokenSource();
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
? TimeSpan.FromMilliseconds(100) // floor — Modbus can't sustain < 100ms polling reliably
: publishingInterval;
var handle = new ModbusSubscriptionHandle(id);
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
_subscriptions[id] = state;
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
return Task.FromResult<ISubscriptionHandle>(handle);
}
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is ModbusSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
{
state.Cts.Cancel();
state.Cts.Dispose();
}
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
{
// Initial-data push: read every tag once at subscribe time so OPC UA clients see the
// current value per Part 4 convention, even if the value never changes thereafter.
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* first-read error — polling continues */ }
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch { /* transient polling error — loop continues, health surface reflects it */ }
}
}
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
{
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
// Raise on first read (forceRaise) OR when the boxed value differs from last-known.
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
}
}
}
private sealed record SubscriptionState(
ModbusSubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed record ModbusSubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"modbus-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
/// <summary>
/// Samples <see cref="DriverResilienceStatusTracker"/> at a fixed tick + upserts each
/// <c>(DriverInstanceId, HostName)</c> snapshot into <see cref="DriverInstanceResilienceStatus"/>
/// so Admin <c>/hosts</c> can render live resilience counters across restarts.
/// </summary>
/// <remarks>
/// <para>Closes the HostedService piece of Phase 6.1 Stream E.2 flagged as a follow-up
/// when the tracker shipped in PR #82. The Admin UI column-refresh piece (red badge when
/// ConsecutiveFailures &gt; breakerThreshold / 2 + SignalR push) is still deferred to
/// the visual-compliance pass — this service owns the persistence half alone.</para>
///
/// <para>Tick interval defaults to 5 s. Persistence is best-effort: a DB outage during
/// a tick logs + continues; the next tick tries again with the latest snapshots. The
/// hosted service never crashes the app on sample failure.</para>
///
/// <para><see cref="PersistOnceAsync"/> factored as a public method so tests can drive
/// it directly, matching the <see cref="ScheduledRecycleHostedService.TickOnceAsync"/>
/// pattern for deterministic unit-test timing.</para>
/// </remarks>
public sealed class ResilienceStatusPublisherHostedService : BackgroundService
{
private readonly DriverResilienceStatusTracker _tracker;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbContextFactory;
private readonly ILogger<ResilienceStatusPublisherHostedService> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>Tick interval — how often the tracker snapshot is persisted.</summary>
public TimeSpan TickInterval { get; }
/// <summary>Snapshot of the tick count for diagnostics + test assertions.</summary>
public int TickCount { get; private set; }
public ResilienceStatusPublisherHostedService(
DriverResilienceStatusTracker tracker,
IDbContextFactory<OtOpcUaConfigDbContext> dbContextFactory,
ILogger<ResilienceStatusPublisherHostedService> logger,
TimeProvider? timeProvider = null,
TimeSpan? tickInterval = null)
{
ArgumentNullException.ThrowIfNull(tracker);
ArgumentNullException.ThrowIfNull(dbContextFactory);
_tracker = tracker;
_dbContextFactory = dbContextFactory;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
TickInterval = tickInterval ?? TimeSpan.FromSeconds(5);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"ResilienceStatusPublisherHostedService starting — tick interval = {Interval}",
TickInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(TickInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
await PersistOnceAsync(stoppingToken).ConfigureAwait(false);
}
_logger.LogInformation("ResilienceStatusPublisherHostedService stopping after {TickCount} tick(s).", TickCount);
}
/// <summary>
/// Take one snapshot of the tracker + upsert each pair into the persistence table.
/// Swallows transient exceptions + logs them; never throws from a sample failure.
/// </summary>
public async Task PersistOnceAsync(CancellationToken cancellationToken)
{
TickCount++;
var snapshot = _tracker.Snapshot();
if (snapshot.Count == 0) return;
try
{
await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow().UtcDateTime;
foreach (var (driverInstanceId, hostName, counters) in snapshot)
{
var existing = await db.DriverInstanceResilienceStatuses
.FirstOrDefaultAsync(x => x.DriverInstanceId == driverInstanceId && x.HostName == hostName, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
{
db.DriverInstanceResilienceStatuses.Add(new DriverInstanceResilienceStatus
{
DriverInstanceId = driverInstanceId,
HostName = hostName,
LastCircuitBreakerOpenUtc = counters.LastBreakerOpenUtc,
ConsecutiveFailures = counters.ConsecutiveFailures,
CurrentBulkheadDepth = counters.CurrentInFlight,
LastRecycleUtc = counters.LastRecycleUtc,
BaselineFootprintBytes = counters.BaselineFootprintBytes,
CurrentFootprintBytes = counters.CurrentFootprintBytes,
LastSampledUtc = now,
});
}
else
{
existing.LastCircuitBreakerOpenUtc = counters.LastBreakerOpenUtc;
existing.ConsecutiveFailures = counters.ConsecutiveFailures;
existing.CurrentBulkheadDepth = counters.CurrentInFlight;
existing.LastRecycleUtc = counters.LastRecycleUtc;
existing.BaselineFootprintBytes = counters.BaselineFootprintBytes;
existing.CurrentFootprintBytes = counters.CurrentFootprintBytes;
existing.LastSampledUtc = now;
}
}
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex,
"ResilienceStatusPublisher persistence tick failed; next tick will retry with latest snapshots.");
}
}
}

View File

@@ -0,0 +1,117 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Stability;
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
/// <summary>
/// Drives one or more <see cref="ScheduledRecycleScheduler"/> instances on a fixed tick
/// cadence. Closes Phase 6.1 Stream B.4 by turning the shipped-as-pure-logic scheduler
/// into a running background feature.
/// </summary>
/// <remarks>
/// <para>Registered as a singleton in Program.cs. Each Tier C driver instance that wants a
/// scheduled recycle registers its scheduler via
/// <see cref="AddScheduler(ScheduledRecycleScheduler)"/> at startup. The hosted service
/// wakes every <see cref="TickInterval"/> (default 1 min) and calls
/// <see cref="ScheduledRecycleScheduler.TickAsync"/> on each registered scheduler.</para>
///
/// <para>Scheduler registration is closed after <see cref="ExecuteAsync"/> starts — callers
/// must register before the host starts, typically during DI setup. Adding a scheduler
/// mid-flight throws to avoid confusing "some ticks saw my scheduler, some didn't" races.</para>
/// </remarks>
public sealed class ScheduledRecycleHostedService : BackgroundService
{
private readonly List<ScheduledRecycleScheduler> _schedulers = [];
private readonly ILogger<ScheduledRecycleHostedService> _logger;
private readonly TimeProvider _timeProvider;
private bool _started;
/// <summary>How often <see cref="ScheduledRecycleScheduler.TickAsync"/> fires on each registered scheduler.</summary>
public TimeSpan TickInterval { get; }
public ScheduledRecycleHostedService(
ILogger<ScheduledRecycleHostedService> logger,
TimeProvider? timeProvider = null,
TimeSpan? tickInterval = null)
{
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
TickInterval = tickInterval ?? TimeSpan.FromMinutes(1);
}
/// <summary>Register a scheduler to drive. Must be called before the host starts.</summary>
public void AddScheduler(ScheduledRecycleScheduler scheduler)
{
ArgumentNullException.ThrowIfNull(scheduler);
if (_started)
throw new InvalidOperationException(
"Cannot register a ScheduledRecycleScheduler after the hosted service has started. " +
"Register all schedulers during DI configuration / startup.");
_schedulers.Add(scheduler);
}
/// <summary>Snapshot of the current tick count — diagnostics only.</summary>
public int TickCount { get; private set; }
/// <summary>Snapshot of the number of registered schedulers — diagnostics only.</summary>
public int SchedulerCount => _schedulers.Count;
public override Task StartAsync(CancellationToken cancellationToken)
{
_started = true;
return base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"ScheduledRecycleHostedService starting — {Count} scheduler(s), tick interval = {Interval}",
_schedulers.Count, TickInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(TickInterval, _timeProvider, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
await TickOnceAsync(stoppingToken).ConfigureAwait(false);
}
_logger.LogInformation("ScheduledRecycleHostedService stopping after {TickCount} tick(s).", TickCount);
}
/// <summary>
/// Execute one scheduler tick against every registered scheduler. Factored out of the
/// <see cref="ExecuteAsync"/> loop so tests can drive it directly without needing to
/// synchronize with <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)"/>.
/// </summary>
public async Task TickOnceAsync(CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
TickCount++;
foreach (var scheduler in _schedulers)
{
try
{
var fired = await scheduler.TickAsync(now, cancellationToken).ConfigureAwait(false);
if (fired)
_logger.LogInformation("Scheduled recycle fired at {Now:o}; next = {Next:o}",
now, scheduler.NextRecycleUtc);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
// A single scheduler fault must not take down the rest — log + continue.
_logger.LogError(ex,
"ScheduledRecycleScheduler tick failed at {Now:o}; continuing to other schedulers.", now);
}
}
}
}

View File

@@ -35,6 +35,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly IDriver _driver;
private readonly IReadable? _readable;
private readonly IWritable? _writable;
private readonly IPerCallHostResolver? _hostResolver;
private readonly CapabilityInvoker _invoker;
private readonly ILogger<DriverNodeManager> _logger;
@@ -75,6 +76,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_driver = driver;
_readable = driver as IReadable;
_writable = driver as IWritable;
_hostResolver = driver as IPerCallHostResolver;
_invoker = invoker;
_authzGate = authzGate;
_scopeResolver = scopeResolver;
@@ -83,6 +85,21 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
/// <summary>
/// Resolve the host name fed to the Phase 6.1 CapabilityInvoker for a per-tag call.
/// Multi-host drivers that implement <see cref="IPerCallHostResolver"/> get their
/// per-PLC isolation (decision #144); single-host drivers + drivers that don't
/// implement the resolver fall back to the DriverInstanceId — preserves existing
/// Phase 6.1 pipeline-key semantics for those drivers.
/// </summary>
private string ResolveHostFor(string fullReference)
{
if (_hostResolver is null) return _driver.DriverInstanceId;
var resolved = _hostResolver.ResolveHost(fullReference);
return string.IsNullOrWhiteSpace(resolved) ? _driver.DriverInstanceId : resolved;
}
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
@@ -224,7 +241,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var result = _invoker.ExecuteAsync(
DriverCapability.Read,
_driver.DriverInstanceId,
ResolveHostFor(fullRef),
async ct => (IReadOnlyList<DataValueSnapshot>)await _readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
if (result.Count == 0)
@@ -439,7 +456,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var isIdempotent = _writeIdempotentByFullRef.GetValueOrDefault(fullRef!, false);
var capturedValue = value;
var results = _invoker.ExecuteWriteAsync(
_driver.DriverInstanceId,
ResolveHostFor(fullRef!),
isIdempotent,
async ct => (IReadOnlyList<WriteResult>)await _writable.WriteAsync(
[new DriverWriteRequest(fullRef!, capturedValue)],
@@ -538,7 +555,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
_driver.DriverInstanceId,
ResolveHostFor(fullRef),
async ct => await History.ReadRawAsync(
fullRef,
details.StartTime,
@@ -612,7 +629,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
_driver.DriverInstanceId,
ResolveHostFor(fullRef),
async ct => await History.ReadProcessedAsync(
fullRef,
details.StartTime,
@@ -679,7 +696,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
_driver.DriverInstanceId,
ResolveHostFor(fullRef),
async ct => await History.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
@@ -749,7 +766,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
_driver.DriverInstanceId,
fullRef is null ? _driver.DriverInstanceId : ResolveHostFor(fullRef),
async ct => await History.ReadEventsAsync(
sourceName: fullRef,
startUtc: details.StartTime,

View File

@@ -27,6 +27,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private readonly AuthorizationGate? _authzGate;
private readonly NodeScopeResolver? _scopeResolver;
private readonly StaleConfigFlag? _staleConfigFlag;
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
@@ -39,7 +41,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
DriverResiliencePipelineBuilder? pipelineBuilder = null,
AuthorizationGate? authzGate = null,
NodeScopeResolver? scopeResolver = null,
StaleConfigFlag? staleConfigFlag = null)
StaleConfigFlag? staleConfigFlag = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null)
{
_options = options;
_driverHost = driverHost;
@@ -48,6 +52,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_authzGate = authzGate;
_scopeResolver = scopeResolver;
_staleConfigFlag = staleConfigFlag;
_tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup;
_loggerFactory = loggerFactory;
_logger = logger;
}
@@ -75,7 +81,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
authzGate: _authzGate, scopeResolver: _scopeResolver);
authzGate: _authzGate, scopeResolver: _scopeResolver,
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",

View File

@@ -23,6 +23,8 @@ public sealed class OtOpcUaServer : StandardServer
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
private readonly AuthorizationGate? _authzGate;
private readonly NodeScopeResolver? _scopeResolver;
private readonly Func<string, DriverTier>? _tierLookup;
private readonly Func<string, string?>? _resilienceConfigLookup;
private readonly ILoggerFactory _loggerFactory;
private readonly List<DriverNodeManager> _driverNodeManagers = new();
@@ -32,13 +34,17 @@ public sealed class OtOpcUaServer : StandardServer
DriverResiliencePipelineBuilder pipelineBuilder,
ILoggerFactory loggerFactory,
AuthorizationGate? authzGate = null,
NodeScopeResolver? scopeResolver = null)
NodeScopeResolver? scopeResolver = null,
Func<string, DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null)
{
_driverHost = driverHost;
_authenticator = authenticator;
_pipelineBuilder = pipelineBuilder;
_authzGate = authzGate;
_scopeResolver = scopeResolver;
_tierLookup = tierLookup;
_resilienceConfigLookup = resilienceConfigLookup;
_loggerFactory = loggerFactory;
}
@@ -59,10 +65,16 @@ public sealed class OtOpcUaServer : StandardServer
if (driver is null) continue;
var logger = _loggerFactory.CreateLogger<DriverNodeManager>();
// Per-driver resilience options: default Tier A pending Stream B.1 which wires
// per-type tiers into DriverTypeRegistry. Read ResilienceConfig JSON from the
// DriverInstance row in a follow-up PR; for now every driver gets Tier A defaults.
var options = new DriverResilienceOptions { Tier = DriverTier.A };
// Per-driver resilience options: tier comes from lookup (Phase 6.1 Stream B.1
// DriverTypeRegistry in the prod wire-up) or falls back to Tier A. ResilienceConfig
// JSON comes from the DriverInstance row via the optional lookup Func; parser
// layers JSON overrides on top of tier defaults (Phase 6.1 Stream A.2).
var tier = _tierLookup?.Invoke(driver.DriverType) ?? DriverTier.A;
var resilienceJson = _resilienceConfigLookup?.Invoke(driver.DriverInstanceId);
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceJson, out var diag);
if (diag is not null)
logger.LogWarning("ResilienceConfig parse diagnostic for driver {DriverId}: {Diag}", driver.DriverInstanceId, diag);
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
authzGate: _authzGate, scopeResolver: _scopeResolver);

View File

@@ -0,0 +1,96 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Pure-function mapper from the shared config DB's <see cref="ServerCluster"/> +
/// <see cref="ClusterNode"/> rows to an immutable <see cref="RedundancyTopology"/>.
/// Validates Phase 6.3 Stream A.1 invariants and throws
/// <see cref="InvalidTopologyException"/> on violation so the coordinator can fail startup
/// fast with a clear message rather than boot into an ambiguous state.
/// </summary>
/// <remarks>
/// Stateless — the caller owns the DB round-trip + hands rows in. Keeping it pure makes
/// the invariant matrix testable without EF or SQL Server.
/// </remarks>
public static class ClusterTopologyLoader
{
/// <summary>Build a topology snapshot for the given self node. Throws on invariant violation.</summary>
public static RedundancyTopology Load(string selfNodeId, ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
{
ArgumentException.ThrowIfNullOrWhiteSpace(selfNodeId);
ArgumentNullException.ThrowIfNull(cluster);
ArgumentNullException.ThrowIfNull(nodes);
ValidateClusterShape(cluster, nodes);
ValidateUniqueApplicationUris(nodes);
ValidatePrimaryCount(cluster, nodes);
var self = nodes.FirstOrDefault(n => string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidTopologyException(
$"Self node '{selfNodeId}' is not a member of cluster '{cluster.ClusterId}'. " +
$"Members: {string.Join(", ", nodes.Select(n => n.NodeId))}.");
var peers = nodes
.Where(n => !string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase))
.Select(n => new RedundancyPeer(
NodeId: n.NodeId,
Role: n.RedundancyRole,
Host: n.Host,
OpcUaPort: n.OpcUaPort,
DashboardPort: n.DashboardPort,
ApplicationUri: n.ApplicationUri))
.ToList();
return new RedundancyTopology(
ClusterId: cluster.ClusterId,
SelfNodeId: self.NodeId,
SelfRole: self.RedundancyRole,
Mode: cluster.RedundancyMode,
Peers: peers,
SelfApplicationUri: self.ApplicationUri);
}
private static void ValidateClusterShape(ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
{
if (nodes.Count == 0)
throw new InvalidTopologyException($"Cluster '{cluster.ClusterId}' has zero nodes.");
// Decision #83 — v2.0 caps clusters at two nodes.
if (nodes.Count > 2)
throw new InvalidTopologyException(
$"Cluster '{cluster.ClusterId}' has {nodes.Count} nodes. v2.0 supports at most 2 nodes per cluster (decision #83).");
// Every node must belong to the given cluster.
var wrongCluster = nodes.FirstOrDefault(n =>
!string.Equals(n.ClusterId, cluster.ClusterId, StringComparison.OrdinalIgnoreCase));
if (wrongCluster is not null)
throw new InvalidTopologyException(
$"Node '{wrongCluster.NodeId}' belongs to cluster '{wrongCluster.ClusterId}', not '{cluster.ClusterId}'.");
}
private static void ValidateUniqueApplicationUris(IReadOnlyList<ClusterNode> nodes)
{
var dup = nodes
.GroupBy(n => n.ApplicationUri, StringComparer.Ordinal)
.FirstOrDefault(g => g.Count() > 1);
if (dup is not null)
throw new InvalidTopologyException(
$"Nodes {string.Join(", ", dup.Select(n => n.NodeId))} share ApplicationUri '{dup.Key}'. " +
$"OPC UA Part 4 requires unique ApplicationUri per server — clients pin trust here (decision #86).");
}
private static void ValidatePrimaryCount(ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
{
// Standalone mode: any role is fine. Warm / Hot: at most one Primary per cluster.
if (cluster.RedundancyMode == RedundancyMode.None) return;
var primaries = nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
if (primaries > 1)
throw new InvalidTopologyException(
$"Cluster '{cluster.ClusterId}' has {primaries} Primary nodes in redundancy mode {cluster.RedundancyMode}. " +
$"At most one Primary per cluster (decision #84). Runtime detects and demotes both to ServiceLevel 2 " +
$"per the 8-state matrix; startup fails fast to surface the misconfiguration earlier.");
}
}

View File

@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Latest observed reachability of the peer node per the Phase 6.3 Stream B.1/B.2 two-layer
/// probe model. HTTP layer is the fast-fail; UA layer is authoritative.
/// </summary>
/// <remarks>
/// Fed into the <see cref="ServiceLevelCalculator"/> as <c>peerHttpHealthy</c> +
/// <c>peerUaHealthy</c>. The concrete probe loops (<c>PeerHttpProbeLoop</c> +
/// <c>PeerUaProbeLoop</c>) live in a Stream B runtime follow-up — this type is the
/// contract the publisher reads; probers write via
/// <see cref="PeerReachabilityTracker"/>.
/// </remarks>
public sealed record PeerReachability(bool HttpHealthy, bool UaHealthy)
{
public static readonly PeerReachability Unknown = new(false, false);
public static readonly PeerReachability FullyHealthy = new(true, true);
/// <summary>True when both probes report healthy — the <c>ServiceLevelCalculator</c>'s peerReachable gate.</summary>
public bool BothHealthy => HttpHealthy && UaHealthy;
}
/// <summary>
/// Thread-safe holder of the latest <see cref="PeerReachability"/> per peer NodeId. Probe
/// loops call <see cref="Update"/>; the <see cref="RedundancyStatePublisher"/> reads via
/// <see cref="Get"/>.
/// </summary>
public sealed class PeerReachabilityTracker
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, PeerReachability> _byPeer =
new(StringComparer.OrdinalIgnoreCase);
public void Update(string peerNodeId, PeerReachability reachability)
{
ArgumentException.ThrowIfNullOrWhiteSpace(peerNodeId);
_byPeer[peerNodeId] = reachability ?? throw new ArgumentNullException(nameof(reachability));
}
/// <summary>Current reachability for a peer. Returns <see cref="PeerReachability.Unknown"/> when not yet probed.</summary>
public PeerReachability Get(string peerNodeId) =>
_byPeer.TryGetValue(peerNodeId, out var r) ? r : PeerReachability.Unknown;
}

View File

@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Process-singleton holder of the current <see cref="RedundancyTopology"/>. Reads the
/// shared config DB at <see cref="InitializeAsync"/> time + re-reads on
/// <see cref="RefreshAsync"/> (called after <c>sp_PublishGeneration</c> completes so
/// operator role-swaps take effect without a process restart).
/// </summary>
/// <remarks>
/// <para>Per Phase 6.3 Stream A.1-A.2. The coordinator is the source of truth for the
/// <see cref="ServiceLevelCalculator"/> inputs: role (from topology), peer reachability
/// (from peer-probe loops — Stream B.1/B.2 follow-up), apply-in-progress (from
/// <see cref="ApplyLeaseRegistry"/>), topology-valid (from invariant checks at load time
/// + runtime detection of conflicting peer claims).</para>
///
/// <para>Topology refresh is CAS-style: a new <see cref="RedundancyTopology"/> instance
/// replaces the old one atomically via <see cref="Interlocked.Exchange{T}"/>. Readers
/// always see a coherent snapshot — never a partial transition.</para>
/// </remarks>
public sealed class RedundancyCoordinator
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbContextFactory;
private readonly ILogger<RedundancyCoordinator> _logger;
private readonly string _selfNodeId;
private readonly string _selfClusterId;
private RedundancyTopology? _current;
private bool _topologyValid = true;
public RedundancyCoordinator(
IDbContextFactory<OtOpcUaConfigDbContext> dbContextFactory,
ILogger<RedundancyCoordinator> logger,
string selfNodeId,
string selfClusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(selfNodeId);
ArgumentException.ThrowIfNullOrWhiteSpace(selfClusterId);
_dbContextFactory = dbContextFactory;
_logger = logger;
_selfNodeId = selfNodeId;
_selfClusterId = selfClusterId;
}
/// <summary>Last-loaded topology; null before <see cref="InitializeAsync"/> completes.</summary>
public RedundancyTopology? Current => Volatile.Read(ref _current);
/// <summary>
/// True when the last load/refresh completed without an invariant violation; false
/// forces <see cref="ServiceLevelCalculator"/> into the <see cref="ServiceLevelBand.InvalidTopology"/>
/// band regardless of other inputs.
/// </summary>
public bool IsTopologyValid => Volatile.Read(ref _topologyValid);
/// <summary>Load the topology for the first time. Throws on invariant violation.</summary>
public async Task InitializeAsync(CancellationToken ct)
{
await RefreshInternalAsync(throwOnInvalid: true, ct).ConfigureAwait(false);
}
/// <summary>
/// Re-read the topology from the shared DB. Called after <c>sp_PublishGeneration</c>
/// completes or after an Admin-triggered role-swap. Never throws — on invariant
/// violation it logs + flips <see cref="IsTopologyValid"/> false so the calculator
/// returns <see cref="ServiceLevelBand.InvalidTopology"/> = 2.
/// </summary>
public async Task RefreshAsync(CancellationToken ct)
{
await RefreshInternalAsync(throwOnInvalid: false, ct).ConfigureAwait(false);
}
private async Task RefreshInternalAsync(bool throwOnInvalid, CancellationToken ct)
{
await using var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var cluster = await db.ServerClusters.AsNoTracking()
.FirstOrDefaultAsync(c => c.ClusterId == _selfClusterId, ct).ConfigureAwait(false)
?? throw new InvalidTopologyException($"Cluster '{_selfClusterId}' not found in config DB.");
var nodes = await db.ClusterNodes.AsNoTracking()
.Where(n => n.ClusterId == _selfClusterId && n.Enabled)
.ToListAsync(ct).ConfigureAwait(false);
try
{
var topology = ClusterTopologyLoader.Load(_selfNodeId, cluster, nodes);
Volatile.Write(ref _current, topology);
Volatile.Write(ref _topologyValid, true);
_logger.LogInformation(
"Redundancy topology loaded: cluster={Cluster} self={Self} role={Role} mode={Mode} peers={PeerCount}",
topology.ClusterId, topology.SelfNodeId, topology.SelfRole, topology.Mode, topology.PeerCount);
}
catch (InvalidTopologyException ex)
{
Volatile.Write(ref _topologyValid, false);
_logger.LogError(ex,
"Redundancy topology invariant violation for cluster {Cluster}: {Reason}",
_selfClusterId, ex.Message);
if (throwOnInvalid) throw;
}
}
}

View File

@@ -0,0 +1,142 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Orchestrates Phase 6.3 Stream C: feeds the <see cref="ServiceLevelCalculator"/> with the
/// current (topology, peer reachability, apply-in-progress, recovery dwell, self health)
/// inputs and emits the resulting <see cref="byte"/> + labelled <see cref="ServiceLevelBand"/>
/// to subscribers. The OPC UA <c>ServiceLevel</c> variable node consumes this via
/// <see cref="OnStateChanged"/> on every tick.
/// </summary>
/// <remarks>
/// Pure orchestration — no background timer, no OPC UA stack dep. The caller (a
/// HostedService in a future PR, or a test) drives <see cref="ComputeAndPublish"/> at
/// whatever cadence is appropriate. Each call reads the inputs + recomputes the ServiceLevel
/// byte; state is fired on the <see cref="OnStateChanged"/> event when the byte differs from
/// the last emitted value (edge-triggered). The <see cref="OnServerUriArrayChanged"/> event
/// fires whenever the topology's <c>ServerUriArray</c> content changes.
/// </remarks>
public sealed class RedundancyStatePublisher
{
private readonly RedundancyCoordinator _coordinator;
private readonly ApplyLeaseRegistry _leases;
private readonly RecoveryStateManager _recovery;
private readonly PeerReachabilityTracker _peers;
private readonly Func<bool> _selfHealthy;
private readonly Func<bool> _operatorMaintenance;
private byte _lastByte = 255; // start at Authoritative — harmless before first tick
private IReadOnlyList<string>? _lastServerUriArray;
public RedundancyStatePublisher(
RedundancyCoordinator coordinator,
ApplyLeaseRegistry leases,
RecoveryStateManager recovery,
PeerReachabilityTracker peers,
Func<bool>? selfHealthy = null,
Func<bool>? operatorMaintenance = null)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(leases);
ArgumentNullException.ThrowIfNull(recovery);
ArgumentNullException.ThrowIfNull(peers);
_coordinator = coordinator;
_leases = leases;
_recovery = recovery;
_peers = peers;
_selfHealthy = selfHealthy ?? (() => true);
_operatorMaintenance = operatorMaintenance ?? (() => false);
}
/// <summary>
/// Fires with the current ServiceLevel byte + band on every call to
/// <see cref="ComputeAndPublish"/> when the byte differs from the previously-emitted one.
/// </summary>
public event Action<ServiceLevelSnapshot>? OnStateChanged;
/// <summary>
/// Fires when the cluster's ServerUriArray (self + peers) content changes — e.g. an
/// operator adds or removes a peer. Consumer is the OPC UA <c>ServerUriArray</c>
/// variable node in Stream C.2.
/// </summary>
public event Action<IReadOnlyList<string>>? OnServerUriArrayChanged;
/// <summary>Snapshot of the last-published ServiceLevel byte — diagnostics + tests.</summary>
public byte LastByte => _lastByte;
/// <summary>
/// Compute the current ServiceLevel + emit change events if anything moved. Caller
/// drives cadence — a 1 s tick in production is reasonable; tests drive it directly.
/// </summary>
public ServiceLevelSnapshot ComputeAndPublish()
{
var topology = _coordinator.Current;
if (topology is null)
{
// Not yet initialized — surface NoData so clients don't treat us as authoritative.
return Emit((byte)ServiceLevelBand.NoData, null);
}
// Aggregate peer reachability. For 2-node v2.0 clusters there is at most one peer;
// treat "all peers healthy" as the boolean input to the calculator.
var peerReachable = topology.Peers.All(p => _peers.Get(p.NodeId).BothHealthy);
var peerUaHealthy = topology.Peers.All(p => _peers.Get(p.NodeId).UaHealthy);
var peerHttpHealthy = topology.Peers.All(p => _peers.Get(p.NodeId).HttpHealthy);
var role = MapRole(topology.SelfRole);
var value = ServiceLevelCalculator.Compute(
role: role,
selfHealthy: _selfHealthy(),
peerUaHealthy: peerUaHealthy,
peerHttpHealthy: peerHttpHealthy,
applyInProgress: _leases.IsApplyInProgress,
recoveryDwellMet: _recovery.IsDwellMet(),
topologyValid: _coordinator.IsTopologyValid,
operatorMaintenance: _operatorMaintenance());
MaybeFireServerUriArray(topology);
return Emit(value, topology);
}
private static RedundancyRole MapRole(RedundancyRole role) => role switch
{
// Standalone is serving; treat as Primary for the matrix since the calculator
// already special-cases Standalone inside its Compute.
RedundancyRole.Primary => RedundancyRole.Primary,
RedundancyRole.Secondary => RedundancyRole.Secondary,
_ => RedundancyRole.Standalone,
};
private ServiceLevelSnapshot Emit(byte value, RedundancyTopology? topology)
{
var snap = new ServiceLevelSnapshot(
Value: value,
Band: ServiceLevelCalculator.Classify(value),
Topology: topology);
if (value != _lastByte)
{
_lastByte = value;
OnStateChanged?.Invoke(snap);
}
return snap;
}
private void MaybeFireServerUriArray(RedundancyTopology topology)
{
var current = topology.ServerUriArray();
if (_lastServerUriArray is null || !current.SequenceEqual(_lastServerUriArray, StringComparer.Ordinal))
{
_lastServerUriArray = current;
OnServerUriArrayChanged?.Invoke(current);
}
}
}
/// <summary>Per-tick output of <see cref="RedundancyStatePublisher.ComputeAndPublish"/>.</summary>
public sealed record ServiceLevelSnapshot(
byte Value,
ServiceLevelBand Band,
RedundancyTopology? Topology);

View File

@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Snapshot of the cluster topology the <see cref="RedundancyCoordinator"/> holds. Read
/// once at startup + refreshed on publish-generation notification. Immutable — every
/// refresh produces a new instance so observers can compare identity-equality to detect
/// topology change.
/// </summary>
/// <remarks>
/// Per Phase 6.3 Stream A.1. Invariants enforced by the loader (see
/// <see cref="ClusterTopologyLoader"/>): at most one Primary per cluster for
/// WarmActive/Hot redundancy modes; every node has a unique ApplicationUri (OPC UA
/// Part 4 requirement — clients pin trust here); at most 2 nodes total per cluster
/// (decision #83).
/// </remarks>
public sealed record RedundancyTopology(
string ClusterId,
string SelfNodeId,
RedundancyRole SelfRole,
RedundancyMode Mode,
IReadOnlyList<RedundancyPeer> Peers,
string SelfApplicationUri)
{
/// <summary>Peer count — 0 for a standalone (single-node) cluster, 1 for v2 two-node clusters.</summary>
public int PeerCount => Peers.Count;
/// <summary>
/// ServerUriArray shape per OPC UA Part 4 §6.6.2.2 — self first, peers in stable
/// deterministic order (lexicographic by NodeId), self's ApplicationUri always at index 0.
/// </summary>
public IReadOnlyList<string> ServerUriArray() =>
new[] { SelfApplicationUri }
.Concat(Peers.OrderBy(p => p.NodeId, StringComparer.OrdinalIgnoreCase).Select(p => p.ApplicationUri))
.ToList();
}
/// <summary>One peer in the cluster (every node other than self).</summary>
/// <param name="NodeId">Peer's stable logical NodeId (e.g. <c>"LINE3-OPCUA-B"</c>).</param>
/// <param name="Role">Peer's declared redundancy role from the shared config DB.</param>
/// <param name="Host">Peer's hostname / IP — drives the health-probe target.</param>
/// <param name="OpcUaPort">Peer's OPC UA endpoint port.</param>
/// <param name="DashboardPort">Peer's dashboard / health-endpoint port.</param>
/// <param name="ApplicationUri">Peer's declared ApplicationUri (carried in <see cref="RedundancyTopology.ServerUriArray"/>).</param>
public sealed record RedundancyPeer(
string NodeId,
RedundancyRole Role,
string Host,
int OpcUaPort,
int DashboardPort,
string ApplicationUri);
/// <summary>Thrown when the loader detects a topology-invariant violation at startup or refresh.</summary>
public sealed class InvalidTopologyException(string message) : Exception(message);

View File

@@ -0,0 +1,165 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class EquipmentImportBatchServiceTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
private readonly EquipmentImportBatchService _svc;
public EquipmentImportBatchServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"import-batch-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
_svc = new EquipmentImportBatchService(_db);
}
public void Dispose() => _db.Dispose();
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
{
ZTag = zTag,
MachineCode = "mc",
SAPID = "sap",
EquipmentId = "eq-id",
EquipmentUuid = Guid.NewGuid().ToString(),
Name = name,
UnsAreaName = "area",
UnsLineName = "line",
};
[Fact]
public async Task CreateBatch_PopulatesId_AndTimestamp()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
batch.Id.ShouldNotBe(Guid.Empty);
batch.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
batch.RowsStaged.ShouldBe(0);
}
[Fact]
public async Task StageRows_AcceptedAndRejected_AllPersist()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
acceptedRows: [Row("z-1"), Row("z-2")],
rejectedRows: [new EquipmentCsvRowError(LineNumber: 5, Reason: "duplicate ZTag")],
CancellationToken.None);
var reloaded = await _db.EquipmentImportBatches.Include(b => b.Rows).FirstAsync(b => b.Id == batch.Id);
reloaded.RowsStaged.ShouldBe(3);
reloaded.RowsAccepted.ShouldBe(2);
reloaded.RowsRejected.ShouldBe(1);
reloaded.Rows.Count.ShouldBe(3);
reloaded.Rows.Count(r => r.IsAccepted).ShouldBe(2);
reloaded.Rows.Single(r => !r.IsAccepted).RejectReason.ShouldBe("duplicate ZTag");
}
[Fact]
public async Task DropBatch_RemovesBatch_AndCascades_Rows()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.DropBatchAsync(batch.Id, CancellationToken.None);
(await _db.EquipmentImportBatches.AnyAsync(b => b.Id == batch.Id)).ShouldBeFalse();
(await _db.EquipmentImportRows.AnyAsync(r => r.BatchId == batch.Id)).ShouldBeFalse("cascaded delete clears rows");
}
[Fact]
public async Task DropBatch_AfterFinalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, generationId: 1, driverInstanceIdForRows: "drv-1", unsLineIdForRows: "line-1", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.DropBatchAsync(batch.Id, CancellationToken.None));
}
[Fact]
public async Task Finalise_AcceptedRows_BecomeEquipment()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
[Row("z-1", name: "alpha"), Row("z-2", name: "beta")],
rejectedRows: [new EquipmentCsvRowError(1, "rejected")],
CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 5, "drv-modbus", "line-warsaw", CancellationToken.None);
var equipment = await _db.Equipment.Where(e => e.GenerationId == 5).ToListAsync();
equipment.Count.ShouldBe(2);
equipment.Select(e => e.Name).ShouldBe(["alpha", "beta"], ignoreOrder: true);
equipment.All(e => e.DriverInstanceId == "drv-modbus").ShouldBeTrue();
equipment.All(e => e.UnsLineId == "line-warsaw").ShouldBeTrue();
var reloaded = await _db.EquipmentImportBatches.FirstAsync(b => b.Id == batch.Id);
reloaded.FinalisedAtUtc.ShouldNotBeNull();
}
[Fact]
public async Task Finalise_Twice_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.FinaliseBatchAsync(batch.Id, 2, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Finalise_MissingBatch_Throws()
{
await Should.ThrowAsync<ImportBatchNotFoundException>(
() => _svc.FinaliseBatchAsync(Guid.NewGuid(), 1, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Stage_After_Finalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.StageRowsAsync(batch.Id, [Row("z-2")], [], CancellationToken.None));
}
[Fact]
public async Task ListByUser_FiltersByCreator_AndFinalised()
{
var a = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var b = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
await _svc.StageRowsAsync(a.Id, [Row("z-a")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(a.Id, 1, "d", "l", CancellationToken.None);
_ = b;
var aliceOpen = await _svc.ListByUserAsync("alice", includeFinalised: false, CancellationToken.None);
aliceOpen.ShouldBeEmpty("alice's only batch is finalised");
var aliceAll = await _svc.ListByUserAsync("alice", includeFinalised: true, CancellationToken.None);
aliceAll.Count.ShouldBe(1);
var bobOpen = await _svc.ListByUserAsync("bob", includeFinalised: false, CancellationToken.None);
bobOpen.Count.ShouldBe(1);
}
[Fact]
public async Task DropBatch_Unknown_IsNoOp()
{
await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None);
// no throw
}
}

View File

@@ -31,6 +31,8 @@ public sealed class SchemaComplianceTests
"DriverHostStatus",
"DriverInstanceResilienceStatus",
"LdapGroupRoleMapping",
"EquipmentImportBatch",
"EquipmentImportRow",
};
var actual = QueryStrings(@"
@@ -78,6 +80,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;",
"CK_ServerCluster_RedundancyMode_NodeCount",
"CK_Device_DeviceConfig_IsJson",
"CK_DriverInstance_DriverConfig_IsJson",
"CK_DriverInstance_ResilienceConfig_IsJson",
"CK_PollGroup_IntervalMs_Min",
"CK_Tag_TagConfig_IsJson",
"CK_ConfigAuditLog_DetailsJson_IsJson",

View File

@@ -0,0 +1,245 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
[Trait("Category", "Unit")]
public sealed class PollGroupEngineTests
{
private sealed class FakeSource
{
public ConcurrentDictionary<string, object?> Values { get; } = new();
public int ReadCount;
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> refs, CancellationToken ct)
{
Interlocked.Increment(ref ReadCount);
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> snapshots = refs
.Select(r => Values.TryGetValue(r, out var v)
? new DataValueSnapshot(v, 0u, now, now)
: new DataValueSnapshot(null, 0x80340000u, null, now))
.ToList();
return Task.FromResult(snapshots);
}
}
[Fact]
public async Task Initial_poll_force_raises_every_subscribed_tag()
{
var src = new FakeSource();
src.Values["A"] = 1;
src.Values["B"] = "hello";
var events = new ConcurrentQueue<(ISubscriptionHandle h, string r, DataValueSnapshot s)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["A", "B"], TimeSpan.FromMilliseconds(200));
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
events.Select(e => e.r).ShouldBe(["A", "B"], ignoreOrder: true);
engine.Unsubscribe(handle).ShouldBeTrue();
}
[Fact]
public async Task Unchanged_value_raises_only_once()
{
var src = new FakeSource();
src.Values["X"] = 42;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await Task.Delay(500);
engine.Unsubscribe(handle);
events.Count.ShouldBe(1);
}
[Fact]
public async Task Value_change_raises_new_event()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
src.Values["X"] = 2;
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
engine.Unsubscribe(handle);
events.Last().Item3.Value.ShouldBe(2);
}
[Fact]
public async Task Unsubscribe_halts_the_loop()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
engine.Unsubscribe(handle).ShouldBeTrue();
var afterUnsub = events.Count;
src.Values["X"] = 999;
await Task.Delay(400);
events.Count.ShouldBe(afterUnsub);
}
[Fact]
public async Task Interval_below_floor_is_clamped()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<(ISubscriptionHandle, string, DataValueSnapshot)>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue((h, r, s)),
minInterval: TimeSpan.FromMilliseconds(200));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(5));
await Task.Delay(300);
engine.Unsubscribe(handle);
// 300 ms window, 200 ms floor, stable value → initial push + at most 1 extra poll.
// With zero changes only the initial-data push fires.
events.Count.ShouldBe(1);
}
[Fact]
public async Task Multiple_subscriptions_are_independent()
{
var src = new FakeSource();
src.Values["A"] = 1;
src.Values["B"] = 2;
var a = new ConcurrentQueue<string>();
var b = new ConcurrentQueue<string>();
await using var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) =>
{
if (r == "A") a.Enqueue(r);
else if (r == "B") b.Enqueue(r);
});
var ha = engine.Subscribe(["A"], TimeSpan.FromMilliseconds(100));
var hb = engine.Subscribe(["B"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => a.Count >= 1 && b.Count >= 1, TimeSpan.FromSeconds(2));
engine.Unsubscribe(ha);
var aCount = a.Count;
src.Values["B"] = 77;
await WaitForAsync(() => b.Count >= 2, TimeSpan.FromSeconds(2));
a.Count.ShouldBe(aCount);
b.Count.ShouldBeGreaterThanOrEqualTo(2);
engine.Unsubscribe(hb);
}
[Fact]
public async Task Reader_exception_does_not_crash_loop()
{
var throwCount = 0;
var readCount = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
if (Interlocked.Increment(ref readCount) <= 2)
{
Interlocked.Increment(ref throwCount);
throw new InvalidOperationException("boom");
}
var now = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
refs.Select(r => new DataValueSnapshot(1, 0u, now, now)).ToList());
}
var events = new ConcurrentQueue<string>();
await using var engine = new PollGroupEngine(Reader,
(h, r, s) => events.Enqueue(r));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
engine.Unsubscribe(handle);
throwCount.ShouldBe(2);
events.Count.ShouldBeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task Unsubscribe_unknown_handle_returns_false()
{
var src = new FakeSource();
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
var foreign = new DummyHandle();
engine.Unsubscribe(foreign).ShouldBeFalse();
}
[Fact]
public async Task ActiveSubscriptionCount_tracks_lifecycle()
{
var src = new FakeSource();
src.Values["X"] = 1;
await using var engine = new PollGroupEngine(src.ReadAsync, (_, _, _) => { });
engine.ActiveSubscriptionCount.ShouldBe(0);
var h1 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
var h2 = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(200));
engine.ActiveSubscriptionCount.ShouldBe(2);
engine.Unsubscribe(h1);
engine.ActiveSubscriptionCount.ShouldBe(1);
engine.Unsubscribe(h2);
engine.ActiveSubscriptionCount.ShouldBe(0);
}
[Fact]
public async Task DisposeAsync_cancels_all_subscriptions()
{
var src = new FakeSource();
src.Values["X"] = 1;
var events = new ConcurrentQueue<string>();
var engine = new PollGroupEngine(src.ReadAsync,
(h, r, s) => events.Enqueue(r));
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
_ = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(100));
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
await engine.DisposeAsync();
engine.ActiveSubscriptionCount.ShouldBe(0);
var afterDispose = events.Count;
await Task.Delay(300);
// After dispose no more events — everything is cancelled.
events.Count.ShouldBe(afterDispose);
}
private sealed record DummyHandle : ISubscriptionHandle
{
public string DiagnosticId => "dummy";
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}

View File

@@ -0,0 +1,158 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
[Trait("Category", "Unit")]
public sealed class IdentificationFolderBuilderTests
{
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
public List<(string BrowseName, DriverDataType DataType, object? Value)> Properties { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this; // flat recording — identification fields land in the same bucket
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> throw new NotSupportedException("Identification fields use AddProperty, not Variable");
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> Properties.Add((browseName, dataType, value));
}
private static Equipment EmptyEquipment() => new()
{
EquipmentId = "EQ-000000000001",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "eq-1",
MachineCode = "machine_001",
};
private static Equipment FullyPopulatedEquipment() => new()
{
EquipmentId = "EQ-000000000001",
DriverInstanceId = "drv-1",
UnsLineId = "line-1",
Name = "eq-1",
MachineCode = "machine_001",
Manufacturer = "Siemens",
Model = "S7-1500",
SerialNumber = "SN-12345",
HardwareRevision = "Rev-A",
SoftwareRevision = "Fw-2.3.1",
YearOfConstruction = 2023,
AssetLocation = "Warsaw-West/Bldg-3",
ManufacturerUri = "https://siemens.example",
DeviceManualUri = "https://siemens.example/manual",
};
[Fact]
public void HasAnyFields_AllNull_ReturnsFalse()
{
IdentificationFolderBuilder.HasAnyFields(EmptyEquipment()).ShouldBeFalse();
}
[Fact]
public void HasAnyFields_OneNonNull_ReturnsTrue()
{
var eq = EmptyEquipment();
eq.SerialNumber = "SN-1";
IdentificationFolderBuilder.HasAnyFields(eq).ShouldBeTrue();
}
[Fact]
public void Build_AllNull_ReturnsNull_AndDoesNotEmit_Folder()
{
var builder = new RecordingBuilder();
var result = IdentificationFolderBuilder.Build(builder, EmptyEquipment());
result.ShouldBeNull();
builder.Folders.ShouldBeEmpty("no Identification folder when every field is null");
builder.Properties.ShouldBeEmpty();
}
[Fact]
public void Build_FullyPopulated_EmitsAllNineFields()
{
var builder = new RecordingBuilder();
var result = IdentificationFolderBuilder.Build(builder, FullyPopulatedEquipment());
result.ShouldNotBeNull();
builder.Folders.ShouldContain(f => f.BrowseName == "Identification");
builder.Properties.Count.ShouldBe(9);
builder.Properties.Select(p => p.BrowseName).ShouldBe(
["Manufacturer", "Model", "SerialNumber",
"HardwareRevision", "SoftwareRevision",
"YearOfConstruction", "AssetLocation",
"ManufacturerUri", "DeviceManualUri"],
"property order matches decision #139 exactly");
}
[Fact]
public void Build_OnlyNonNull_Are_Emitted()
{
var eq = EmptyEquipment();
eq.Manufacturer = "Siemens";
eq.SerialNumber = "SN-1";
eq.YearOfConstruction = 2024;
var builder = new RecordingBuilder();
IdentificationFolderBuilder.Build(builder, eq);
builder.Properties.Count.ShouldBe(3, "only the 3 non-null fields are exposed");
builder.Properties.Select(p => p.BrowseName).ShouldBe(
["Manufacturer", "SerialNumber", "YearOfConstruction"]);
}
[Fact]
public void YearOfConstruction_Maps_Short_To_Int32_DriverDataType()
{
var eq = EmptyEquipment();
eq.YearOfConstruction = 2023;
var builder = new RecordingBuilder();
IdentificationFolderBuilder.Build(builder, eq);
var prop = builder.Properties.Single(p => p.BrowseName == "YearOfConstruction");
prop.DataType.ShouldBe(DriverDataType.Int32);
prop.Value.ShouldBe(2023, "short is widened to int for OPC UA Int32 representation");
}
[Fact]
public void Build_StringValues_RoundTrip()
{
var eq = FullyPopulatedEquipment();
var builder = new RecordingBuilder();
IdentificationFolderBuilder.Build(builder, eq);
builder.Properties.Single(p => p.BrowseName == "Manufacturer").Value.ShouldBe("Siemens");
builder.Properties.Single(p => p.BrowseName == "DeviceManualUri").Value.ShouldBe("https://siemens.example/manual");
}
[Fact]
public void FieldNames_Match_Decision139_Exactly()
{
IdentificationFolderBuilder.FieldNames.ShouldBe(
["Manufacturer", "Model", "SerialNumber",
"HardwareRevision", "SoftwareRevision",
"YearOfConstruction", "AssetLocation",
"ManufacturerUri", "DeviceManualUri"]);
}
[Fact]
public void FolderName_Is_Identification()
{
IdentificationFolderBuilder.FolderName.ShouldBe("Identification");
}
}

View File

@@ -0,0 +1,166 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Unit")]
public sealed class DriverResilienceOptionsParserTests
{
[Fact]
public void NullJson_ReturnsPureTierDefaults()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, null, out var diag);
diag.ShouldBeNull();
options.Tier.ShouldBe(DriverTier.A);
options.Resolve(DriverCapability.Read).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
[Fact]
public void WhitespaceJson_ReturnsDefaults()
{
DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, " ", out var diag);
diag.ShouldBeNull();
}
[Fact]
public void MalformedJson_FallsBack_WithDiagnostic()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{not json", out var diag);
diag.ShouldNotBeNull();
diag.ShouldContain("malformed");
options.Tier.ShouldBe(DriverTier.A);
options.Resolve(DriverCapability.Read).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
[Fact]
public void EmptyObject_ReturnsDefaults()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, "{}", out var diag);
diag.ShouldBeNull();
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
[Fact]
public void ReadOverride_MergedIntoTierDefaults()
{
var json = """
{
"capabilityPolicies": {
"Read": { "timeoutSeconds": 5, "retryCount": 7, "breakerFailureThreshold": 2 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldBeNull();
var read = options.Resolve(DriverCapability.Read);
read.TimeoutSeconds.ShouldBe(5);
read.RetryCount.ShouldBe(7);
read.BreakerFailureThreshold.ShouldBe(2);
// Other capabilities untouched
options.Resolve(DriverCapability.Write).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
[Fact]
public void PartialPolicy_FillsMissingFieldsFromTierDefault()
{
var json = """
{
"capabilityPolicies": {
"Read": { "retryCount": 10 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
var read = options.Resolve(DriverCapability.Read);
var tierDefault = DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read];
read.RetryCount.ShouldBe(10);
read.TimeoutSeconds.ShouldBe(tierDefault.TimeoutSeconds, "partial override; timeout falls back to tier default");
read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
}
[Fact]
public void BulkheadOverrides_AreHonored()
{
var json = """
{ "bulkheadMaxConcurrent": 100, "bulkheadMaxQueue": 500 }
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, json, out _);
options.BulkheadMaxConcurrent.ShouldBe(100);
options.BulkheadMaxQueue.ShouldBe(500);
}
[Fact]
public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
{
var json = """
{
"capabilityPolicies": {
"InventedCapability": { "timeoutSeconds": 99 }
}
}
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldNotBeNull();
diag.ShouldContain("InventedCapability");
// Known capabilities untouched.
options.Resolve(DriverCapability.Read).ShouldBe(
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
[Fact]
public void PropertyNames_AreCaseInsensitive()
{
var json = """
{ "BULKHEADMAXCONCURRENT": 42 }
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out _);
options.BulkheadMaxConcurrent.ShouldBe(42);
}
[Fact]
public void CapabilityName_IsCaseInsensitive()
{
var json = """
{ "capabilityPolicies": { "read": { "retryCount": 99 } } }
""";
var options = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.A, json, out var diag);
diag.ShouldBeNull();
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
}
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
[InlineData(DriverTier.C)]
public void EveryTier_WithEmptyJson_RoundTrips_Its_Defaults(DriverTier tier)
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, "{}", out var diag);
diag.ShouldBeNull();
options.Tier.ShouldBe(tier);
foreach (var cap in Enum.GetValues<DriverCapability>())
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
}
}

View File

@@ -0,0 +1,130 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Unit")]
public sealed class InFlightCounterTests
{
[Fact]
public void StartThenComplete_NetsToZero()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordCallStart("drv", "host-a");
tracker.RecordCallComplete("drv", "host-a");
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
}
[Fact]
public void NestedStarts_SumDepth()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordCallStart("drv", "host-a");
tracker.RecordCallStart("drv", "host-a");
tracker.RecordCallStart("drv", "host-a");
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(3);
tracker.RecordCallComplete("drv", "host-a");
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(2);
}
[Fact]
public void CompleteBeforeStart_ClampedToZero()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordCallComplete("drv", "host-a");
// A stray Complete without a matching Start shouldn't drive the counter negative.
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
}
[Fact]
public void DifferentHosts_TrackIndependently()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordCallStart("drv", "host-a");
tracker.RecordCallStart("drv", "host-a");
tracker.RecordCallStart("drv", "host-b");
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(2);
tracker.TryGet("drv", "host-b")!.CurrentInFlight.ShouldBe(1);
}
[Fact]
public void ConcurrentStarts_DoNotLose_Count()
{
var tracker = new DriverResilienceStatusTracker();
Parallel.For(0, 500, _ => tracker.RecordCallStart("drv", "host-a"));
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(500);
}
[Fact]
public async Task CapabilityInvoker_IncrementsTracker_DuringExecution()
{
var tracker = new DriverResilienceStatusTracker();
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-live",
() => new DriverResilienceOptions { Tier = DriverTier.A },
driverType: "Modbus",
statusTracker: tracker);
var observedMidCall = -1;
await invoker.ExecuteAsync(
DriverCapability.Read,
"plc-1",
async _ =>
{
observedMidCall = tracker.TryGet("drv-live", "plc-1")?.CurrentInFlight ?? -1;
await Task.Yield();
return 42;
},
CancellationToken.None);
observedMidCall.ShouldBe(1, "during call, in-flight == 1");
tracker.TryGet("drv-live", "plc-1")!.CurrentInFlight.ShouldBe(0, "post-call, counter decremented");
}
[Fact]
public async Task CapabilityInvoker_ExceptionPath_DecrementsCounter()
{
var tracker = new DriverResilienceStatusTracker();
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-live",
() => new DriverResilienceOptions { Tier = DriverTier.A },
statusTracker: tracker);
await Should.ThrowAsync<InvalidOperationException>(async () =>
await invoker.ExecuteAsync<int>(
DriverCapability.Write,
"plc-1",
_ => throw new InvalidOperationException("boom"),
CancellationToken.None));
tracker.TryGet("drv-live", "plc-1")!.CurrentInFlight.ShouldBe(0,
"finally-block must decrement even when call-site throws");
}
[Fact]
public async Task CapabilityInvoker_WithoutTracker_DoesNotThrow()
{
var invoker = new CapabilityInvoker(
new DriverResiliencePipelineBuilder(),
"drv-live",
() => new DriverResilienceOptions { Tier = DriverTier.A },
statusTracker: null);
var result = await invoker.ExecuteAsync(
DriverCapability.Read, "host-1",
_ => ValueTask.FromResult(7),
CancellationToken.None);
result.ShouldBe(7);
}
}

View File

@@ -0,0 +1,110 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
/// <summary>
/// Exercises the per-call host resolver contract against the shared
/// <see cref="DriverResiliencePipelineBuilder"/> + <see cref="CapabilityInvoker"/> — one
/// dead PLC behind a multi-device driver must NOT open the breaker for healthy sibling
/// PLCs (decision #144).
/// </summary>
[Trait("Category", "Unit")]
public sealed class PerCallHostResolverDispatchTests
{
private sealed class StaticResolver : IPerCallHostResolver
{
private readonly Dictionary<string, string> _map;
public StaticResolver(Dictionary<string, string> map) => _map = map;
public string ResolveHost(string fullReference) =>
_map.TryGetValue(fullReference, out var host) ? host : string.Empty;
}
[Fact]
public async Task DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver()
{
// Two PLCs behind one driver. Dead PLC keeps failing; healthy PLC must keep serving.
var builder = new DriverResiliencePipelineBuilder();
var options = new DriverResilienceOptions { Tier = DriverTier.B };
var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options);
var resolver = new StaticResolver(new Dictionary<string, string>
{
["tag-on-dead"] = "plc-dead",
["tag-on-alive"] = "plc-alive",
});
var threshold = options.Resolve(DriverCapability.Read).BreakerFailureThreshold;
for (var i = 0; i < threshold + 3; i++)
{
await Should.ThrowAsync<Exception>(async () =>
await invoker.ExecuteAsync(
DriverCapability.Read,
hostName: resolver.ResolveHost("tag-on-dead"),
_ => throw new InvalidOperationException("plc-dead unreachable"),
CancellationToken.None));
}
// Healthy PLC's pipeline is in a different bucket; the first call should succeed
// without hitting the dead-PLC breaker.
var aliveAttempts = 0;
await invoker.ExecuteAsync(
DriverCapability.Read,
hostName: resolver.ResolveHost("tag-on-alive"),
_ => { aliveAttempts++; return ValueTask.FromResult("ok"); },
CancellationToken.None);
aliveAttempts.ShouldBe(1, "decision #144 — per-PLC isolation keeps healthy PLCs serving");
}
[Fact]
public void Resolver_EmptyString_Treated_As_Single_Host_Fallback()
{
var resolver = new StaticResolver(new Dictionary<string, string>
{
["tag-unknown"] = "",
});
resolver.ResolveHost("tag-unknown").ShouldBe("");
resolver.ResolveHost("not-in-map").ShouldBe("", "unknown refs return empty so dispatch falls back to single-host");
}
[Fact]
public async Task WithoutResolver_SameHost_Shares_One_Pipeline()
{
// Without a resolver all calls share the DriverInstanceId pipeline — that's the
// pre-decision-#144 behavior single-host drivers should keep.
var builder = new DriverResiliencePipelineBuilder();
var options = new DriverResilienceOptions { Tier = DriverTier.A };
var invoker = new CapabilityInvoker(builder, "drv-single", () => options);
await invoker.ExecuteAsync(DriverCapability.Read, "drv-single",
_ => ValueTask.FromResult("a"), CancellationToken.None);
await invoker.ExecuteAsync(DriverCapability.Read, "drv-single",
_ => ValueTask.FromResult("b"), CancellationToken.None);
builder.CachedPipelineCount.ShouldBe(1, "single-host drivers share one pipeline");
}
[Fact]
public async Task WithResolver_TwoHosts_Get_Two_Pipelines()
{
var builder = new DriverResiliencePipelineBuilder();
var options = new DriverResilienceOptions { Tier = DriverTier.B };
var invoker = new CapabilityInvoker(builder, "drv-modbus", () => options);
var resolver = new StaticResolver(new Dictionary<string, string>
{
["tag-a"] = "plc-a",
["tag-b"] = "plc-b",
});
await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-a"),
_ => ValueTask.FromResult(1), CancellationToken.None);
await invoker.ExecuteAsync(DriverCapability.Read, resolver.ResolveHost("tag-b"),
_ => ValueTask.FromResult(2), CancellationToken.None);
builder.CachedPipelineCount.ShouldBe(2, "each host keyed on its own pipeline");
}
}

View File

@@ -0,0 +1,163 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class ClusterTopologyLoaderTests
{
private static ServerCluster Cluster(RedundancyMode mode = RedundancyMode.Warm) => new()
{
ClusterId = "c1",
Name = "Warsaw-West",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = mode,
CreatedBy = "test",
};
private static ClusterNode Node(string id, RedundancyRole role, string host, int port = 4840, string? appUri = null) => new()
{
NodeId = id,
ClusterId = "c1",
RedundancyRole = role,
Host = host,
OpcUaPort = port,
ApplicationUri = appUri ?? $"urn:{host}:OtOpcUa",
CreatedBy = "test",
};
[Fact]
public void SingleNode_Standalone_Loads()
{
var cluster = Cluster(RedundancyMode.None);
var nodes = new[] { Node("A", RedundancyRole.Standalone, "hostA") };
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.SelfNodeId.ShouldBe("A");
topology.SelfRole.ShouldBe(RedundancyRole.Standalone);
topology.Peers.ShouldBeEmpty();
topology.SelfApplicationUri.ShouldBe("urn:hostA:OtOpcUa");
}
[Fact]
public void TwoNode_Cluster_LoadsSelfAndPeer()
{
var cluster = Cluster();
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA"),
Node("B", RedundancyRole.Secondary, "hostB"),
};
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.SelfNodeId.ShouldBe("A");
topology.SelfRole.ShouldBe(RedundancyRole.Primary);
topology.Peers.Count.ShouldBe(1);
topology.Peers[0].NodeId.ShouldBe("B");
topology.Peers[0].Role.ShouldBe(RedundancyRole.Secondary);
}
[Fact]
public void ServerUriArray_Puts_Self_First_Peers_SortedLexicographically()
{
var cluster = Cluster();
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:A"),
Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:B"),
};
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.ServerUriArray().ShouldBe(["urn:A", "urn:B"]);
}
[Fact]
public void EmptyNodes_Throws()
{
Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), []));
}
[Fact]
public void SelfNotInCluster_Throws()
{
var nodes = new[] { Node("B", RedundancyRole.Primary, "hostB") };
Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A-missing", Cluster(), nodes));
}
[Fact]
public void ThreeNodeCluster_Rejected_Per_Decision83()
{
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA"),
Node("B", RedundancyRole.Secondary, "hostB"),
Node("C", RedundancyRole.Secondary, "hostC"),
};
var ex = Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
ex.Message.ShouldContain("decision #83");
}
[Fact]
public void DuplicateApplicationUri_Rejected()
{
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:shared"),
Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:shared"),
};
var ex = Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
ex.Message.ShouldContain("ApplicationUri");
}
[Fact]
public void TwoPrimaries_InWarmMode_Rejected()
{
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA"),
Node("B", RedundancyRole.Primary, "hostB"),
};
var ex = Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(RedundancyMode.Warm), nodes));
ex.Message.ShouldContain("2 Primary");
}
[Fact]
public void CrossCluster_Node_Rejected()
{
var foreign = Node("B", RedundancyRole.Secondary, "hostB");
foreign.ClusterId = "c-other";
var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA"), foreign };
Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
}
[Fact]
public void None_Mode_Allows_Any_Role_Mix()
{
// Standalone clusters don't enforce Primary-count; operator can pick anything.
var cluster = Cluster(RedundancyMode.None);
var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA") };
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.Mode.ShouldBe(RedundancyMode.None);
}
}

View File

@@ -0,0 +1,213 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class RedundancyStatePublisherTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
public RedundancyStatePublisherTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"redundancy-publisher-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
_dbFactory = new DbContextFactory(options);
}
public void Dispose() => _db.Dispose();
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => new(options);
}
private async Task<RedundancyCoordinator> SeedAndInitialize(string selfNodeId, params (string id, RedundancyRole role, string appUri)[] nodes)
{
var cluster = new ServerCluster
{
ClusterId = "c1",
Name = "Warsaw-West",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = nodes.Length == 1 ? RedundancyMode.None : RedundancyMode.Warm,
CreatedBy = "test",
};
_db.ServerClusters.Add(cluster);
foreach (var (id, role, appUri) in nodes)
{
_db.ClusterNodes.Add(new ClusterNode
{
NodeId = id,
ClusterId = "c1",
RedundancyRole = role,
Host = id.ToLowerInvariant(),
ApplicationUri = appUri,
CreatedBy = "test",
});
}
await _db.SaveChangesAsync();
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, selfNodeId, "c1");
await coordinator.InitializeAsync(CancellationToken.None);
return coordinator;
}
[Fact]
public async Task BeforeInit_Publishes_NoData()
{
// Coordinator not initialized — current topology is null.
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, "A", "c1");
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker());
var snap = publisher.ComputeAndPublish();
snap.Band.ShouldBe(ServiceLevelBand.NoData);
snap.Value.ShouldBe((byte)1);
await Task.Yield();
}
[Fact]
public async Task AuthoritativePrimary_WhenHealthyAndPeerReachable()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)255);
snap.Band.ShouldBe(ServiceLevelBand.AuthoritativePrimary);
}
[Fact]
public async Task IsolatedPrimary_WhenPeerUnreachable_RetainsAuthority()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.Unknown);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)230);
}
[Fact]
public async Task MidApply_WhenLeaseOpen_Dominates()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var leases = new ApplyLeaseRegistry();
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
await using var lease = leases.BeginApplyLease(1, Guid.NewGuid());
var publisher = new RedundancyStatePublisher(
coordinator, leases, new RecoveryStateManager(), peers);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)200);
}
[Fact]
public async Task SelfUnhealthy_Returns_NoData()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers,
selfHealthy: () => false);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)1);
}
[Fact]
public async Task OnStateChanged_FiresOnly_OnValueChange()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var emitCount = 0;
byte? lastEmitted = null;
publisher.OnStateChanged += snap => { emitCount++; lastEmitted = snap.Value; };
publisher.ComputeAndPublish(); // first tick — emits 255 since _lastByte was seeded at 255; no change
peers.Update("B", PeerReachability.Unknown);
publisher.ComputeAndPublish(); // 255 → 230 transition — emits
publisher.ComputeAndPublish(); // still 230 — no emit
emitCount.ShouldBe(1);
lastEmitted.ShouldBe((byte)230);
}
[Fact]
public async Task OnServerUriArrayChanged_FiresOnce_PerTopology()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var emits = new List<IReadOnlyList<string>>();
publisher.OnServerUriArrayChanged += arr => emits.Add(arr);
publisher.ComputeAndPublish();
publisher.ComputeAndPublish();
publisher.ComputeAndPublish();
emits.Count.ShouldBe(1, "ServerUriArray event is edge-triggered on topology content change");
emits[0].ShouldBe(["urn:A", "urn:B"]);
}
[Fact]
public async Task Standalone_Cluster_IsAuthoritative_When_Healthy()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Standalone, "urn:A"));
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker());
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)255);
}
}

View File

@@ -0,0 +1,161 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class ResilienceStatusPublisherHostedServiceTests : IDisposable
{
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
private sealed class FakeClock : TimeProvider
{
public DateTime Utc { get; set; } = T0;
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
}
private sealed class InMemoryDbContextFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
private readonly DbContextOptions<OtOpcUaConfigDbContext> _options;
public InMemoryDbContextFactory(string dbName)
{
_options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
}
public OtOpcUaConfigDbContext CreateDbContext() => new(_options);
}
private readonly string _dbName = $"resilience-pub-{Guid.NewGuid():N}";
private readonly InMemoryDbContextFactory _factory;
private readonly OtOpcUaConfigDbContext _readCtx;
public ResilienceStatusPublisherHostedServiceTests()
{
_factory = new InMemoryDbContextFactory(_dbName);
_readCtx = _factory.CreateDbContext();
}
public void Dispose() => _readCtx.Dispose();
[Fact]
public async Task EmptyTracker_Tick_NoOp_NoRowsWritten()
{
var tracker = new DriverResilienceStatusTracker();
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
host.TickCount.ShouldBe(1);
(await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(0);
}
[Fact]
public async Task SingleHost_OnePairWithCounters_UpsertsNewRow()
{
var clock = new FakeClock();
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordBreakerOpen("drv-1", "plc-a", T0.AddSeconds(1));
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance,
timeProvider: clock);
clock.Utc = T0.AddSeconds(2);
await host.PersistOnceAsync(CancellationToken.None);
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
row.DriverInstanceId.ShouldBe("drv-1");
row.HostName.ShouldBe("plc-a");
row.ConsecutiveFailures.ShouldBe(2);
row.LastCircuitBreakerOpenUtc.ShouldBe(T0.AddSeconds(1));
row.LastSampledUtc.ShouldBe(T0.AddSeconds(2));
}
[Fact]
public async Task SecondTick_UpdatesExistingRow_InPlace()
{
var clock = new FakeClock();
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-1", "plc-a", T0);
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance,
timeProvider: clock);
clock.Utc = T0.AddSeconds(5);
await host.PersistOnceAsync(CancellationToken.None);
// Second tick: success resets the counter.
tracker.RecordSuccess("drv-1", "plc-a", T0.AddSeconds(6));
clock.Utc = T0.AddSeconds(10);
await host.PersistOnceAsync(CancellationToken.None);
(await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(1, "one row, updated in place");
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
row.ConsecutiveFailures.ShouldBe(0);
row.LastSampledUtc.ShouldBe(T0.AddSeconds(10));
}
[Fact]
public async Task MultipleHosts_BothPersist_Independently()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordFailure("drv-1", "plc-a", T0);
tracker.RecordFailure("drv-1", "plc-b", T0);
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
var rows = await _readCtx.DriverInstanceResilienceStatuses
.OrderBy(r => r.HostName)
.ToListAsync();
rows.Count.ShouldBe(2);
rows[0].HostName.ShouldBe("plc-a");
rows[0].ConsecutiveFailures.ShouldBe(2);
rows[1].HostName.ShouldBe("plc-b");
rows[1].ConsecutiveFailures.ShouldBe(1);
}
[Fact]
public async Task FootprintCounters_Persist()
{
var tracker = new DriverResilienceStatusTracker();
tracker.RecordFootprint("drv-1", "plc-a",
baselineBytes: 100_000_000, currentBytes: 150_000_000, T0);
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
row.BaselineFootprintBytes.ShouldBe(100_000_000);
row.CurrentFootprintBytes.ShouldBe(150_000_000);
}
[Fact]
public async Task TickCount_Advances_OnEveryCall()
{
var tracker = new DriverResilienceStatusTracker();
var host = new ResilienceStatusPublisherHostedService(
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
await host.PersistOnceAsync(CancellationToken.None);
await host.PersistOnceAsync(CancellationToken.None);
await host.PersistOnceAsync(CancellationToken.None);
host.TickCount.ShouldBe(3);
}
}

View File

@@ -0,0 +1,152 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Stability;
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class ScheduledRecycleHostedServiceTests
{
private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc);
private sealed class FakeClock : TimeProvider
{
public DateTime Utc { get; set; } = T0;
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
}
private sealed class FakeSupervisor : IDriverSupervisor
{
public string DriverInstanceId => "tier-c-fake";
public int RecycleCount { get; private set; }
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
{
RecycleCount++;
return Task.CompletedTask;
}
}
private sealed class ThrowingSupervisor : IDriverSupervisor
{
public string DriverInstanceId => "tier-c-throws";
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
=> throw new InvalidOperationException("supervisor unavailable");
}
[Fact]
public async Task TickOnce_BeforeInterval_DoesNotFire()
{
var clock = new FakeClock();
var supervisor = new FakeSupervisor();
var scheduler = new ScheduledRecycleScheduler(
DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor,
NullLogger<ScheduledRecycleScheduler>.Instance);
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
host.AddScheduler(scheduler);
clock.Utc = T0.AddMinutes(1);
await host.TickOnceAsync(CancellationToken.None);
supervisor.RecycleCount.ShouldBe(0);
host.TickCount.ShouldBe(1);
}
[Fact]
public async Task TickOnce_AfterInterval_Fires()
{
var clock = new FakeClock();
var supervisor = new FakeSupervisor();
var scheduler = new ScheduledRecycleScheduler(
DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor,
NullLogger<ScheduledRecycleScheduler>.Instance);
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
host.AddScheduler(scheduler);
clock.Utc = T0.AddMinutes(6);
await host.TickOnceAsync(CancellationToken.None);
supervisor.RecycleCount.ShouldBe(1);
}
[Fact]
public async Task TickOnce_MultipleTicks_AccumulateCount()
{
var clock = new FakeClock();
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
await host.TickOnceAsync(CancellationToken.None);
await host.TickOnceAsync(CancellationToken.None);
await host.TickOnceAsync(CancellationToken.None);
host.TickCount.ShouldBe(3);
}
[Fact]
public async Task AddScheduler_AfterStart_Throws()
{
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance);
using var cts = new CancellationTokenSource();
cts.Cancel();
await host.StartAsync(cts.Token); // flips _started true even with cancelled token
await host.StopAsync(CancellationToken.None);
var scheduler = new ScheduledRecycleScheduler(
DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, new FakeSupervisor(),
NullLogger<ScheduledRecycleScheduler>.Instance);
Should.Throw<InvalidOperationException>(() => host.AddScheduler(scheduler));
}
[Fact]
public async Task OneSchedulerThrowing_DoesNotStopOthers()
{
var clock = new FakeClock();
var good = new FakeSupervisor();
var bad = new ThrowingSupervisor();
var goodSch = new ScheduledRecycleScheduler(
DriverTier.C, TimeSpan.FromMinutes(5), T0, good,
NullLogger<ScheduledRecycleScheduler>.Instance);
var badSch = new ScheduledRecycleScheduler(
DriverTier.C, TimeSpan.FromMinutes(5), T0, bad,
NullLogger<ScheduledRecycleScheduler>.Instance);
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
host.AddScheduler(badSch);
host.AddScheduler(goodSch);
clock.Utc = T0.AddMinutes(6);
await host.TickOnceAsync(CancellationToken.None);
good.RecycleCount.ShouldBe(1, "a faulting scheduler must not poison its neighbours");
}
[Fact]
public void SchedulerCount_MatchesAdded()
{
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance);
var sup = new FakeSupervisor();
host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, sup, NullLogger<ScheduledRecycleScheduler>.Instance));
host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(10), DateTime.UtcNow, sup, NullLogger<ScheduledRecycleScheduler>.Instance));
host.SchedulerCount.ShouldBe(2);
}
[Fact]
public async Task EmptyScheduler_List_TicksCleanly()
{
var clock = new FakeClock();
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
// No registered schedulers — tick is a no-op + counter still advances.
await host.TickOnceAsync(CancellationToken.None);
host.TickCount.ShouldBe(1);
}
}

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>