Compare commits
34 Commits
phase-6-4-
...
abcip-pr1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab587707f | ||
| 2172d49d2e | |||
|
|
ae8f226e45 | ||
| e032045247 | |||
|
|
ad131932d3 | ||
| 98b69ff4f9 | |||
|
|
016122841b | ||
| 244a36e03e | |||
|
|
4de94fab0d | ||
| fdd0bf52c3 | |||
|
|
7b50118b68 | ||
| eac457fa7c | |||
|
|
c1cab33e38 | ||
| 0c903ff4e0 | |||
|
|
c4a92f424a | ||
| 510e488ea4 | |||
| 8994e73a0b | |||
|
|
e71f44603c | ||
|
|
c4824bea12 | ||
| e588c4f980 | |||
|
|
84fe88fadb | ||
| 59f793f87c | |||
| 37ba9e8d14 | |||
|
|
a8401ab8fd | ||
|
|
19a0bfcc43 | ||
| fc7e18c7f5 | |||
|
|
ba42967943 | ||
| b912969805 | |||
|
|
f8d5b0fdbb | ||
| cc069509cd | |||
|
|
3b2d0474a7 | ||
| e1d38ecc66 | |||
|
|
99cf1197c5 | ||
| ad39f866e5 |
@@ -1,6 +1,14 @@
|
||||
# Phase 6.4 — Admin UI Completion
|
||||
|
||||
> **Status**: DRAFT — Phase 1 Stream E shipped the Admin scaffold + core pages; several feature-completeness items from its completion checklist (`phase-1-configuration-and-admin-scaffold.md` §Stream E) never landed. This phase closes them.
|
||||
> **Status**: **SHIPPED (data layer)** 2026-04-19 — Stream A.2 (UnsImpactAnalyzer + DraftRevisionToken) and Stream B.1 (EquipmentCsvImporter parser) merged to `v2` in PR #91. Exit gate in PR #92.
|
||||
>
|
||||
> Deferred follow-ups (Blazor UI + staging tables + address-space wiring):
|
||||
> - Stream A UI — UnsTab MudBlazor drag/drop + 409 concurrent-edit modal + Playwright smoke (task #153).
|
||||
> - Stream B follow-up — EquipmentImportBatch staging + FinaliseImportBatch transaction + CSV import UI (task #155).
|
||||
> - Stream C — DiffViewer refactor into base + 6 section plugins + 1000-row cap + SignalR paging (task #156).
|
||||
> - Stream D — IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder exposure (task #157).
|
||||
>
|
||||
> Baseline pre-Phase-6.4: 1137 solution tests → post-Phase-6.4 data layer: 1159 passing (+22).
|
||||
>
|
||||
> **Branch**: `v2/phase-6-4-admin-ui-completion`
|
||||
> **Estimated duration**: 2 weeks
|
||||
|
||||
109
docs/v2/v2-release-readiness.md
Normal file
109
docs/v2/v2-release-readiness.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# v2 Release Readiness
|
||||
|
||||
> **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.
|
||||
|
||||
## Release-readiness dashboard
|
||||
|
||||
| Phase | Shipped | Status |
|
||||
|---|---|---|
|
||||
| Phase 0 — Rename + entry gate | ✓ | Shipped |
|
||||
| Phase 1 — Configuration + Admin scaffold | ✓ | Shipped (some UI items deferred to 6.4) |
|
||||
| Phase 2 — Galaxy driver split (Proxy/Host/Shared) | ✓ | Shipped |
|
||||
| Phase 3 — OPC UA server + LDAP + security profiles | ✓ | Shipped |
|
||||
| Phase 4 — Redundancy scaffold (entities + endpoints) | ✓ | Shipped (runtime closes in 6.3) |
|
||||
| Phase 5 — Drivers | ⚠ partial | Galaxy / Modbus / S7 / OpcUaClient shipped; AB CIP / AB Legacy / TwinCAT / FOCAS deferred (task #120) |
|
||||
| Phase 6.1 — Resilience & Observability | ✓ | **SHIPPED** (PRs #78–83) |
|
||||
| Phase 6.2 — Authorization runtime | ◐ core | **SHIPPED (core)** (PRs #84–88); dispatch wiring + Admin UI deferred |
|
||||
| Phase 6.3 — Redundancy runtime | ◐ core | **SHIPPED (core)** (PRs #89–90); coordinator + UA-node wiring + Admin UI + interop deferred |
|
||||
| Phase 6.4 — Admin UI completion | ◐ data layer | **SHIPPED (data layer)** (PRs #91–92); Blazor UI + OPC 40010 address-space wiring deferred |
|
||||
|
||||
**Aggregate test counts:** 906 baseline (pre-Phase-6) → **1159 passing** across Phase 6. One pre-existing Client.CLI `SubscribeCommandTests.Execute_PrintsSubscriptionMessage` flake tracked separately.
|
||||
|
||||
## Release blockers (must close before v2 GA)
|
||||
|
||||
Ordered by severity + impact on production fitness.
|
||||
|
||||
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94)
|
||||
|
||||
**Closed**. `AuthorizationGate` + `NodeScopeResolver` now thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
|
||||
|
||||
Additional Stream C surfaces (not release-blocking, hardening only):
|
||||
|
||||
- Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.
|
||||
- CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).
|
||||
- Alarm Acknowledge / Confirm / Shelve gating.
|
||||
- Call (method invocation) gating.
|
||||
- Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.
|
||||
- 3-user integration matrix covering every operation × allow/deny.
|
||||
|
||||
These are additional hardening — the three highest-value surfaces (Read / Write / HistoryRead) are now gated, which covers the base-security gap for v2 GA.
|
||||
|
||||
### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96)
|
||||
|
||||
**Closed**. `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` end-to-end: bootstrap calls go through the timeout → retry → fallback-to-sealed pipeline; every central-DB success writes a fresh sealed snapshot so the next cache-miss has a known-good fallback; `StaleConfigFlag.IsStale` is now consumed by `HealthEndpointsHost.usingStaleConfig` so `/healthz` body reports reality.
|
||||
|
||||
Production activation: Program.cs switches `NodeBootstrap → SealedBootstrap` + constructs `OpcUaApplicationHost` with the `StaleConfigFlag` as an optional ctor parameter.
|
||||
|
||||
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 core~~ (tasks #145 + #147 — **CLOSED** 2026-04-19, PRs #98–99)
|
||||
|
||||
**Closed**. The runtime orchestration layer now exists end-to-end:
|
||||
|
||||
- `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.
|
||||
|
||||
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)
|
||||
|
||||
AB CIP, AB Legacy, TwinCAT ADS, FOCAS drivers are planned but unshipped. Decision pending on whether these are release-blocking for v2 GA or can slip to a v2.1 follow-up.
|
||||
|
||||
## Nice-to-haves (not release-blocking)
|
||||
|
||||
- **Admin UI** — Phase 6.1 Stream E.2/E.3 (`/hosts` column refresh), Phase 6.2 Stream D (`RoleGrantsTab` + `AclsTab` Probe), Phase 6.3 Stream E (`RedundancyTab`), Phase 6.4 Streams A/B UI pieces, Stream C DiffViewer, Stream D `IdentificationFields.razor`. Tasks #134, #144, #149, #153, #155, #156, #157.
|
||||
- **Background services** — Phase 6.1 Stream B.4 `ScheduledRecycleScheduler` HostedService (task #137), Phase 6.1 Stream A analyzer (task #135 — Roslyn analyzer asserting every capability surface routes through `CapabilityInvoker`).
|
||||
- **Multi-host dispatch** — Phase 6.1 Stream A follow-up (task #135). Currently every driver gets a single pipeline keyed on `driver.DriverInstanceId`; multi-host drivers (Modbus with N PLCs) need per-PLC host resolution so failing PLCs trip per-PLC breakers without poisoning siblings. Decision #144 requires this but we haven't wired it yet.
|
||||
|
||||
## Running the release-readiness check
|
||||
|
||||
```bash
|
||||
pwsh ./scripts/compliance/phase-6-all.ps1
|
||||
```
|
||||
|
||||
This meta-runner invokes each `phase-6-N-compliance.ps1` script in sequence and reports an aggregate PASS/FAIL. It is the single-command verification that what we claim is shipped still compiles + tests pass + the plan-level invariants are still satisfied.
|
||||
|
||||
Exit 0 = every phase passes its compliance checks + no test-count regression.
|
||||
|
||||
## Release-readiness exit criteria
|
||||
|
||||
v2 GA requires all of the following:
|
||||
|
||||
- [ ] All four Phase 6.N compliance scripts exit 0.
|
||||
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes with ≤ 1 known-flake failure.
|
||||
- [ ] Release blockers listed above all closed (or consciously deferred to v2.1 with a written decision).
|
||||
- [ ] Production deployment checklist (separate doc) signed off by Fleet Admin.
|
||||
- [ ] At least one end-to-end integration run against the live Galaxy on the dev box succeeds.
|
||||
- [ ] OPC UA conformance test (CTT or UA Compliance Test Tool) passes against the live endpoint.
|
||||
- [ ] Non-transparent redundancy cutover validated with at least one production client (Ignition 8.3 recommended — see decision #85).
|
||||
|
||||
## Change log
|
||||
|
||||
- **2026-04-19** — Release blocker #3 **closed** (PRs #98–99). 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 #91–92). Phase 6 core complete. Capstone doc created.
|
||||
- **2026-04-19** — Phase 6.3 core merged (PRs #89–90). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.
|
||||
- **2026-04-19** — Phase 6.2 core merged (PRs #84–88). `AuthorizationGate` + `TriePermissionEvaluator` + `LdapGroupRoleMapping` land; dispatch wiring + Admin UI deferred.
|
||||
- **2026-04-19** — Phase 6.1 shipped (PRs #78–83). Polly resilience + Tier A/B/C stability + health endpoints + LiteDB generation-sealed cache + Admin `/hosts` data layer all live.
|
||||
@@ -1,82 +1,95 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Phase 6.4 exit-gate compliance check — stub. Each `Assert-*` either passes
|
||||
(Write-Host green) or throws. Non-zero exit = fail.
|
||||
Phase 6.4 exit-gate compliance check. Each check either passes or records a
|
||||
failure; non-zero exit = fail.
|
||||
|
||||
.DESCRIPTION
|
||||
Validates Phase 6.4 (Admin UI completion) completion. Checks enumerated in
|
||||
Validates Phase 6.4 (Admin UI completion) progress. Checks enumerated in
|
||||
`docs/v2/implementation/phase-6-4-admin-ui-completion.md`
|
||||
§"Compliance Checks (run at exit gate)".
|
||||
|
||||
Current status: SCAFFOLD. Every check writes a TODO line and does NOT throw.
|
||||
Each implementation task in Phase 6.4 is responsible for replacing its TODO
|
||||
with a real check before closing that task.
|
||||
|
||||
.NOTES
|
||||
Usage: pwsh ./scripts/compliance/phase-6-4-compliance.ps1
|
||||
Exit: 0 = all checks passed (or are still TODO); non-zero = explicit fail
|
||||
Exit: 0 = all checks passed; non-zero = one or more FAILs
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$script:failures = 0
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Assert-Todo {
|
||||
param([string]$Check, [string]$ImplementationTask)
|
||||
Write-Host " [TODO] $Check (implement during $ImplementationTask)" -ForegroundColor Yellow
|
||||
function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green }
|
||||
function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ }
|
||||
function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow }
|
||||
|
||||
function Assert-FileExists {
|
||||
param([string]$C, [string]$P)
|
||||
if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" }
|
||||
else { Assert-Fail $C "missing file: $P" }
|
||||
}
|
||||
|
||||
function Assert-Pass {
|
||||
param([string]$Check)
|
||||
Write-Host " [PASS] $Check" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Assert-Fail {
|
||||
param([string]$Check, [string]$Reason)
|
||||
Write-Host " [FAIL] $Check — $Reason" -ForegroundColor Red
|
||||
$script:failures++
|
||||
function Assert-TextFound {
|
||||
param([string]$C, [string]$Pat, [string[]]$Paths)
|
||||
foreach ($p in $Paths) {
|
||||
$full = Join-Path $repoRoot $p
|
||||
if (-not (Test-Path $full)) { continue }
|
||||
if (Select-String -Path $full -Pattern $Pat -Quiet) {
|
||||
Assert-Pass "$C (matched in $p)"
|
||||
return
|
||||
}
|
||||
}
|
||||
Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Phase 6.4 compliance — Admin UI completion ===" -ForegroundColor Cyan
|
||||
Write-Host "=== Phase 6.4 compliance - Admin UI completion ===" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Stream A — UNS drag/move + impact preview"
|
||||
Assert-Todo "UNS drag/move — drag line across areas; modal shows correct impacted-equipment + tag counts" "Stream A.2"
|
||||
Assert-Todo "Concurrent-edit safety — session B saves draft mid-preview; session A Confirm returns 409" "Stream A.3 (DraftRevisionToken)"
|
||||
Assert-Todo "Cross-cluster drop disabled — actionable toast points to Export/Import" "Stream A.2"
|
||||
Assert-Todo "1000-node tree — drag-enter feedback < 100 ms" "Stream A.4"
|
||||
Write-Host "Stream A data layer - UnsImpactAnalyzer"
|
||||
Assert-FileExists "UnsImpactAnalyzer present" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs"
|
||||
Assert-TextFound "DraftRevisionToken present" "record DraftRevisionToken" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs")
|
||||
Assert-TextFound "Cross-cluster move rejected per decision #82" "CrossClusterMoveRejectedException" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs")
|
||||
Assert-TextFound "LineMove + AreaRename + LineMerge covered" "UnsMoveKind\.LineMerge" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream B — CSV import + staged-import + 5-identifier search"
|
||||
Assert-Todo "CSV header version — file missing '# OtOpcUaCsv v1' rejected pre-parse" "Stream B.1"
|
||||
Assert-Todo "CSV canonical identifier set — columns match decision #117 exactly" "Stream B.1"
|
||||
Assert-Todo "Staged-import atomicity — 10k-row FinaliseImportBatch < 30 s; user-scoped visibility; DropImportBatch rollback" "Stream B.3"
|
||||
Assert-Todo "Concurrent import + external reservation — finalize retries with conflict handling; no corruption" "Stream B.3"
|
||||
Assert-Todo "5-identifier search ranking — exact > prefix; published > draft for equal scores" "Stream B.4"
|
||||
Write-Host "Stream B data layer - EquipmentCsvImporter"
|
||||
Assert-FileExists "EquipmentCsvImporter present" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs"
|
||||
Assert-TextFound "CSV header version marker '# OtOpcUaCsv v1'" "OtOpcUaCsv v1" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Required columns match decision #117" "ZTag.+MachineCode.+SAPID.+EquipmentId.+EquipmentUuid" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Optional columns match decision #139 (Manufacturer)" "Manufacturer" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Optional columns include DeviceManualUri" "DeviceManualUri" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Rejects duplicate ZTag within file" "Duplicate ZTag" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
Assert-TextFound "Rejects unknown column" "unknown column" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream C — DiffViewer sections"
|
||||
Assert-Todo "Diff viewer section caps — 2000-row subtree-rename summary-only; 'Load full diff' paginates" "Stream C.2"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Stream D — Identification (OPC 40010)"
|
||||
Assert-Todo "OPC 40010 field list match — rendered fields match decision #139 exactly; no extras" "Stream D.1"
|
||||
Assert-Todo "OPC 40010 exposure — Identification sub-folder shows when non-null; absent when all null" "Stream D.3"
|
||||
Assert-Todo "ACL inheritance for Identification — Equipment-grant reads; no-grant denies both" "Stream D.4"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Visual compliance"
|
||||
Assert-Todo "Visual parity reviewer — FleetAdmin signoff vs admin-ui.md §Visual-Design; screenshot set checked in under docs/v2/visual-compliance/phase-6-4/" "Visual review"
|
||||
Write-Host "Deferred surfaces"
|
||||
Assert-Deferred "Stream A UI - UnsTab MudBlazor drag/drop + 409 modal + Playwright" "task #153"
|
||||
Assert-Deferred "Stream B follow-up - EquipmentImportBatch staging + FinaliseImportBatch + CSV import UI" "task #155"
|
||||
Assert-Deferred "Stream C - DiffViewer refactor + 6 section plugins + 1000-row cap" "task #156"
|
||||
Assert-Deferred "Stream D - IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder" "task #157"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Cross-cutting"
|
||||
Assert-Todo "Full solution dotnet test passes; no test-count regression vs pre-Phase-6.4 baseline" "Final exit-gate"
|
||||
Write-Host " Running full solution test suite..." -ForegroundColor DarkGray
|
||||
$prevPref = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1
|
||||
$ErrorActionPreference = $prevPref
|
||||
$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches
|
||||
$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches
|
||||
$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value }
|
||||
$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value }
|
||||
$baseline = 1137
|
||||
if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-6.4 baseline)" }
|
||||
else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" }
|
||||
|
||||
if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" }
|
||||
else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" }
|
||||
|
||||
Write-Host ""
|
||||
if ($script:failures -eq 0) {
|
||||
Write-Host "Phase 6.4 compliance: scaffold-mode PASS (all checks TODO)" -ForegroundColor Green
|
||||
Write-Host "Phase 6.4 compliance: PASS" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Phase 6.4 compliance: $script:failures FAIL(s)" -ForegroundColor Red
|
||||
|
||||
77
scripts/compliance/phase-6-all.ps1
Normal file
77
scripts/compliance/phase-6-all.ps1
Normal file
@@ -0,0 +1,77 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Meta-runner that invokes every per-phase Phase 6.x compliance script and
|
||||
reports an aggregate verdict.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs phase-6-1-compliance.ps1, phase-6-2, phase-6-3, phase-6-4 in sequence.
|
||||
Each sub-script returns its own exit code; this wrapper aggregates them.
|
||||
Useful before a v2 release tag + as the `dotnet test` companion in CI.
|
||||
|
||||
.NOTES
|
||||
Usage: pwsh ./scripts/compliance/phase-6-all.ps1
|
||||
Exit: 0 = every phase passed; 1 = one or more phases failed
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$phases = @(
|
||||
@{ Name = 'Phase 6.1 - Resilience & Observability'; Script = 'phase-6-1-compliance.ps1' },
|
||||
@{ Name = 'Phase 6.2 - Authorization runtime'; Script = 'phase-6-2-compliance.ps1' },
|
||||
@{ Name = 'Phase 6.3 - Redundancy runtime'; Script = 'phase-6-3-compliance.ps1' },
|
||||
@{ Name = 'Phase 6.4 - Admin UI completion'; Script = 'phase-6-4-compliance.ps1' }
|
||||
)
|
||||
|
||||
$results = @()
|
||||
$startedAt = Get-Date
|
||||
|
||||
foreach ($phase in $phases) {
|
||||
Write-Host ""
|
||||
Write-Host ""
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
Write-Host ("Running {0}" -f $phase.Name) -ForegroundColor Cyan
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot $phase.Script
|
||||
if (-not (Test-Path $scriptPath)) {
|
||||
Write-Host (" [MISSING] {0}" -f $phase.Script) -ForegroundColor Red
|
||||
$results += @{ Name = $phase.Name; Exit = 2 }
|
||||
continue
|
||||
}
|
||||
|
||||
# Invoke each sub-script in its own powershell.exe process so its local
|
||||
# $ErrorActionPreference + exit-code semantics can't interfere with the meta-runner's
|
||||
# state. Slower (one process spawn per phase) but makes aggregate PASS/FAIL match
|
||||
# standalone runs exactly.
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath
|
||||
$exitCode = $LASTEXITCODE
|
||||
$results += @{ Name = $phase.Name; Exit = $exitCode }
|
||||
}
|
||||
|
||||
$elapsed = (Get-Date) - $startedAt
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ""
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
Write-Host "Phase 6 compliance aggregate" -ForegroundColor Cyan
|
||||
Write-Host "=============================================================" -ForegroundColor DarkGray
|
||||
|
||||
$totalFailures = 0
|
||||
foreach ($r in $results) {
|
||||
$colour = if ($r.Exit -eq 0) { 'Green' } else { 'Red' }
|
||||
$tag = if ($r.Exit -eq 0) { 'PASS' } else { "FAIL (exit=$($r.Exit))" }
|
||||
Write-Host (" [{0}] {1}" -f $tag, $r.Name) -ForegroundColor $colour
|
||||
if ($r.Exit -ne 0) { $totalFailures++ }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("Elapsed: {0:N1} s" -f $elapsed.TotalSeconds) -ForegroundColor DarkGray
|
||||
|
||||
if ($totalFailures -eq 0) {
|
||||
Write-Host "Phase 6 aggregate: PASS" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
Write-Host ("Phase 6 aggregate: {0} phase(s) FAILED" -f $totalFailures) -ForegroundColor Red
|
||||
exit 1
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
1347
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
generated
Normal file
1347
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419161932_AddDriverInstanceResilienceConfig.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1505
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs
generated
Normal file
1505
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string></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);
|
||||
}
|
||||
146
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs
Normal file
146
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 > 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
@@ -34,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;
|
||||
|
||||
@@ -59,19 +61,45 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
// returns a child builder per Folder call and the caller threads nesting through those references.
|
||||
private FolderState _currentFolder = null!;
|
||||
|
||||
// Phase 6.2 Stream C follow-up — optional gate + scope resolver. When both are null
|
||||
// the old pre-Phase-6.2 dispatch path runs unchanged (backwards compat for every
|
||||
// integration test that constructs DriverNodeManager without the gate). When wired,
|
||||
// OnReadValue / OnWriteValue / HistoryRead all consult the gate before the invoker call.
|
||||
private readonly AuthorizationGate? _authzGate;
|
||||
private readonly NodeScopeResolver? _scopeResolver;
|
||||
|
||||
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
||||
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger)
|
||||
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
||||
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null)
|
||||
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
||||
{
|
||||
_driver = driver;
|
||||
_readable = driver as IReadable;
|
||||
_writable = driver as IWritable;
|
||||
_hostResolver = driver as IPerCallHostResolver;
|
||||
_invoker = invoker;
|
||||
_authzGate = authzGate;
|
||||
_scopeResolver = scopeResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -197,9 +225,23 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
try
|
||||
{
|
||||
var fullRef = node.NodeId.Identifier as string ?? "";
|
||||
|
||||
// Phase 6.2 Stream C — authorization gate. Runs ahead of the invoker so a denied
|
||||
// read never hits the driver. Returns true in lax mode when identity lacks LDAP
|
||||
// groups; strict mode denies those cases. See AuthorizationGate remarks.
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var scope = _scopeResolver.Resolve(fullRef);
|
||||
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.Read, scope))
|
||||
{
|
||||
statusCode = StatusCodes.BadUserAccessDenied;
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -390,6 +432,23 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
fullRef, classification, string.Join(",", roles));
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
// Phase 6.2 Stream C — additive gate check. The classification/role check above
|
||||
// is the pre-Phase-6.2 baseline; the gate adds per-tag ACL enforcement on top. In
|
||||
// lax mode (default during rollout) the gate falls through when the identity
|
||||
// lacks LDAP groups, so existing integration tests keep passing.
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var scope = _scopeResolver.Resolve(fullRef!);
|
||||
var writeOp = WriteAuthzPolicy.ToOpcUaOperation(classification);
|
||||
if (!_authzGate.IsAllowed(context.UserIdentity, writeOp, scope))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Write denied by ACL gate for {FullRef}: operation={Op} classification={Classification}",
|
||||
fullRef, writeOp, classification);
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
@@ -397,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)],
|
||||
@@ -482,11 +541,21 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||
{
|
||||
WriteAccessDenied(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
DriverCapability.HistoryRead,
|
||||
_driver.DriverInstanceId,
|
||||
ResolveHostFor(fullRef),
|
||||
async ct => await History.ReadRawAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
@@ -546,11 +615,21 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||
{
|
||||
WriteAccessDenied(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
DriverCapability.HistoryRead,
|
||||
_driver.DriverInstanceId,
|
||||
ResolveHostFor(fullRef),
|
||||
async ct => await History.ReadProcessedAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
@@ -603,11 +682,21 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||
{
|
||||
WriteAccessDenied(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -660,11 +749,24 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
// "all sources in the driver's namespace" per the IHistoryProvider contract.
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
|
||||
// fullRef is null for event-history queries that target a notifier (driver root).
|
||||
// Those are cluster-wide reads + need a different scope shape; skip the gate here
|
||||
// and let the driver-level authz handle them. Non-null path gets per-node gating.
|
||||
if (fullRef is not null && _authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||
{
|
||||
WriteAccessDenied(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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,
|
||||
@@ -721,6 +823,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
errors[i] = StatusCodes.BadInternalError;
|
||||
}
|
||||
|
||||
private static void WriteAccessDenied(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
|
||||
errors[i] = StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
|
||||
private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
@@ -23,6 +24,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly DriverHost _driverHost;
|
||||
private readonly IUserAuthenticator _authenticator;
|
||||
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
||||
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;
|
||||
@@ -32,12 +38,22 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
|
||||
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger,
|
||||
DriverResiliencePipelineBuilder? pipelineBuilder = null)
|
||||
DriverResiliencePipelineBuilder? pipelineBuilder = null,
|
||||
AuthorizationGate? authzGate = null,
|
||||
NodeScopeResolver? scopeResolver = null,
|
||||
StaleConfigFlag? staleConfigFlag = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
_authenticator = authenticator;
|
||||
_pipelineBuilder = pipelineBuilder ?? new DriverResiliencePipelineBuilder();
|
||||
_authzGate = authzGate;
|
||||
_scopeResolver = scopeResolver;
|
||||
_staleConfigFlag = staleConfigFlag;
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -64,7 +80,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
throw new InvalidOperationException(
|
||||
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
||||
|
||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory);
|
||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup);
|
||||
await _application.Start(_server).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||
@@ -77,6 +95,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_healthHost = new HealthEndpointsHost(
|
||||
_driverHost,
|
||||
_loggerFactory.CreateLogger<HealthEndpointsHost>(),
|
||||
usingStaleConfig: _staleConfigFlag is null ? null : () => _staleConfigFlag.IsStale,
|
||||
prefix: _options.HealthEndpointsPrefix);
|
||||
_healthHost.Start();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
private readonly DriverHost _driverHost;
|
||||
private readonly IUserAuthenticator _authenticator;
|
||||
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();
|
||||
|
||||
@@ -28,11 +32,19 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
DriverHost driverHost,
|
||||
IUserAuthenticator authenticator,
|
||||
DriverResiliencePipelineBuilder pipelineBuilder,
|
||||
ILoggerFactory loggerFactory)
|
||||
ILoggerFactory loggerFactory,
|
||||
AuthorizationGate? authzGate = 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;
|
||||
}
|
||||
|
||||
@@ -53,12 +65,19 @@ 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);
|
||||
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
||||
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
||||
_driverNodeManagers.Add(manager);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
42
src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/PeerReachability.cs
Normal file
42
src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/PeerReachability.cs
Normal 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;
|
||||
}
|
||||
107
src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/RedundancyCoordinator.cs
Normal file
107
src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/RedundancyCoordinator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
100
src/ZB.MOM.WW.OtOpcUa.Server/SealedBootstrap.cs
Normal file
100
src/ZB.MOM.WW.OtOpcUa.Server/SealedBootstrap.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.1 Stream D consumption hook — bootstraps the node's current generation through
|
||||
/// the <see cref="ResilientConfigReader"/> pipeline + writes every successful central-DB
|
||||
/// read into the <see cref="GenerationSealedCache"/> so the next cache-miss path has a
|
||||
/// sealed snapshot to fall back to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Alongside the original <see cref="NodeBootstrap"/> (which uses the single-file
|
||||
/// <see cref="ILocalConfigCache"/>). Program.cs can switch to this one once operators are
|
||||
/// ready for the generation-sealed semantics. The original stays for backward compat
|
||||
/// with the three integration tests that construct <see cref="NodeBootstrap"/> directly.</para>
|
||||
///
|
||||
/// <para>Closes release blocker #2 in <c>docs/v2/v2-release-readiness.md</c> — the
|
||||
/// generation-sealed cache + resilient reader + stale-config flag ship as unit-tested
|
||||
/// primitives in PR #81 but no production path consumed them until this wrapper.</para>
|
||||
/// </remarks>
|
||||
public sealed class SealedBootstrap
|
||||
{
|
||||
private readonly NodeOptions _options;
|
||||
private readonly GenerationSealedCache _cache;
|
||||
private readonly ResilientConfigReader _reader;
|
||||
private readonly StaleConfigFlag _staleFlag;
|
||||
private readonly ILogger<SealedBootstrap> _logger;
|
||||
|
||||
public SealedBootstrap(
|
||||
NodeOptions options,
|
||||
GenerationSealedCache cache,
|
||||
ResilientConfigReader reader,
|
||||
StaleConfigFlag staleFlag,
|
||||
ILogger<SealedBootstrap> logger)
|
||||
{
|
||||
_options = options;
|
||||
_cache = cache;
|
||||
_reader = reader;
|
||||
_staleFlag = staleFlag;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the current generation for this node. Routes the central-DB fetch through
|
||||
/// <see cref="ResilientConfigReader"/> (timeout → retry → fallback-to-cache) + seals a
|
||||
/// fresh snapshot on every successful DB read so a future cache-miss has something to
|
||||
/// serve.
|
||||
/// </summary>
|
||||
public async Task<BootstrapResult> LoadCurrentGenerationAsync(CancellationToken ct)
|
||||
{
|
||||
return await _reader.ReadAsync(
|
||||
_options.ClusterId,
|
||||
centralFetch: async innerCt => await FetchFromCentralAsync(innerCt).ConfigureAwait(false),
|
||||
fromSnapshot: snap => BootstrapResult.FromCache(snap.GenerationId),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<BootstrapResult> FetchFromCentralAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = new SqlConnection(_options.ConfigDbConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
|
||||
cmd.Parameters.AddWithValue("@n", _options.NodeId);
|
||||
cmd.Parameters.AddWithValue("@c", _options.ClusterId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogWarning("Cluster {Cluster} has no Published generation yet", _options.ClusterId);
|
||||
return BootstrapResult.EmptyFromDb();
|
||||
}
|
||||
|
||||
var generationId = reader.GetInt64(0);
|
||||
_logger.LogInformation("Bootstrapped from central DB: generation {GenerationId}; sealing snapshot", generationId);
|
||||
|
||||
// Seal a minimal snapshot with the generation pointer. A richer snapshot that carries
|
||||
// the full sp_GetGenerationContent payload lands when the bootstrap flow grows to
|
||||
// consume the content during offline operation (separate follow-up — see decision #148
|
||||
// and phase-6-1 Stream D.3). The pointer alone is enough for the fallback path to
|
||||
// surface the last-known-good generation id + flip UsingStaleConfig.
|
||||
await _cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = _options.ClusterId,
|
||||
GenerationId = generationId,
|
||||
CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = JsonSerializer.Serialize(new { generationId, source = "sp_GetCurrentGenerationForCluster" }),
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
// StaleConfigFlag bookkeeping: ResilientConfigReader.MarkFresh on the returning call
|
||||
// path; we're on the fresh branch so we don't touch the flag here.
|
||||
_ = _staleFlag; // held so the field isn't flagged unused
|
||||
|
||||
return BootstrapResult.FromDb(generationId);
|
||||
}
|
||||
}
|
||||
47
src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the
|
||||
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Today a simplified resolver that
|
||||
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
|
||||
/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
|
||||
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
|
||||
/// finer hierarchy only matters when operators want per-area or per-equipment grants;
|
||||
/// those still work for Cluster-level grants, and landing the finer resolution in a
|
||||
/// follow-up doesn't regress the base security model.</para>
|
||||
///
|
||||
/// <para>Thread-safety: the resolver is stateless once constructed. Callers may cache a
|
||||
/// single instance per DriverNodeManager without locks.</para>
|
||||
/// </remarks>
|
||||
public sealed class NodeScopeResolver
|
||||
{
|
||||
private readonly string _clusterId;
|
||||
|
||||
public NodeScopeResolver(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
_clusterId = clusterId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
|
||||
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only;
|
||||
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
|
||||
/// join against the Configuration DB to populate the full path.
|
||||
/// </summary>
|
||||
public NodeScope Resolve(string fullReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
|
||||
return new NodeScope
|
||||
{
|
||||
ClusterId = _clusterId,
|
||||
TagId = fullReference,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,4 +67,22 @@ public static class WriteAuthzPolicy
|
||||
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver-reported <see cref="SecurityClassification"/> to the
|
||||
/// <see cref="Core.Abstractions.OpcUaOperation"/> the Phase 6.2 evaluator consults
|
||||
/// for the matching <see cref="Configuration.Enums.NodePermissions"/> bit.
|
||||
/// FreeAccess + ViewOnly fall back to WriteOperate — the evaluator never sees them
|
||||
/// because <see cref="IsAllowed"/> short-circuits first.
|
||||
/// </summary>
|
||||
public static Core.Abstractions.OpcUaOperation ToOpcUaOperation(SecurityClassification classification) =>
|
||||
classification switch
|
||||
{
|
||||
SecurityClassification.Operate => Core.Abstractions.OpcUaOperation.WriteOperate,
|
||||
SecurityClassification.SecuredWrite => Core.Abstractions.OpcUaOperation.WriteOperate,
|
||||
SecurityClassification.Tune => Core.Abstractions.OpcUaOperation.WriteTune,
|
||||
SecurityClassification.VerifiedWrite => Core.Abstractions.OpcUaOperation.WriteConfigure,
|
||||
SecurityClassification.Configure => Core.Abstractions.OpcUaOperation.WriteConfigure,
|
||||
_ => Core.Abstractions.OpcUaOperation.WriteOperate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NodeScopeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_PopulatesClusterAndTag()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-warsaw");
|
||||
|
||||
var scope = resolver.Resolve("TestMachine_001/Oven/SetPoint");
|
||||
|
||||
scope.ClusterId.ShouldBe("c-warsaw");
|
||||
scope.TagId.ShouldBe("TestMachine_001/Oven/SetPoint");
|
||||
scope.Kind.ShouldBe(NodeHierarchyKind.Equipment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Leaves_UnsPath_Null_For_Phase1()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-1");
|
||||
|
||||
var scope = resolver.Resolve("tag-1");
|
||||
|
||||
// Phase 1 flat scope — finer resolution tracked as Stream C.12 follow-up.
|
||||
scope.NamespaceId.ShouldBeNull();
|
||||
scope.UnsAreaId.ShouldBeNull();
|
||||
scope.UnsLineId.ShouldBeNull();
|
||||
scope.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Throws_OnEmptyFullReference()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-1");
|
||||
|
||||
Should.Throw<ArgumentException>(() => resolver.Resolve(""));
|
||||
Should.Throw<ArgumentException>(() => resolver.Resolve(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_Throws_OnEmptyClusterId()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => new NodeScopeResolver(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolver_IsStateless_AcrossCalls()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c");
|
||||
var s1 = resolver.Resolve("tag-a");
|
||||
var s2 = resolver.Resolve("tag-b");
|
||||
|
||||
s1.TagId.ShouldBe("tag-a");
|
||||
s2.TagId.ShouldBe("tag-b");
|
||||
s1.ClusterId.ShouldBe("c");
|
||||
s2.ClusterId.ShouldBe("c");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration-style tests for the Phase 6.1 Stream D consumption hook — they don't touch
|
||||
/// SQL Server (the real SealedBootstrap does, via sp_GetCurrentGenerationForCluster), but
|
||||
/// they exercise ResilientConfigReader + GenerationSealedCache + StaleConfigFlag end-to-end
|
||||
/// by simulating central-DB outcomes through a direct ReadAsync call.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class SealedBootstrapIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-bootstrap-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
||||
File.SetAttributes(f, FileAttributes.Normal);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbSuccess_SealsSnapshot_And_FlagFresh()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10));
|
||||
|
||||
// Simulate the SealedBootstrap fresh-path: central DB returns generation id 42; the
|
||||
// bootstrap seals it + ResilientConfigReader marks the flag fresh.
|
||||
var result = await reader.ReadAsync(
|
||||
"c-a",
|
||||
centralFetch: async _ =>
|
||||
{
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "c-a",
|
||||
GenerationId = 42,
|
||||
CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"gen\":42}",
|
||||
}, CancellationToken.None);
|
||||
return (long?)42;
|
||||
},
|
||||
fromSnapshot: snap => (long?)snap.GenerationId,
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(42);
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
cache.TryGetCurrentGenerationId("c-a").ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbFails_FallsBackToSealedSnapshot_FlagStale()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
// Seed a prior sealed snapshot (simulating a previous successful boot).
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "c-a", GenerationId = 37, CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"gen\":37}",
|
||||
});
|
||||
|
||||
// Now simulate central DB down → fallback.
|
||||
var result = await reader.ReadAsync(
|
||||
"c-a",
|
||||
centralFetch: _ => throw new InvalidOperationException("SQL dead"),
|
||||
fromSnapshot: snap => (long?)snap.GenerationId,
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(37);
|
||||
flag.IsStale.ShouldBeTrue("cache fallback flips the /healthz flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSnapshot_AndCentralDown_Throws_ClearError()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<long?>(
|
||||
"c-a",
|
||||
centralFetch: _ => throw new InvalidOperationException("SQL dead"),
|
||||
fromSnapshot: snap => (long?)snap.GenerationId,
|
||||
CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuccessfulBootstrap_AfterFailure_ClearsStaleFlag()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "c-a", GenerationId = 1, CachedAt = DateTime.UtcNow, PayloadJson = "{}",
|
||||
});
|
||||
|
||||
// Fallback serves snapshot → flag goes stale.
|
||||
await reader.ReadAsync("c-a",
|
||||
centralFetch: _ => throw new InvalidOperationException("dead"),
|
||||
fromSnapshot: s => (long?)s.GenerationId,
|
||||
CancellationToken.None);
|
||||
flag.IsStale.ShouldBeTrue();
|
||||
|
||||
// Subsequent successful bootstrap clears it.
|
||||
await reader.ReadAsync("c-a",
|
||||
centralFetch: _ => ValueTask.FromResult((long?)5),
|
||||
fromSnapshot: s => (long?)s.GenerationId,
|
||||
CancellationToken.None);
|
||||
flag.IsStale.ShouldBeFalse("next successful DB round-trip clears the flag");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user