Resolve all 46 low-severity architecture review findings
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
<PackageVersion Include="Akka.Streams" Version="1.5.62" />
|
<PackageVersion Include="Akka.Streams" Version="1.5.62" />
|
||||||
<PackageVersion Include="Akka.Streams.TestKit" Version="1.5.62" />
|
<PackageVersion Include="Akka.Streams.TestKit" Version="1.5.62" />
|
||||||
<PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
<PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||||
<PackageVersion Include="bunit" Version="2.0.33-preview" />
|
<PackageVersion Include="bunit" Version="2.7.2" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
|
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
|
||||||
<!--
|
<!--
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
in Communication. Grpc.Core.Api is pinned explicitly here to keep the whole family on
|
in Communication. Grpc.Core.Api is pinned explicitly here to keep the whole family on
|
||||||
one version rather than letting it float in transitively.
|
one version rather than letting it float in transitively.
|
||||||
|
|
||||||
Not bumped here: Microsoft.Data.SqlClient and Newtonsoft.Json, also named in that note.
|
SqlClient is now pinned to EF SqlServer 10.0.7's 6.1.1 floor (2026-09-09 Low review).
|
||||||
Nothing forces them, and they are a data-access change with its own risk profile.
|
Newtonsoft.Json remains a separate data-access compatibility decision.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="Google.Protobuf" Version="3.34.1" />
|
<PackageVersion Include="Google.Protobuf" Version="3.34.1" />
|
||||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||||
@@ -44,10 +44,10 @@
|
|||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3" />
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.1" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
|
||||||
|
|||||||
@@ -26,26 +26,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -26,26 +26,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -27,26 +27,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -27,26 +27,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -27,26 +27,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -27,26 +27,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -27,26 +27,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -27,26 +27,12 @@
|
|||||||
"ProbeConnectTimeoutMs": 1000
|
"ProbeConnectTimeoutMs": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Database": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The site config tables now live in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path). SiteDbPath is read once at boot to drain
|
|
||||||
// a pre-Phase-2 scadabridge.db, and is unused after that - keep it until this node has
|
|
||||||
// started once.
|
|
||||||
"SiteDbPath": "/app/data/scadabridge.db"
|
|
||||||
},
|
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
"TagResolutionRetryInterval": "00:00:10",
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
"WriteTimeout": "00:00:30",
|
"WriteTimeout": "00:00:30",
|
||||||
"SeedReadTimeout": "00:00:30"
|
"SeedReadTimeout": "00:00:30"
|
||||||
},
|
},
|
||||||
"StoreAndForward": {
|
|
||||||
// Migration-only as of LocalDb Phase 2. The store-and-forward buffer now lives in the
|
|
||||||
// consolidated LocalDb database (LocalDb:Path) as the replicated sf_messages table.
|
|
||||||
// SqliteDbPath is read once at boot by SiteLocalDbLegacyMigrator to drain a pre-Phase-2
|
|
||||||
// file, and is unused after that - keep it until this node has started once.
|
|
||||||
"SqliteDbPath": "/app/data/store-and-forward.db"
|
|
||||||
},
|
|
||||||
"Communication": {
|
"Communication": {
|
||||||
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
// DEV-ONLY control-plane preshared key — NOT a real secret. Must be
|
||||||
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
// IDENTICAL on both nodes of the pair and match the central-side entry in
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ contract changes since August are additive; all eight migrations are sound and t
|
|||||||
matches. The test suite is broad; fresh execution evidence and its limits are in §7.
|
matches. The test suite is broad; fresh execution evidence and its limits are in §7.
|
||||||
|
|
||||||
The reviewed baseline contained **92 unique findings: 13 High, 33 Medium, 46 Low**.
|
The reviewed baseline contained **92 unique findings: 13 High, 33 Medium, 46 Low**.
|
||||||
**Remediation status: all 13 High and 33 Medium findings completed; 46 Low findings remain open.**
|
**Remediation status: all 13 High and 33 Medium findings completed; 46 / 46 Low findings completed (see §11).**
|
||||||
Completion evidence and limits are recorded in §§9–10.
|
Completion evidence and limits are recorded in §§9–10.
|
||||||
This includes low-priority test/documentation gaps and latent risks, not just reproduced failures.
|
This includes low-priority test/documentation gaps and latent risks, not just reproduced failures.
|
||||||
The reviewed High-priority findings were:
|
The reviewed High-priority findings were:
|
||||||
@@ -983,4 +983,110 @@ cross-reviews rechecked lifecycle, sandbox containment, relay provenance and mig
|
|||||||
explicit field/service metadata. Earlier fixture/build failures were corrected without
|
explicit field/service metadata. Earlier fixture/build failures were corrected without
|
||||||
weakening production guards: audit test doubles accept caller scope, and protocol-writing
|
weakening production guards: audit test doubles accept caller scope, and protocol-writing
|
||||||
fixtures use stable ephemeral key rings. These are not claimed as before-fix reproductions.
|
fixtures use stable ephemeral key rings. These are not claimed as before-fix reproductions.
|
||||||
- The 46 Low findings remain open. Focused test counts overlap the full suite and are not additive.
|
- Low findings are tracked in §11. Focused test counts overlap the full suite and are not additive.
|
||||||
|
|
||||||
|
|
||||||
|
## 11. Low-severity remediation progress
|
||||||
|
|
||||||
|
**Completed: 46 / 46 Low findings.** All 46 Low findings are in scope. This register supersedes the
|
||||||
|
baseline finding status only when a row is marked **Completed** with verification.
|
||||||
|
High and Medium completion records in §§9–10 remain authoritative.
|
||||||
|
|
||||||
|
Implementation follows the existing finding-specific fixes: preserve wire compatibility,
|
||||||
|
bound maintenance and debugging work, enforce authorization at application boundaries,
|
||||||
|
and correct documentation against current source. Independent UI, Flow Debugger, and
|
||||||
|
runtime/device groups run in parallel with exclusive file ownership. Transport,
|
||||||
|
maintenance, package/configuration work, and this register have one coordinating owner.
|
||||||
|
Behavior changes receive focused regression checks; documentation and configuration
|
||||||
|
changes receive source/structure validation. Final gates are full build, relevant/full
|
||||||
|
solution tests, diff review, and a local rig rebuild for shipping runtime changes.
|
||||||
|
|
||||||
|
| Finding | Status | Progress / evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| SR-6 | **Completed** | Device-to-script linkage is explicitly inferred; alarm causal parents remain reserved for actual writes. Runtime/Commons regressions pass. |
|
||||||
|
| SR-7 | **Completed** | Trace failure diagnostics use the undecorated logger; recursive trace capture regression passes. |
|
||||||
|
| SR-8 | **Completed** | Thread-static scheduler identity prevents cross-scheduler inlining; saturation regression passes. |
|
||||||
|
| SR-9 | **Completed** | Shared trigger gate has explicit process ownership; consumer Dispose cannot poison later callers. Lifecycle regression passes. |
|
||||||
|
| SR-10 | **Completed** | Per-context helpers and guard closures are lazily cached; identity regression passes. |
|
||||||
|
| CM-3 | **Completed** | Raw audit/site-call pulls reject limits outside 1–2000 before reading; both reconciliation option validators enforce the same cap. Boundary regressions pass. |
|
||||||
|
| CM-4 | **Completed** | Bootstrap checks cancellation before resolving the actor system or joining seeds, and propagates canceled probes. Host suite 598 passed. |
|
||||||
|
| CM-5 | **Completed** | Retired site ingest RPCs always return Unimplemented, including empty payloads; dead actor wiring removed. Contract regressions pass. |
|
||||||
|
| CM-6 | **Completed** | Central and site HTTP/2 listeners share finite keepalive settings; metrics listener isolation remains enforced. Build and Host 598 tests pass. |
|
||||||
|
| CM-7 | **Completed** | Channel graveyard retains active calls beyond grace; lifecycle locking closes shutdown/requeue races. Completion and shutdown regressions pass. |
|
||||||
|
| CM-8 | **Completed** | Connected-header timeout is immutable per client, bound through validated CommunicationOptions. Parallel-instance regression passes. |
|
||||||
|
| MG-5 | **Completed** | Total live-command budget includes lookup/write/audit/pull, reserves response grace, and propagates audit cancellation. Management suite 658 passed. |
|
||||||
|
| MG-6 | **Completed** | Clock-independent keyset rescans capture late/backdated completion and descendant rows. Indexed key union bounds payload pages; real SQLite plans cover 1/64 IDs and malformed legacy JSON. Management 660 and AuditLog 407 tests pass. |
|
||||||
|
| MG-7 | **Completed** | Live write results use bounded collector with explicit event/log truncation; Management suite 658 passed. |
|
||||||
|
| MG-8 | **Completed** | Shared-script create/update uses cached compile/trust validation; TemplateEngine suite 483 passed. |
|
||||||
|
| MG-9 | **Completed** | Positive page sizes clamp to 1000; null/nonpositive semantics preserved and boundaries tested. |
|
||||||
|
| MG-10 | **Completed** | Management component catalog now documents all four FlowDebug commands, authority, paging and budget. |
|
||||||
|
| UI-4 | **Completed** | Poll cleanup runs in outer finally and observes rejected renderer work; full UI suite 1416 passed. |
|
||||||
|
| UI-6 | **Completed** | Scoped failover facade requires current Administrator authority before invoking the host backend; positive/negative tests pass. |
|
||||||
|
| UI-7 | **Completed** | Exact route-policy sweep covers UI and external Secrets components; full UI suite 1416 passed. |
|
||||||
|
| UI-8 | **Completed** | Shared parked-call relay resolves site ownership and denies unknown/out-of-scope targets for scoped principals; regressions pass. |
|
||||||
|
| UI-9 | **Completed** | Tree value snapshots skip unchanged forest rebuilds while preserving in-place edits, child metadata and filtered key/inheritance behavior. |
|
||||||
|
| UI-10 | **Completed** | Cookie login explicitly validates antiforgery; missing token rejected and valid real token issues cookie. CLI bearer-token endpoint preserved. |
|
||||||
|
| UI-11 | **Completed** | Lazy site/area queries retain one untracked 50-row instance page per loaded branch; shared async gate serializes branch/refresh/staleness. SQLite paging and controlled interleaving regressions pass. |
|
||||||
|
| UI-12 | **Completed** | Viewer sees no empty Notifications heading and Flow Debugger navigation matches its policy; regression passes. |
|
||||||
|
| EI-6 | **Completed** | Purge actor forwards per-channel 50000-row tick budget; repository caps final partial batch. Purge/options 20 and repository/guard 10 tests pass. |
|
||||||
|
| EI-7 | **Completed** | Root-DI admission registry retains one unfinished collection per registration across actor recreation; actual wait is bounded and task owns scope until completion. Full KPI suite 57 passed. |
|
||||||
|
| EI-8 | **Completed** | Relay strips Accept-Encoding because it forwards raw response bytes without Content-Encoding; regression and full InboundAPI suite 360 pass. |
|
||||||
|
| EI-9 | **Completed** | Outbox contract explicitly documents repeated duplicate batches during dual-active operation and lack of row claims. |
|
||||||
|
| DL-4 | **Completed** | Echo edges replace ordinary duplicate endpoint pairs; unique-edge and echo-semantic regressions pass. |
|
||||||
|
| DL-5 | **Completed** | Refresh bases graph order on authoritative Replay and appends live-only rows; order regression passes. |
|
||||||
|
| DL-7 | **Completed** | Graph build parses each audit payload once and reuses projections; Commons suite 851 passed. |
|
||||||
|
| DL-8 | **Completed** | Existing Medium chain-anchor fix revalidated by timestamp-skewed child/root regression. |
|
||||||
|
| DL-9 | **Completed** | Commons Debugging namespace, per-run bounded collector ownership and redaction documented. |
|
||||||
|
| DL-10 | **Completed** | Enum name/value serialization contracts and display ordering have separate regression coverage. |
|
||||||
|
| DL-11 | **Completed** | Folded steps/lifecycle records retain latest exception detail and clear stale details after success; regressions pass. |
|
||||||
|
| DC-8 | **Completed** | CLI harness covers all 19 groups plus browse/search/verify/certs; secured-write README added; CLI suite 475 passed. |
|
||||||
|
| DC-9 | **Completed** | DCL docs corrected against current unsubscribe, write rejection, atomic disconnect and snapshot behavior. |
|
||||||
|
| DC-10 | **Completed** | OPC shard plans are computed once per resolved chunk and reused for each monitored item; DCL suite 379 passed. |
|
||||||
|
| DC-11 | **Completed** | All notifier base URLs are validated as absolute HTTP(S) before delivery; notifier suite 36 passed. |
|
||||||
|
| XC-7 | **Completed** | Removed both migration-only sections from all eight rig site JSON configurations; parsed and verified each file. |
|
||||||
|
| XC-8 | **Completed** | Source-discovered service maps and external LocalDb/Secrets mappings are compared against generated method descriptors and auth gates, with gate-removal negative control. Host suite 598 passed. |
|
||||||
|
| XC-9 | **Completed** | Central SqlClient 6.1.1 removes per-test overrides; SignalR.Client 10.0.7 aligns with runtime; bUnit 2.7.2 uses stable syntax. Restore, zero-warning build and all solution suites pass. |
|
||||||
|
| XC-10 | **Completed** | Global OPC/Mx options validate required identity strings at startup, preserving optional path defaults; DCL suite 379 passed. |
|
||||||
|
| XC-11 | **Completed** | Umbrella index now includes instance picker/v1.4 logs and live remediation status. Fresh origin/main fetch confirms current main fef3e767 is already pushed (0 ahead/behind); no new push needed for the historical finding. |
|
||||||
|
| XC-12 | **Completed** | Health-check remarks now distinguish seven central singleton proxies, five required and two best-effort exclusions. |
|
||||||
|
|
||||||
|
|
||||||
|
### Final verification — completed 2026-09-09
|
||||||
|
|
||||||
|
- Full solution restore succeeded after centralizing Microsoft.Data.SqlClient 6.1.1,
|
||||||
|
Microsoft.AspNetCore.SignalR.Client 10.0.7, and stable bUnit 2.7.2.
|
||||||
|
- Full solution build: **0 warnings, 0 errors**. Canonical `docker/regen-proto.sh sitestream`
|
||||||
|
preserves the source descriptor path for the additive trace cursor; all 766 Communication
|
||||||
|
tests pass, including the complete source/vendored descriptor comparison.
|
||||||
|
- Initial solution run (excluding deployed Playwright tests): 9474 passed, three failures.
|
||||||
|
One was the generated descriptor path, corrected and verified above. Two login integration
|
||||||
|
fixtures omitted the now-required antiforgery token; they now fetch the real rendered token
|
||||||
|
and cookie, preserve the original login assertions, and add missing-token rejection coverage.
|
||||||
|
Full Integration rerun: **118 passed, zero failed/skipped**. Across the full run with
|
||||||
|
corrected Communication and Integration suite reruns: **9478 passed, zero outstanding failures
|
||||||
|
or skips**. Counts replace earlier suite results rather than adding repeated tests. The deployed
|
||||||
|
Playwright smoke adds **1 passed**: real antiforgery login, initially unloaded topology, bounded
|
||||||
|
branch loading/page controls, and Flow Debugger navigation without browser errors.
|
||||||
|
- `bash docker/deploy.sh` succeeded from the canonical repository. All eight primary rig nodes run
|
||||||
|
image `sha256:cfa919dd343ffa090995336f2854ac4d79d4c2bb919514aa7e72abf579b0b4ed`, with
|
||||||
|
zero restarts, `/health/ready` 200, two members and zero unreachable members per pair, and
|
||||||
|
exactly one `/health/active` 200 per pair (standby 503). Existing bind mounts and volumes
|
||||||
|
were preserved. Env2 containers were not redeployed.
|
||||||
|
- Final completion audit verifies all 46 unique Low IDs have Completed rows, with no remaining
|
||||||
|
Low findings. `git diff --check` passes. The verified remediation is committed directly on `main`.
|
||||||
|
- Validation logs: `/tmp/low-final-build2.log`, `/tmp/low-full-tests.log`,
|
||||||
|
`/tmp/low-comm-final.log`, `/tmp/low-integration-final.log`, `/tmp/low-rig-deploy.log`,
|
||||||
|
`/tmp/low-rig-health.json`, and `/tmp/low-smoke.log`. Focused regression evidence above overlaps
|
||||||
|
the full suite and is not additive.
|
||||||
|
|
||||||
|
### Operational notes
|
||||||
|
|
||||||
|
- MG-6 intentionally rescans matching execution-ID batches on each poll to capture completion
|
||||||
|
rows inserted late with earlier timestamps. Indexed SQLite key queries avoid scanning unrelated
|
||||||
|
retained audit payloads. Discovery memory grows with the traversed tree during the bounded
|
||||||
|
command; returned results have fixed count/byte caps. No central/site clock agreement is assumed.
|
||||||
|
- EI-7 bounds the caller wait and admits at most one unfinished source collection per registration
|
||||||
|
across actor recreation on the same root provider. A source that ignores cancellation retains
|
||||||
|
its task and scope until actual completion; subsequent ticks do not create duplicate work.
|
||||||
|
- EI-9 is resolved through the requested contract clarification: dual-active outbox writers can
|
||||||
|
deliver repeated duplicate batches; database row claims are not implemented by this change.
|
||||||
|
|||||||
@@ -641,7 +641,10 @@ MS SQL for direct-write events). Unredacted secrets never persist.
|
|||||||
silently skipped (the global switch already covers them). The DELETE runs under
|
silently skipped (the global switch already covers them). The DELETE runs under
|
||||||
`scadabridge_audit_purger` (the maintenance role); the append-only writer role
|
`scadabridge_audit_purger` (the maintenance role); the append-only writer role
|
||||||
is unaffected. Batch size is configurable via
|
is unaffected. Batch size is configurable via
|
||||||
`AuditLog:Purge:ChannelPurgeBatchSize` (default 5000). Each channel override
|
`AuditLog:Purge:ChannelPurgeBatchSize` (default 5000). Each channel deletes at
|
||||||
|
most `AuditLog:Purge:MaxPurgedRowsPerChannelPerTick` rows (default 50,000;
|
||||||
|
must be positive), including a smaller final batch when needed. Remaining
|
||||||
|
backlog resumes on the next tick so later channels are not starved. Each channel override
|
||||||
runs in its own try/catch, mirroring the per-boundary error-isolation of the
|
runs in its own try/catch, mirroring the per-boundary error-isolation of the
|
||||||
partition switch-out loop. Values are validated to be in
|
partition switch-out loop. Values are validated to be in
|
||||||
`[30, RetentionDays]`; keys that are not a recognized `AuditChannel` enum name
|
`[30, RetentionDays]`; keys that are not a recognized `AuditChannel` enum name
|
||||||
@@ -772,7 +775,8 @@ size:
|
|||||||
"AuditLog": {
|
"AuditLog": {
|
||||||
"Purge": {
|
"Purge": {
|
||||||
"IntervalHours": 24,
|
"IntervalHours": 24,
|
||||||
"ChannelPurgeBatchSize": 5000
|
"ChannelPurgeBatchSize": 5000,
|
||||||
|
"MaxPurgedRowsPerChannelPerTick": 50000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ Central cluster only. Sites have no user interface.
|
|||||||
- Colour is never the only signal — the button always carries its verb (see the label contract above), so the mapping is an accessibility affordance, not the meaning.
|
- Colour is never the only signal — the button always carries its verb (see the label contract above), so the mapping is an accessibility affordance, not the meaning.
|
||||||
- **`DialogSeverity` is an additive overload**, not a replacement: the boolean form remains the signature for the ~34 existing call sites, which are genuinely two-state. Adding a fifth optional parameter to the boolean form instead would have silently un-matched every existing four-argument NSubstitute stub — the row-28 gotcha, where a stub that stops matching returns a null `Task` and the caller NREs with no compile error. Reach for the overload only when `Caution` is what you mean.
|
- **`DialogSeverity` is an additive overload**, not a replacement: the boolean form remains the signature for the ~34 existing call sites, which are genuinely two-state. Adding a fifth optional parameter to the boolean form instead would have silently un-matched every existing four-argument NSubstitute stub — the row-28 gotcha, where a stub that stops matching returns a null `Task` and the caller NREs with no compile error. Reach for the overload only when `Caution` is what you mean.
|
||||||
|
|
||||||
|
## Review hardening (2026-09-09)
|
||||||
|
|
||||||
|
- Cookie login validates an antiforgery token before reading credentials or calling LDAP; the login form supplies the token. The JSON token endpoint retains its non-cookie client contract.
|
||||||
|
- Manual failover goes through a scoped `AuthorizedManualFailoverService` that refreshes the circuit's Administrator grant before calling the host singleton. The component's visibility policy is an additional presentation guard.
|
||||||
|
- Flow Debugger retry/discard validates the current Deployer grant and resolves site scope inside its service. Scoped callers cannot relay an unknown site. Refresh merges preserve Replay's event ordering and append only rows known exclusively to the live run.
|
||||||
|
- Health and Alarm Summary observe the whole posted refresh task and release their poll gate even when renderer dispatch fails during disposal.
|
||||||
|
- The Topology initially loads permitted sites. Expand a site and select **Load instances** to load that site's area metadata and first root-instance page; each expanded area has its own loader. **Previous** and **Next** replace that branch's 50-instance page. Queries are untracked and omit member collections; templates are not loaded. Live refresh and staleness checks cover loaded pages. Search and counts explicitly describe loaded data.
|
||||||
|
- `TemplateFolderTree` compares immutable value snapshots of folder/template hierarchy and extra children before rebuilding. In-place renames, moves, derived-state changes, and composition edits remain visible; filter and reveal changes are applied independently.
|
||||||
|
- Navigation grants Flow Debugger its own policy and hides Notifications unless the principal holds a role that admits a child. Route-policy coverage freezes every CentralUI route and the external Secrets route.
|
||||||
|
|
||||||
## Failover Behavior
|
## Failover Behavior
|
||||||
|
|
||||||
- A **load balancer** sits in front of the central cluster and routes to the active node.
|
- A **load balancer** sits in front of the central cluster and routes to the active node.
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ ZB.MOM.WW.ScadaBridge.Commons/
|
|||||||
│ │ # AuditQueryParamParsers, ExecutionTreeNode,
|
│ │ # AuditQueryParamParsers, ExecutionTreeNode,
|
||||||
│ │ # SiteCallKpiSnapshot, SiteCallPaging,
|
│ │ # SiteCallKpiSnapshot, SiteCallPaging,
|
||||||
│ │ # SiteCallQueryFilter, SiteCallSiteKpiSnapshot
|
│ │ # SiteCallQueryFilter, SiteCallSiteKpiSnapshot
|
||||||
|
│ ├── Debugging/ # FlowStep/FlowGraph, projector/builder, trace collector, ScriptTraceExtra
|
||||||
│ ├── DataConnections/ # OPC UA endpoint config value objects + enums
|
│ ├── DataConnections/ # OPC UA endpoint config value objects + enums
|
||||||
│ ├── Flattening/ # FlattenedConfiguration, ConfigurationDiff,
|
│ ├── Flattening/ # FlattenedConfiguration, ConfigurationDiff,
|
||||||
│ │ # DeploymentPackage, ValidationResult
|
│ │ # DeploymentPackage, ValidationResult
|
||||||
@@ -326,6 +327,8 @@ It must **not** contain any business logic that orchestrates other components, s
|
|||||||
|
|
||||||
Examples currently in Commons that fall under this carve-out: `Result<T>`, `ScriptParameters` and `ScriptArgs` (script-parameter shaping), `ValueFormatter` (value-to-string formatting), `DynamicJsonElement` (dynamic JSON access), `StaleTagMonitor` (a self-contained heartbeat watchdog), `OpcUaEndpointConfigSerializer` (typed↔legacy JSON conversion of a Commons value object) and `OpcUaEndpointConfigValidator` (rule checks over a Commons value object). These are intentionally placed in Commons so every consuming component shares one implementation rather than duplicating the logic. Anything that would require an I/O dependency, mutable cross-call state, or knowledge of another component's behavior does **not** qualify and must live in the owning component.
|
Examples currently in Commons that fall under this carve-out: `Result<T>`, `ScriptParameters` and `ScriptArgs` (script-parameter shaping), `ValueFormatter` (value-to-string formatting), `DynamicJsonElement` (dynamic JSON access), `StaleTagMonitor` (a self-contained heartbeat watchdog), `OpcUaEndpointConfigSerializer` (typed↔legacy JSON conversion of a Commons value object) and `OpcUaEndpointConfigValidator` (rule checks over a Commons value object). These are intentionally placed in Commons so every consuming component shares one implementation rather than duplicating the logic. Anything that would require an I/O dependency, mutable cross-call state, or knowledge of another component's behavior does **not** qualify and must live in the owning component.
|
||||||
|
|
||||||
|
**Debugging helpers and ownership.** `Types/Debugging` contains `FlowStep`, `FlowGraph`, lane/kind/outcome enums, lifecycle/log projections, `ScriptTraceExtra`, `FlowStepProjector`, `FlowGraphBuilder`, and `FlowTraceCollector`. Projection and graph construction are pure transformations of audit events. The collector is created and owned by one traced run, never shared across runs or registered as a process singleton. It holds only bounded in-memory rows, applies the supplied `IAuditRedactor` abstraction, reserves capacity for step rows ahead of log lines, and freezes collection on `Complete()`. Callers report its event/log truncation flags and own persistence separately. A null redactor is appropriate for rows already redacted by the site. This self-contained per-run state fits REQ-COM-6; it performs no I/O or component orchestration.
|
||||||
|
|
||||||
### REQ-COM-7: Minimal Dependencies
|
### REQ-COM-7: Minimal Dependencies
|
||||||
|
|
||||||
Commons must depend only on core .NET libraries (`System.*`, `Microsoft.Extensions.Primitives` if needed). It must **not** reference:
|
Commons must depend only on core .NET libraries (`System.*`, `Microsoft.Extensions.Primitives` if needed). It must **not** reference:
|
||||||
|
|||||||
@@ -499,3 +499,16 @@ mapped gRPC methods can execute on the control-plane socket; they are rejected o
|
|||||||
sockets. Dedicated metrics sockets expose metrics and health only. Decisions use LocalPort and
|
sockets. Dedicated metrics sockets expose metrics and health only. Decisions use LocalPort and
|
||||||
endpoint metadata, never the caller's Host/authority header. The env2 central/site pairs now enable
|
endpoint metadata, never the caller's Host/authority header. The env2 central/site pairs now enable
|
||||||
the same deterministic bootstrap guard as the main rig for simultaneous container recreation.
|
the same deterministic bootstrap guard as the main rig for simultaneous container recreation.
|
||||||
|
|
||||||
|
|
||||||
|
### Transport hardening (2026-09-09 Low review)
|
||||||
|
|
||||||
|
Central and site Kestrel servers use the same HTTP/2 keepalive settings. Central's optional
|
||||||
|
metrics listener exposes metrics/health only under listener isolation; it does not grant access
|
||||||
|
to the control plane or UI. The gRPC socket likewise rejects ordinary HTTP endpoints.
|
||||||
|
`ScadaBridge:Communication:ConnectedHeaderTimeout` (default 10 seconds) belongs to each client;
|
||||||
|
when headers do not arrive, only an actual first event signals a connected stream.
|
||||||
|
Reconciliation pulls accept 1–2000 rows and also obey the serialized wire-byte budget.
|
||||||
|
SiteStreamService's legacy ingest RPCs return `Unimplemented`; central ingestion uses
|
||||||
|
CentralControlService. Replaced channel pairs remain parked until both the idle grace and
|
||||||
|
all current calls have finished, including long attribute waits.
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ The registered `SourceReference` is a **NodeId** for OPC UA (the picker, CSV and
|
|||||||
- **Reconnect handling**: on entering **Reconnecting**, the actor pushes a `NativeAlarmSourceUnavailable` to every alarm subscriber (consumers mark mirrored alarms uncertain rather than clearing them). On successful reconnection it re-subscribes the feed; the adapter re-emits a snapshot, reconciling state.
|
- **Reconnect handling**: on entering **Reconnecting**, the actor pushes a `NativeAlarmSourceUnavailable` to every alarm subscriber (consumers mark mirrored alarms uncertain rather than clearing them). On successful reconnection it re-subscribes the feed; the adapter re-emits a snapshot, reconciling state.
|
||||||
- **Shared condition filter (last-subscriber-wins)**: because the feed is opened once per source, it carries a single condition filter. A second subscriber that registers a *different* filter for the same source overwrites it (last writer wins) and the actor logs a warning — co-subscribers to one source are expected to agree on the filter.
|
- **Shared condition filter (last-subscriber-wins)**: because the feed is opened once per source, it carries a single condition filter. A second subscriber that registers a *different* filter for the same source overwrites it (last writer wins) and the actor logs a warning — co-subscribers to one source are expected to agree on the filter.
|
||||||
- **Routing index**: subscribed sources are bucketed by the **first path segment** of their reference (everything before the first `.`), and a transition is matched only against the bucket for its own first segment, plus a residue list of sources that carry no separator at all (a prefix shorter than one segment, which can match other buckets). This is sound because a source reference containing a separator can only prefix a reference sharing its entire first segment. The per-source `StartsWith` test and the condition-type gate inside the bucket are unchanged, so routing decisions are identical to the previous linear scan over every subscribed source; snapshot boundaries bypass the condition filter, but OPC UA boundaries are sent only to the exact source binding. Connection-wide Mx boundaries broadcast to all subscribers while preserving their refresh epoch.
|
- **Routing index**: subscribed sources are bucketed by the **first path segment** of their reference (everything before the first `.`), and a transition is matched only against the bucket for its own first segment, plus a residue list of sources that carry no separator at all (a prefix shorter than one segment, which can match other buckets). This is sound because a source reference containing a separator can only prefix a reference sharing its entire first segment. The per-source `StartsWith` test and the condition-type gate inside the bucket are unchanged, so routing decisions are identical to the previous linear scan over every subscribed source; snapshot boundaries bypass the condition filter, but OPC UA boundaries are sent only to the exact source binding. Connection-wide Mx boundaries broadcast to all subscribers while preserving their refresh epoch.
|
||||||
- **Gateway union filter (MxGateway)**: `StreamAlarms` carries a single `alarm_filter_prefix`, so the adapter opens the stream on the **longest common prefix** of the currently subscribed source references and restarts it for each new source, including one covered by the existing prefix, so every newly bound source receives standing state. Each transport refresh has connection-wide start/end markers and a distinct epoch. An unsubscribe never restarts the stream — the prefix it leaves behind is at worst too broad, which costs bandwidth, not correctness. The prefix is a bandwidth optimisation only; the actor's per-source + condition-type gate remains authoritative, mirroring the OPC UA server-side WhereClause stance.
|
- **Gateway union filter (MxGateway)**: `StreamAlarms` carries a single `alarm_filter_prefix`, so the adapter opens the stream on the **longest common prefix** of the currently subscribed source references and restarts it for each new source, including one covered by the existing prefix, so every newly bound source receives standing state. Each transport refresh has connection-wide start/end markers and a distinct epoch. An unsubscribe never restarts the stream — the prefix it leaves behind is at worst too broad, which may increase bandwidth. Correctness also depends on the epoch-scoped snapshot cache merging concurrent live updates and rejecting stale snapshot ends (DC-5); a broad filter alone provides no such guarantee. The prefix is a bandwidth optimisation only; the actor's per-source + condition-type gate remains authoritative, mirroring the OPC UA server-side WhereClause stance.
|
||||||
|
|
||||||
### Protocol-Neutral Types & Messages
|
### Protocol-Neutral Types & Messages
|
||||||
|
|
||||||
@@ -387,7 +387,7 @@ Instance Actors and NativeAlarmActors send explicit unsubscribe requests from `P
|
|||||||
|
|
||||||
OPC UA refreshes carry an exact `SnapshotSourceReference` (including the empty Server binding) and a `SnapshotId` shared by `SnapshotStart`, entries and `SnapshotComplete`. Each NativeAlarmActor replaces its buffer on a new start and commits only a matching end; duplicate or superseded ends cannot clear a committed snapshot. The adapter tags each monitored item's refresh separately even though ConditionRefresh targets the shared event subscription. Mx snapshots retain their connection-wide framing. An interrupted refresh buffer is discarded on disconnect.
|
OPC UA refreshes carry an exact `SnapshotSourceReference` (including the empty Server binding) and a `SnapshotId` shared by `SnapshotStart`, entries and `SnapshotComplete`. Each NativeAlarmActor replaces its buffer on a new start and commits only a matching end; duplicate or superseded ends cannot clear a committed snapshot. The adapter tags each monitored item's refresh separately even though ConditionRefresh targets the shared event subscription. Mx snapshots retain their connection-wide framing. An interrupted refresh buffer is discarded on disconnect.
|
||||||
|
|
||||||
This pattern ensures no messages are lost during connection transitions and is the standard Akka.NET approach for actors with I/O lifecycle dependencies.
|
Subscription demand survives connection transitions through coalescing and owner tracking. Device writes during those transitions fail immediately so stale setpoints cannot be replayed.
|
||||||
|
|
||||||
**OPC UA-specific notes**: The `RealOpcUaClient` uses the OPC Foundation SDK's `Session.KeepAlive` event for proactive disconnect detection. The SDK sends keep-alive requests at the subscription's `KeepAliveCount × PublishingInterval` (default: 10s). When keep-alive fails, the `ConnectionLost` event fires, triggering the same reconnection flow. On reconnection, the DCL re-creates the OPC UA session and subscription, then re-adds all monitored items.
|
**OPC UA-specific notes**: The `RealOpcUaClient` uses the OPC Foundation SDK's `Session.KeepAlive` event for proactive disconnect detection. The SDK sends keep-alive requests at the subscription's `KeepAliveCount × PublishingInterval` (default: 10s). When keep-alive fails, the `ConnectionLost` event fires, triggering the same reconnection flow. On reconnection, the DCL re-creates the OPC UA session and subscription, then re-adds all monitored items.
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ Each adapter implements the `IDataConnection.Disconnected` event to proactively
|
|||||||
|
|
||||||
**Event marshalling**: The `DataConnectionActor` subscribes to `_adapter.Disconnected` in `PreStart()`. Since `Disconnected` may fire from a background thread (gRPC stream task, OPC UA keep-alive timer), the handler sends an `AdapterDisconnected` message to `Self`, marshalling the notification onto the actor's message loop. This triggers `BecomeReconnecting()` → bad quality push → retry timer.
|
**Event marshalling**: The `DataConnectionActor` subscribes to `_adapter.Disconnected` in `PreStart()`. Since `Disconnected` may fire from a background thread (gRPC stream task, OPC UA keep-alive timer), the handler sends an `AdapterDisconnected` message to `Self`, marshalling the notification onto the actor's message loop. This triggers `BecomeReconnecting()` → bad quality push → retry timer.
|
||||||
|
|
||||||
**Once-only guard**: `OpcUaDataConnection` uses a `volatile bool _disconnectFired` flag to ensure `RaiseDisconnected()` fires exactly once per connection session. The flag resets on successful reconnection (`ConnectAsync`).
|
**Once-only guard**: `OpcUaDataConnection` uses an `int _disconnectFired` guard with `Interlocked.Exchange(ref _disconnectFired, 1)` to ensure `RaiseDisconnected()` fires exactly once per connection session. The guard resets atomically with `Interlocked.Exchange(ref _disconnectFired, 0)` on successful reconnection (`ConnectAsync`).
|
||||||
|
|
||||||
## Write Failure Handling
|
## Write Failure Handling
|
||||||
|
|
||||||
|
|||||||
@@ -80,14 +80,16 @@ The table is non-partitioned, on the standard `[PRIMARY]` filegroup, with no DB-
|
|||||||
|
|
||||||
The recorder is the Akka.NET cluster singleton **`kpi-history-recorder`** (singleton-manager actor `kpi-history-recorder-singleton`), running on the active central node. It is **not readiness-gated** — the recorder is pure observability and must never gate `/health/ready`, so it is started outside the readiness barrier (unlike the operational singletons). On graceful shutdown it drains via a `CoordinatedShutdown` task for clean singleton handover.
|
The recorder is the Akka.NET cluster singleton **`kpi-history-recorder`** (singleton-manager actor `kpi-history-recorder-singleton`), running on the active central node. It is **not readiness-gated** — the recorder is pure observability and must never gate `/health/ready`, so it is started outside the readiness barrier (unlike the operational singletons). On graceful shutdown it drains via a `CoordinatedShutdown` task for clean singleton handover.
|
||||||
|
|
||||||
A timer fires every `SampleInterval` (default 60s; an immediate first tick primes the series, then it settles into the periodic cadence). On each tick the recorder:
|
A timer fires every `SampleInterval` (default 60s; a first tick after five seconds primes the series, then it settles into the periodic cadence). On each tick the recorder:
|
||||||
|
|
||||||
1. Opens a **per-tick DI scope** (scoped `DbContext`/repository — the same scope-per-sweep pattern as the `NotificationOutboxActor`).
|
1. Opens a **per-tick DI scope** (scoped `DbContext`/repository — the same scope-per-sweep pattern as the `NotificationOutboxActor`).
|
||||||
2. Enumerates the registered `IEnumerable<IKpiSampleSource>`. Each source returns an `IReadOnlyList<KpiSample>` stamped with the tick's single `CapturedAtUtc`.
|
2. Enumerates the registered `IEnumerable<IKpiSampleSource>`. Each collection runs with its own DI scope and returns an `IReadOnlyList<KpiSample>` stamped with the tick's single `CapturedAtUtc`.
|
||||||
3. Writes all collected samples via `IKpiHistoryRepository.RecordSamplesAsync`.
|
3. Writes all collected samples via `IKpiHistoryRepository.RecordSamplesAsync`.
|
||||||
|
|
||||||
**Best-effort, per-source isolation.** Each source call and the write are individually guarded. A throwing source is logged and its samples skipped for that tick; it never aborts the tick, the other sources, or the source component itself. This is the same `IEnumerable<>`-of-adapters decoupling pattern used by `INotificationDeliveryAdapter`.
|
**Best-effort, per-source isolation.** Each source call and the write are individually guarded. A throwing source is logged and its samples skipped for that tick; it never aborts the tick, the other sources, or the source component itself. This is the same `IEnumerable<>`-of-adapters decoupling pattern used by `INotificationDeliveryAdapter`.
|
||||||
|
|
||||||
|
Each source has a `SourceTimeout` (default 10 seconds) bounding the actual wait as well as requesting cancellation. A source that ignores cancellation keeps its task, cancellation source, and DI scope until it actually finishes; later ticks skip that registration instead of starting replacements. Healthy sources continue with separate scopes, so they do not share a busy `DbContext` with unfinished work. A root-DI singleton admits at most one unfinished collection per registration across recorder instances, including actor stop/recreation on the same provider. Admission is atomic; replacing an actor does not start another call against a still-running source. Different root providers have independent admission state.
|
||||||
|
|
||||||
**Hourly rollup tick.** A third timer (`kpi-rollup`) fires every `RollupInterval` (default 1h). On each tick the recorder opens a per-tick DI scope and calls `IKpiHistoryRepository.FoldHourlyRollupsAsync` over the window `[TruncateToHour(now) − RollupLookbackHours, TruncateToHour(now))` — an **exclusive** upper bound at the current hour start, so the in-progress hour is never folded. The fold groups raw `KpiSample` rows by `(series, hour)`, applies the per-metric aggregation intent (sum for rate metrics, last-value for gauges — see Query), and **idempotently upserts** each `(Source, Metric, Scope, ScopeKey, HourStartUtc)` row. Re-folding the trailing lookback window (default 3h) rather than only the last hour is the self-heal for a **singleton-failover-missed tick**: because the upsert overwrites the aggregate in place, re-running the same window produces identical values (no double-count), so any complete hour a missed tick skipped is refilled on the next fold. A `_rollupInFlight` guard (mirroring `_sampleInFlight`) skips a tick while a prior fold is still running so two idempotent upserts never race on overlapping rows. During a **failover overlap** — the old singleton incarnation's in-flight fold and the new node's first fold both `Add`-ing the same `(series, hour)` row — the loser of the unique `IX_KpiRollupHourly_Series` upsert race discards its **whole fold pass** (a single `SaveChanges`), logged at Information and repaired by the next idempotent fold; when reading fold logs after a failover, the failure grain is the **pass, not the row** (arch-review 04 round 2, R5).
|
**Hourly rollup tick.** A third timer (`kpi-rollup`) fires every `RollupInterval` (default 1h). On each tick the recorder opens a per-tick DI scope and calls `IKpiHistoryRepository.FoldHourlyRollupsAsync` over the window `[TruncateToHour(now) − RollupLookbackHours, TruncateToHour(now))` — an **exclusive** upper bound at the current hour start, so the in-progress hour is never folded. The fold groups raw `KpiSample` rows by `(series, hour)`, applies the per-metric aggregation intent (sum for rate metrics, last-value for gauges — see Query), and **idempotently upserts** each `(Source, Metric, Scope, ScopeKey, HourStartUtc)` row. Re-folding the trailing lookback window (default 3h) rather than only the last hour is the self-heal for a **singleton-failover-missed tick**: because the upsert overwrites the aggregate in place, re-running the same window produces identical values (no double-count), so any complete hour a missed tick skipped is refilled on the next fold. A `_rollupInFlight` guard (mirroring `_sampleInFlight`) skips a tick while a prior fold is still running so two idempotent upserts never race on overlapping rows. During a **failover overlap** — the old singleton incarnation's in-flight fold and the new node's first fold both `Add`-ing the same `(series, hour)` row — the loser of the unique `IX_KpiRollupHourly_Series` upsert race discards its **whole fold pass** (a single `SaveChanges`), logged at Information and repaired by the next idempotent fold; when reading fold logs after a failover, the failure grain is the **pass, not the row** (arch-review 04 round 2, R5).
|
||||||
|
|
||||||
**One-shot backfill.** So the 30 d / 90 d charts are not blank until enough wall-clock passes, the recorder runs a backfill on start (`kpi-rollup-backfill` timer) that folds the retention window, reusing the same `FoldHourlyRollupsAsync` path. The `FoldHourlyRollupsAsync` repository call materializes its whole window in memory, so the backfill folds the window in **≤24 h slices, oldest-first** — one bounded slice per fold — rather than handing the fold the full 90-day window at once (which at fleet volume is tens of millions of rows in one tracked pass). Between slices it lowers its in-flight guard so periodic folds **interleave** instead of stalling behind the historical pass; a periodic fold and a backfill slice strictly alternate through the single `_rollupInFlight`/`_backfillInFlight` gate pair, so the two idempotent upserts never race on overlapping rows. On a **failover restart** the plan first consults the `IKpiHistoryRepository.GetLatestRollupHourAsync` watermark: if the newest folded hour is already within `RollupLookbackHours` of now (the common case) the whole historical pass is **skipped** — the periodic fold's lookback covers the tail — otherwise the window is **shrunk to the un-rolled tail** `[watermark, TruncateToHour(now))` (re-folding the watermark hour itself as an idempotent safety margin) instead of the full `RetentionDays` floor. A `_backfillDone` latch makes the whole pass at-most-once per actor lifetime, and it defers (re-arms) while a periodic fold is in flight. Being an idempotent upsert, it is safe (idempotent) to re-run on a later failover restart; the watermark fast-path makes the failover re-run cheap as well.
|
**One-shot backfill.** So the 30 d / 90 d charts are not blank until enough wall-clock passes, the recorder runs a backfill on start (`kpi-rollup-backfill` timer) that folds the retention window, reusing the same `FoldHourlyRollupsAsync` path. The `FoldHourlyRollupsAsync` repository call materializes its whole window in memory, so the backfill folds the window in **≤24 h slices, oldest-first** — one bounded slice per fold — rather than handing the fold the full 90-day window at once (which at fleet volume is tens of millions of rows in one tracked pass). Between slices it lowers its in-flight guard so periodic folds **interleave** instead of stalling behind the historical pass; a periodic fold and a backfill slice strictly alternate through the single `_rollupInFlight`/`_backfillInFlight` gate pair, so the two idempotent upserts never race on overlapping rows. On a **failover restart** the plan first consults the `IKpiHistoryRepository.GetLatestRollupHourAsync` watermark: if the newest folded hour is already within `RollupLookbackHours` of now (the common case) the whole historical pass is **skipped** — the periodic fold's lookback covers the tail — otherwise the window is **shrunk to the un-rolled tail** `[watermark, TruncateToHour(now))` (re-folding the watermark hour itself as an idempotent safety margin) instead of the full `RetentionDays` floor. A `_backfillDone` latch makes the whole pass at-most-once per actor lifetime, and it defers (re-arms) while a periodic fold is in flight. Being an idempotent upsert, it is safe (idempotent) to re-run on a later failover restart; the watermark fast-path makes the failover re-run cheap as well.
|
||||||
@@ -201,6 +203,7 @@ Bound from the `ScadaBridge:KpiHistory` section on the central host (Options pat
|
|||||||
| Option | Default | Notes |
|
| Option | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SampleInterval` | `60s` | Recorder tick cadence. Must be `> 0`. |
|
| `SampleInterval` | `60s` | Recorder tick cadence. Must be `> 0`. |
|
||||||
|
| `SourceTimeout` | `10s` | Actual maximum wait per source; must be `> 0` and `<= 1 day`. Unfinished work remains owned and is skipped on subsequent ticks. |
|
||||||
| `RetentionDays` | `90` | Raw `KpiSample` rows older than this are purged. Bounded to `[1, 3650]` days. |
|
| `RetentionDays` | `90` | Raw `KpiSample` rows older than this are purged. Bounded to `[1, 3650]` days. |
|
||||||
| `PurgeInterval` | `1d` | Daily purge cadence (drives **both** raw and rollup purges). Must be `> 0`. |
|
| `PurgeInterval` | `1d` | Daily purge cadence (drives **both** raw and rollup purges). Must be `> 0`. |
|
||||||
| `DefaultMaxSeriesPoints` | `200` | Default bucket cap for a series query when the caller does not override it. Bounded to `[2, 5000]`. |
|
| `DefaultMaxSeriesPoints` | `200` | Default bucket cap for a series query when the caller does not override it. Bounded to `[2, 5000]`. |
|
||||||
|
|||||||
@@ -230,6 +230,19 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
|
|||||||
- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. Actor-dispatched, `SiteIdentifier`-required + site-scope, relayed via `CommunicationService.VerifyEndpointAsync` (arch-review C4). (`Design` role — runs inside the Admin-gated connection editor.)
|
- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. Actor-dispatched, `SiteIdentifier`-required + site-scope, relayed via `CommunicationService.VerifyEndpointAsync` (arch-review C4). (`Design` role — runs inside the Admin-gated connection editor.)
|
||||||
- **TrustServerCertCommand** / **RemoveServerCertCommand** / **ListServerCertsCommand**: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to **both** site nodes (see Component-SiteRuntime.md). Actor-dispatched and `Admin`-gated at the actor (arch-review C4) — each handler requires the command's `SiteIdentifier`, enforces site-scope, then relays via `CommunicationService.TrustServerCertAsync` / `RemoveServerCertAsync` / `ListServerCertsAsync`; the site-side reconcile across both nodes is owned by the site `CertStoreActor`. (`Admin` role.)
|
- **TrustServerCertCommand** / **RemoveServerCertCommand** / **ListServerCertsCommand**: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to **both** site nodes (see Component-SiteRuntime.md). Actor-dispatched and `Admin`-gated at the actor (arch-review C4) — each handler requires the command's `SiteIdentifier`, enforces site-scope, then relays via `CommunicationService.TrustServerCertAsync` / `RemoveServerCertAsync` / `ListServerCertsAsync`; the site-side reconcile across both nodes is owned by the site `CertStoreActor`. (`Admin` role.)
|
||||||
|
|
||||||
|
### Flow Debugger
|
||||||
|
|
||||||
|
All four commands return `FlowDebugRunResult`: the chain root execution ID, completion/error metadata, audit events, timestamps, and independent event/log truncation flags.
|
||||||
|
|
||||||
|
| Command | Behaviour | Required role |
|
||||||
|
|---|---|---|
|
||||||
|
| `FlowDebugRunMethodCommand` | Validates an inbound API method's parameters and runs it with tracing; routed calls enforce the caller's site scope. | Deployer |
|
||||||
|
| `FlowDebugRunValueChangeCommand` | Writes an instance attribute through the real site path and polls for the triggered execution tree. | Deployer |
|
||||||
|
| `FlowDebugRunScriptCommand` | Resolves the effective script; writes its trigger attribute for write-trigger kinds, otherwise invokes it directly with validated parameters. | Deployer |
|
||||||
|
| `FlowDebugReplayCommand` | Reads the stored execution tree for any execution ID, enforcing caller scope and retaining the repository's chain anchor. | Any of Deployer, Administrator, Viewer |
|
||||||
|
|
||||||
|
Live commands require the active central node and return `STANDBY_NODE` elsewhere. They perform real script/device operations and write operator-attributed audit roots. One cancellation budget starts at runner entry, covers lookups, execution/write, audit and trace pulls, and reserves five seconds below `LongRunningCommandTimeout` for the reply. Configuration must exceed that grace. Write-trigger waits are clamped to 1–295 seconds and the remaining total budget. Polling drains all `(OccurredAtUtc, EventId)` keyset pages in execution-ID batches of at most 64. Each subsequent poll intentionally rescans those batches: closing rows are inserted at completion but stamped with their start time, so a persistent timestamp cursor would miss them. The root is a fresh execution GUID; scans use no timestamp lower bound and do not require central/site clock agreement. This removes fixed-page starvation but deliberately retransfers known rows. Site reads use partial valid-JSON execution/parent-expression indexes; an indexed union of keys deduplicates both matches and limits the page before loading payloads. Malformed legacy details are excluded from trace matching without preventing index initialization. Discovery retains deduplicated rows for the bounded command duration, so working memory grows with the traversed tree; only the returned reply has a fixed collector cap. Inline results use `FlowTraceCollector` count/byte caps and expose truncation. Replay also has repository count/UTF-8 bounds; it does not execute scripts or write to devices.
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
Every incoming message carries the authenticated user's identity and roles. The ManagementActor enforces the same role-based authorization rules as the Central UI:
|
Every incoming message carries the authenticated user's identity and roles. The ManagementActor enforces the same role-based authorization rules as the Central UI:
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ Terminal rows (`Delivered`, `Parked`, `Discarded`) are removed by a **daily purg
|
|||||||
|
|
||||||
The site→central handoff is **at-least-once**. Central ingests an inbound notification submission with an insert-if-not-exists on `NotificationId`, then acks the site; the site S&F engine clears the message only on that ack. Because central acks only after the row is persisted (ack-after-persist), a lost ack causes the site to resend, and the GUID `NotificationId` idempotency key makes the resend harmless — the duplicate insert is a no-op.
|
The site→central handoff is **at-least-once**. Central ingests an inbound notification submission with an insert-if-not-exists on `NotificationId`, then acks the site; the site S&F engine clears the message only on that ack. Because central acks only after the row is persisted (ack-after-persist), a lost ack causes the site to resend, and the GUID `NotificationId` idempotency key makes the resend harmless — the duplicate insert is a no-op.
|
||||||
|
|
||||||
A rare central failover mid-delivery could re-send one already-`Delivered` notification. This is an accepted trade-off, consistent with the duplicate-delivery trade-off the Store-and-Forward Engine already accepts.
|
Delivery has no row-level claim or lease: dispatchers read due rows and persist delivery status after sending. An interrupted delivery can therefore be sent again after failover. During a dual-active partition permitted by the auto-down policy, both central singletons can repeatedly select overlapping batches and deliver duplicate notifications across multiple sweeps; exposure is not limited to one notification or one handover. The `NotificationId` deduplication above protects ingestion, not external Email/SMS delivery. This remains an accepted at-least-once delivery limitation; there is no dispatcher fencing or exactly-once guarantee.
|
||||||
|
|
||||||
## Dispatcher
|
## Dispatcher
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,8 @@ public class AuditLogPurgeActor : ReceiveActor
|
|||||||
channel,
|
channel,
|
||||||
channelThreshold,
|
channelThreshold,
|
||||||
_purgeOptions.ChannelPurgeBatchSize,
|
_purgeOptions.ChannelPurgeBatchSize,
|
||||||
_purgeOptions.ResolvedMaintenanceCommandTimeout)
|
_purgeOptions.ResolvedMaintenanceCommandTimeout,
|
||||||
|
maxRows: _purgeOptions.MaxPurgedRowsPerChannelPerTick)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ public sealed class AuditLogPurgeOptions
|
|||||||
/// <summary>Period of the purge tick in hours (default 24).</summary>
|
/// <summary>Period of the purge tick in hours (default 24).</summary>
|
||||||
public int IntervalHours { get; set; } = 24;
|
public int IntervalHours { get; set; } = 24;
|
||||||
|
|
||||||
|
/// <summary>Maximum rows deleted for each channel in one purge tick (default 50,000); remaining backlog resumes on later ticks.</summary>
|
||||||
|
public int MaxPurgedRowsPerChannelPerTick { get; set; } = 50_000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Batch size for the per-channel retention-override row DELETE
|
/// Batch size for the per-channel retention-override row DELETE
|
||||||
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.PurgeChannelOlderThanAsync"/>).
|
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IAuditLogRepository.PurgeChannelOlderThanAsync"/>).
|
||||||
/// Each <c>DELETE TOP (@batch)</c> caps the transaction-log and lock footprint
|
/// Each <c>DELETE TOP (@batch)</c> caps the transaction-log and lock footprint
|
||||||
/// per statement; the repository loops batches until no rows remain. Default
|
/// per statement; the repository stops at <see cref="MaxPurgedRowsPerChannelPerTick"/>
|
||||||
/// 5000 keeps individual deletes short on a busy central DB while still draining
|
/// so a backlogged channel yields to later channels. Default 5000 keeps individual
|
||||||
/// a large backlog within a tick. Clamped to a sane minimum in
|
/// deletes short on a busy central DB. Clamped to a sane minimum in
|
||||||
/// <see cref="ChannelPurgeBatchSize"/>.
|
/// <see cref="ChannelPurgeBatchSize"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ public sealed class AuditLogPurgeOptionsValidator : OptionsValidatorBase<AuditLo
|
|||||||
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.IntervalHours)} " +
|
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.IntervalHours)} " +
|
||||||
$"({options.IntervalHours}) must be > 0.");
|
$"({options.IntervalHours}) must be > 0.");
|
||||||
|
|
||||||
|
builder.RequireThat(options.MaxPurgedRowsPerChannelPerTick > 0,
|
||||||
|
"AuditLog:Purge:MaxPurgedRowsPerChannelPerTick must be > 0.");
|
||||||
|
|
||||||
// Backing field for the documented "ChannelPurgeBatchSize" config key.
|
// Backing field for the documented "ChannelPurgeBatchSize" config key.
|
||||||
builder.RequireThat(options.ChannelPurgeBatchSizeConfigured > 0,
|
builder.RequireThat(options.ChannelPurgeBatchSizeConfigured > 0,
|
||||||
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured)} " +
|
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured)} " +
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
|
|||||||
IReadOnlyCollection<Guid> executionIds,
|
IReadOnlyCollection<Guid> executionIds,
|
||||||
DateTime sinceUtc,
|
DateTime sinceUtc,
|
||||||
int maxEvents,
|
int maxEvents,
|
||||||
CancellationToken ct)
|
CancellationToken ct, string? afterEventId = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(executionIds);
|
ArgumentNullException.ThrowIfNull(executionIds);
|
||||||
if (executionIds.Count == 0 || maxEvents <= 0)
|
if (executionIds.Count == 0 || maxEvents <= 0)
|
||||||
@@ -67,6 +67,7 @@ public sealed class GrpcPullTraceEventsClient : IPullTraceEventsClient
|
|||||||
{
|
{
|
||||||
SinceUtc = Timestamp.FromDateTime(EnsureUtc(sinceUtc)),
|
SinceUtc = Timestamp.FromDateTime(EnsureUtc(sinceUtc)),
|
||||||
MaxEvents = maxEvents,
|
MaxEvents = maxEvents,
|
||||||
|
AfterId = afterEventId ?? string.Empty,
|
||||||
};
|
};
|
||||||
foreach (var id in executionIds)
|
foreach (var id in executionIds)
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -20,9 +20,9 @@ public sealed class SiteAuditReconciliationOptionsValidator
|
|||||||
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds)} " +
|
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds)} " +
|
||||||
$"({options.ReconciliationIntervalSeconds}) must be > 0.");
|
$"({options.ReconciliationIntervalSeconds}) must be > 0.");
|
||||||
|
|
||||||
builder.RequireThat(options.BatchSize > 0,
|
builder.RequireThat(options.BatchSize is > 0 and <= Communication.Grpc.SiteStreamGrpcServer.MaxReconciliationRowsPerPull,
|
||||||
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.BatchSize)} " +
|
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.BatchSize)} " +
|
||||||
$"({options.BatchSize}) must be > 0.");
|
$"({options.BatchSize}) must be 1..2000.");
|
||||||
|
|
||||||
builder.RequireThat(options.StalledAfterNonDrainingCycles > 0,
|
builder.RequireThat(options.StalledAfterNonDrainingCycles > 0,
|
||||||
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.StalledAfterNonDrainingCycles)} " +
|
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.StalledAfterNonDrainingCycles)} " +
|
||||||
|
|||||||
@@ -271,6 +271,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
-- to the window. Additive and idempotent.
|
-- to the window. Additive and idempotent.
|
||||||
CREATE INDEX IF NOT EXISTS IX_audit_event_occurred
|
CREATE INDEX IF NOT EXISTS IX_audit_event_occurred
|
||||||
ON audit_event (OccurredAtUtc);
|
ON audit_event (OccurredAtUtc);
|
||||||
|
|
||||||
|
-- Fresh execution IDs permit clock-independent trace reads. Partial indexes
|
||||||
|
-- exclude malformed legacy details before evaluating either JSON expression.
|
||||||
|
CREATE INDEX IF NOT EXISTS IX_audit_event_execution
|
||||||
|
ON audit_event (json_extract(DetailsJson, '$.executionId'), OccurredAtUtc, EventId)
|
||||||
|
WHERE json_valid(DetailsJson);
|
||||||
|
CREATE INDEX IF NOT EXISTS IX_audit_event_parent_execution
|
||||||
|
ON audit_event (json_extract(DetailsJson, '$.parentExecutionId'), OccurredAtUtc, EventId)
|
||||||
|
WHERE json_valid(DetailsJson);
|
||||||
""";
|
""";
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
@@ -859,7 +868,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default)
|
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(executionIds);
|
ArgumentNullException.ThrowIfNull(executionIds);
|
||||||
if (limit <= 0)
|
if (limit <= 0)
|
||||||
@@ -895,18 +904,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
var inList = string.Join(", ", names);
|
var inList = string.Join(", ", names);
|
||||||
|
|
||||||
cmd.CommandText = $"""
|
cmd.CommandText = BuildTraceReadSql(inList);
|
||||||
SELECT EventId, OccurredAtUtc, Actor, Action, Outcome,
|
|
||||||
Category, Target, SourceNode, CorrelationId, DetailsJson
|
|
||||||
FROM audit_event
|
|
||||||
WHERE OccurredAtUtc >= $since
|
|
||||||
AND (json_extract(DetailsJson, '$.executionId') IN ({inList})
|
|
||||||
OR json_extract(DetailsJson, '$.parentExecutionId') IN ({inList}))
|
|
||||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
|
||||||
LIMIT $limit;
|
|
||||||
""";
|
|
||||||
cmd.Parameters.AddWithValue("$since", EnsureUtc(sinceUtc).ToString(
|
cmd.Parameters.AddWithValue("$since", EnsureUtc(sinceUtc).ToString(
|
||||||
"o", System.Globalization.CultureInfo.InvariantCulture));
|
"o", System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
cmd.Parameters.AddWithValue("$after", (object?)afterEventId ?? DBNull.Value);
|
||||||
cmd.Parameters.AddWithValue("$limit", limit);
|
cmd.Parameters.AddWithValue("$limit", limit);
|
||||||
|
|
||||||
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
var rows = new List<AuditEvent>(Math.Min(limit, 256));
|
||||||
@@ -919,6 +920,33 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads matching keys through both expression indexes before loading the bounded payload page.
|
||||||
|
/// UNION deduplicates rows matching both IDs. Keeping payloads outside the union bounds sorting
|
||||||
|
/// to IDs/timestamps and avoids the optimizer preferring a whole-store timestamp scan.
|
||||||
|
/// </summary>
|
||||||
|
internal static string BuildTraceReadSql(string inList) => $"""
|
||||||
|
SELECT e.EventId, e.OccurredAtUtc, e.Actor, e.Action, e.Outcome,
|
||||||
|
e.Category, e.Target, e.SourceNode, e.CorrelationId, e.DetailsJson
|
||||||
|
FROM audit_event AS e
|
||||||
|
JOIN (
|
||||||
|
SELECT EventId, OccurredAtUtc
|
||||||
|
FROM audit_event INDEXED BY IX_audit_event_execution
|
||||||
|
WHERE json_valid(DetailsJson)
|
||||||
|
AND json_extract(DetailsJson, '$.executionId') IN ({inList})
|
||||||
|
AND (OccurredAtUtc > $since OR (OccurredAtUtc = $since AND ($after IS NULL OR EventId > $after)))
|
||||||
|
UNION
|
||||||
|
SELECT EventId, OccurredAtUtc
|
||||||
|
FROM audit_event INDEXED BY IX_audit_event_parent_execution
|
||||||
|
WHERE json_valid(DetailsJson)
|
||||||
|
AND json_extract(DetailsJson, '$.parentExecutionId') IN ({inList})
|
||||||
|
AND (OccurredAtUtc > $since OR (OccurredAtUtc = $since AND ($after IS NULL OR EventId > $after)))
|
||||||
|
ORDER BY OccurredAtUtc, EventId
|
||||||
|
LIMIT $limit
|
||||||
|
) AS matched ON matched.EventId = e.EventId
|
||||||
|
ORDER BY matched.OccurredAtUtc, matched.EventId;
|
||||||
|
""";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Highest <c>audit_forward_state.rowid</c> this instance has ever SERVED from
|
/// Highest <c>audit_forward_state.rowid</c> this instance has ever SERVED from
|
||||||
/// <see cref="ReadPendingSinceAsync"/> — i.e. the insertion-order high-water mark of
|
/// <see cref="ReadPendingSinceAsync"/> — i.e. the insertion-order high-water mark of
|
||||||
|
|||||||
@@ -2270,6 +2270,16 @@ scadabridge --url <url> cached-call discard --site-id <string> --tracked-operati
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `secured-write` — Query two-person write requests
|
||||||
|
|
||||||
|
`secured-write list` reads the MxGateway secured-write queue and terminal history. It requires any of Operator, Verifier or Administrator and applies the caller's site scope. This CLI group exposes listing only; submitting, approving and rejecting are management/UI operations.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge secured-write list --status Pending --site site-a --skip 0 --take 50
|
||||||
|
```
|
||||||
|
|
||||||
|
`--status` and `--site` are optional filters. `--skip` defaults to 0; `--take` defaults to 200 and is clamped server-side to 1–500. Global connection, authentication and output-format options apply.
|
||||||
|
|
||||||
### `flow-debug` — Trace an inbound API, script, or value-change flow end to end
|
### `flow-debug` — Trace an inbound API, script, or value-change flow end to end
|
||||||
|
|
||||||
The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`,
|
The CLI face of the **Flow Debugger** (design `docs/plans/2026-09-05-flow-debugger-design.md`,
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
endpoints.MapPost("/auth/login", async (HttpContext context) =>
|
endpoints.MapPost("/auth/login", async (HttpContext context) =>
|
||||||
{
|
{
|
||||||
|
var antiforgery = context.RequestServices.GetRequiredService<IAntiforgery>();
|
||||||
|
try { await antiforgery.ValidateRequestAsync(context); }
|
||||||
|
catch (AntiforgeryValidationException)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||||
|
return;
|
||||||
|
}
|
||||||
var form = await context.Request.ReadFormAsync();
|
var form = await context.Request.ReadFormAsync();
|
||||||
var username = form["username"].ToString();
|
var username = form["username"].ToString();
|
||||||
var password = form["password"].ToString();
|
var password = form["password"].ToString();
|
||||||
@@ -109,7 +116,7 @@ public static class AuthEndpoints
|
|||||||
BuildSignInProperties());
|
BuildSignInProperties());
|
||||||
|
|
||||||
context.Response.Redirect("/");
|
context.Response.Redirect("/");
|
||||||
}).DisableAntiforgery();
|
});
|
||||||
|
|
||||||
endpoints.MapPost("/auth/token", async (HttpContext context) =>
|
endpoints.MapPost("/auth/token", async (HttpContext context) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
|
||||||
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
|
||||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||||
@using ZB.MOM.WW.ScadaBridge.Security
|
@using ZB.MOM.WW.ScadaBridge.Security
|
||||||
@inject IManualFailoverService Failover
|
@inject AuthorizedManualFailoverService Failover
|
||||||
@inject IDialogService Dialog
|
@inject IDialogService Dialog
|
||||||
|
|
||||||
@* Admin-only manual failover of the central pair (decision 2026-07-22). Rendered on the
|
@* Admin-only manual failover of the central pair (decision 2026-07-22). Rendered on the
|
||||||
@@ -94,11 +93,10 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var actor = await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
|
|
||||||
|
|
||||||
if (IsSite)
|
if (IsSite)
|
||||||
{
|
{
|
||||||
var outcome = await Failover.FailOverSiteAsync(SiteId!, actor);
|
var outcome = await Failover.FailOverSiteAsync(SiteId!);
|
||||||
if (outcome.Accepted)
|
if (outcome.Accepted)
|
||||||
{
|
{
|
||||||
_message = $"Failover triggered — {outcome.TargetAddress} is leaving the site cluster.";
|
_message = $"Failover triggered — {outcome.TargetAddress} is leaving the site cluster.";
|
||||||
@@ -113,7 +111,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var target = await Failover.FailOverCentralAsync(actor);
|
var target = await Failover.FailOverCentralAsync();
|
||||||
if (target is null)
|
if (target is null)
|
||||||
{
|
{
|
||||||
// The server-side peer guard refused. Never report a failover that did not happen.
|
// The server-side peer guard refused. Never report a failover that did not happen.
|
||||||
|
|||||||
@@ -47,33 +47,38 @@
|
|||||||
<NavRailItem Href="/deployment/topology" Text="Topology" />
|
<NavRailItem Href="/deployment/topology" Text="Topology" />
|
||||||
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
|
<NavRailItem Href="/deployment/deployments" Text="Deployments" />
|
||||||
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
|
<NavRailItem Href="/deployment/debug-view" Text="Debug View" />
|
||||||
<NavRailItem Href="/deployment/flow-debugger" Text="Flow Debugger" />
|
|
||||||
</NavRailSection>
|
</NavRailSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@* Notifications — mixed-role section; each item gated by its own policy.
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireFlowDebugger">
|
||||||
The section is ungated: every authenticated user holds at least one of
|
<Authorized Context="flowContext">
|
||||||
Admin/Design/Deployment, so it always has a visible child. *@
|
<NavRailItem Href="/deployment/flow-debugger" Text="Flow Debugger" />
|
||||||
<NavRailSection Title="Notifications" Key="notifications">
|
</Authorized>
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
</AuthorizeView>
|
||||||
<Authorized Context="notifAdminContext">
|
|
||||||
<NavRailItem Href="/notifications/smtp" Text="SMTP Configuration" />
|
@if (context.User.Claims.Any(c => c.Type == JwtTokenService.RoleClaimType && (c.Value == Roles.Administrator || c.Value == Roles.Designer || c.Value == Roles.Deployer)))
|
||||||
<NavRailItem Href="/notifications/sms" Text="SMS Configuration" />
|
{
|
||||||
</Authorized>
|
<NavRailSection Title="Notifications" Key="notifications">
|
||||||
</AuthorizeView>
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
<Authorized Context="notifAdminContext">
|
||||||
<Authorized Context="notifDesignContext">
|
<NavRailItem Href="/notifications/smtp" Text="SMTP Configuration" />
|
||||||
<NavRailItem Href="/notifications/lists" Text="Notification Lists" />
|
<NavRailItem Href="/notifications/sms" Text="SMS Configuration" />
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||||
<Authorized Context="notifDeploymentContext">
|
<Authorized Context="notifDesignContext">
|
||||||
<NavRailItem Href="/notifications/report" Text="Notification Report" />
|
<NavRailItem Href="/notifications/lists" Text="Notification Lists" />
|
||||||
<NavRailItem Href="/notifications/kpis" Text="Notification KPIs" />
|
</Authorized>
|
||||||
</Authorized>
|
</AuthorizeView>
|
||||||
</AuthorizeView>
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||||
</NavRailSection>
|
<Authorized Context="notifDeploymentContext">
|
||||||
|
<NavRailItem Href="/notifications/report" Text="Notification Report" />
|
||||||
|
<NavRailItem Href="/notifications/kpis" Text="Notification KPIs" />
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</NavRailSection>
|
||||||
|
}
|
||||||
|
|
||||||
@* Site Calls — Site Call Audit (#22). Deployer-role only,
|
@* Site Calls — Site Call Audit (#22). Deployer-role only,
|
||||||
matching the Notification Report page's gate; the whole
|
matching the Notification Report page's gate; the whole
|
||||||
|
|||||||
+5
-5
@@ -704,12 +704,12 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
private static int CountLogRows(IReadOnlyList<AuditEvent>? events)
|
private static int CountLogRows(IReadOnlyList<AuditEvent>? events)
|
||||||
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
|
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
|
||||||
|
|
||||||
/// <summary>Union of two audit-row lists by <see cref="AuditEvent.EventId"/>; a row present in both takes the fresh copy.</summary>
|
/// <summary>Preserves fresh Replay order, then appends live-only rows; duplicate event IDs use the fresh copy.</summary>
|
||||||
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
|
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
|
||||||
{
|
{
|
||||||
var byId = new Dictionary<Guid, AuditEvent>();
|
var byId = new Dictionary<Guid, AuditEvent>();
|
||||||
foreach (var e in known ?? []) byId[e.EventId] = e;
|
|
||||||
foreach (var e in fresh ?? []) byId[e.EventId] = e;
|
foreach (var e in fresh ?? []) byId[e.EventId] = e;
|
||||||
|
foreach (var e in known ?? []) byId.TryAdd(e.EventId, e);
|
||||||
return byId.Values.ToList();
|
return byId.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,11 +811,11 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-side re-check before relaying — the action must not fire for a site outside the caller's
|
/// Server-side re-check before relaying — the action must not fire for a site outside the caller's
|
||||||
/// permitted set even though the node is on screen. Notifications are central-owned (no site).
|
/// permitted set even though the node is on screen. Notifications are central-owned (no site).
|
||||||
/// A site the page cannot resolve is allowed through, as on the Site Calls page.
|
/// An unresolvable site is denied for a scoped caller.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<bool> IsStepSiteAllowedAsync(FlowStep step)
|
private async Task<bool> IsStepSiteAllowedAsync(FlowStep step)
|
||||||
{
|
{
|
||||||
if (step.Kind == FlowStepKind.Notify || string.IsNullOrEmpty(step.SiteId))
|
if (step.Kind == FlowStepKind.Notify)
|
||||||
return true;
|
return true;
|
||||||
if (await SiteScope.IsSystemWideAsync())
|
if (await SiteScope.IsSystemWideAsync())
|
||||||
return true;
|
return true;
|
||||||
@@ -832,7 +832,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (site is null)
|
if (site is null)
|
||||||
return true;
|
return false;
|
||||||
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
|
return (await SiteScope.PermittedSiteIdsAsync()).Contains(site.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
|
<div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
|
||||||
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
|
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
|
||||||
placeholder="Search sites, areas, instances..."
|
placeholder="Search loaded sites, areas, instances..."
|
||||||
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="Topology actions">
|
<div class="btn-group btn-group-sm" role="group" aria-label="Topology actions">
|
||||||
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
|
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
|
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-secondary btn-sm" aria-label="Re-check staleness"
|
<button class="btn btn-outline-secondary btn-sm" aria-label="Re-check staleness"
|
||||||
title="Recompute the Stale/Current badge for every deployed instance"
|
title="Recompute the Stale/Current badge for loaded deployed instances"
|
||||||
@onclick="RecheckStalenessAsync" disabled="@_recheckingStaleness">
|
@onclick="RecheckStalenessAsync" disabled="@_recheckingStaleness">
|
||||||
@if (_recheckingStaleness)
|
@if (_recheckingStaleness)
|
||||||
{
|
{
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-light py-2 mb-3 small">
|
<div class="alert alert-light py-2 mb-3 small">
|
||||||
@_allAreas.Count area(s) · @_allInstances.Count instance(s) across @_sites.Count site(s).
|
@_allAreas.Count loaded area(s) · @_allInstances.Count loaded instance(s) across @_sites.Count site(s). Expand a branch and load its instances (50 per page).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
|
<div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
|
||||||
@@ -136,7 +136,11 @@
|
|||||||
// ---- Data ----
|
// ---- Data ----
|
||||||
private List<Instance> _allInstances = new();
|
private List<Instance> _allInstances = new();
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
private List<Template> _templates = new();
|
private const int InstancePageSize = 50;
|
||||||
|
private readonly Dictionary<(int Site, int? Area), int> _loadedPages = new();
|
||||||
|
private readonly HashSet<int> _loadedSites = new();
|
||||||
|
private bool _branchLoading;
|
||||||
|
private readonly SemaphoreSlim _loadGate = new(1, 1);
|
||||||
private List<Area> _allAreas = new();
|
private List<Area> _allAreas = new();
|
||||||
private Dictionary<int, bool> _stalenessMap = new();
|
private Dictionary<int, bool> _stalenessMap = new();
|
||||||
|
|
||||||
@@ -183,7 +187,7 @@
|
|||||||
private const string SelectedKeyStorage = "topology-tree-selected";
|
private const string SelectedKeyStorage = "topology-tree-selected";
|
||||||
|
|
||||||
// ---- Tree model ----
|
// ---- Tree model ----
|
||||||
private enum TopoNodeKind { Site, Area, Instance }
|
private enum TopoNodeKind { Site, Area, Instance, Loader }
|
||||||
|
|
||||||
private record TopoNode(
|
private record TopoNode(
|
||||||
string Key,
|
string Key,
|
||||||
@@ -330,45 +334,81 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fast path — loads the cheap deployed state (sites, instances, areas →
|
/// Refreshes permitted sites and previously loaded branch pages without expanding
|
||||||
/// hierarchy + State badge) WITHOUT recomputing per-instance staleness. These
|
/// new sites or loading every instance. Each instance query is capped at 50 rows.
|
||||||
/// are all single, indexed central-DB reads. This is exactly what the
|
/// This is the live-update timer path; it does not recompute staleness. Internal so the live-update regression
|
||||||
/// live-update timer polls every 15s. Internal so the live-update regression
|
|
||||||
/// test can drive the timer's code path directly.
|
/// test can drive the timer's code path directly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal async Task LoadDeployedStateAsync()
|
internal async Task LoadDeployedStateAsync()
|
||||||
{
|
{
|
||||||
_loading = true;
|
await _loadGate.WaitAsync();
|
||||||
_errorMessage = null;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
|
_loading = true;
|
||||||
// sites — and therefore the areas/instances — they are permitted on.
|
_errorMessage = null;
|
||||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
try
|
||||||
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
|
|
||||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
|
|
||||||
.Where(i => permittedSiteIds.Contains(i.SiteId))
|
|
||||||
.ToList();
|
|
||||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
|
||||||
|
|
||||||
_allAreas.Clear();
|
|
||||||
foreach (var site in _sites)
|
|
||||||
{
|
{
|
||||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
|
||||||
_allAreas.AddRange(areas);
|
// sites — and therefore the areas/instances — they are permitted on.
|
||||||
}
|
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||||
|
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
|
||||||
|
_loadedSites.IntersectWith(permittedSiteIds);
|
||||||
|
foreach (var key in _loadedPages.Keys.Where(k => !permittedSiteIds.Contains(k.Site)).ToArray())
|
||||||
|
_loadedPages.Remove(key);
|
||||||
|
_allInstances.Clear();
|
||||||
|
_allAreas.Clear();
|
||||||
|
foreach (var siteId in _loadedSites)
|
||||||
|
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
|
||||||
|
foreach (var (key, offset) in _loadedPages)
|
||||||
|
_allInstances.AddRange(await TemplateEngineRepository.GetTopologyInstancesAsync(key.Site, key.Area, offset, InstancePageSize));
|
||||||
|
|
||||||
|
BuildTree();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to load topology: {ex.Message}";
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
|
||||||
BuildTree();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
finally { _loadGate.Release(); }
|
||||||
{
|
|
||||||
_errorMessage = $"Failed to load topology: {ex.Message}";
|
|
||||||
}
|
|
||||||
_loading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit branch loading keeps collapsed sites/areas out of circuit memory.
|
||||||
|
internal async Task LoadBranchAsync(int siteId, int? areaId, int offset = 0)
|
||||||
|
{
|
||||||
|
await _loadGate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await SiteScope.IsSiteAllowedAsync(siteId)) return;
|
||||||
|
_branchLoading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_loadedSites.Contains(siteId))
|
||||||
|
{
|
||||||
|
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
|
||||||
|
_loadedSites.Add(siteId);
|
||||||
|
}
|
||||||
|
var rows = await TemplateEngineRepository.GetTopologyInstancesAsync(siteId, areaId, offset, InstancePageSize);
|
||||||
|
_allInstances.RemoveAll(i => i.SiteId == siteId && i.AreaId == areaId);
|
||||||
|
_allInstances.AddRange(rows);
|
||||||
|
_loadedPages[(siteId, areaId)] = offset;
|
||||||
|
await ComputeStalenessAsync(loadGateHeld: true);
|
||||||
|
BuildTree();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _errorMessage = $"Failed to load branch: {ex.Message}"; }
|
||||||
|
finally { _branchLoading = false; }
|
||||||
|
|
||||||
|
}
|
||||||
|
finally { _loadGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private TopoNode MakeLoader(int siteId, int? areaId) => new(
|
||||||
|
$"load:{siteId}:{areaId}", TopoNodeKind.Loader, areaId ?? 0, siteId,
|
||||||
|
"Load instances", null, null, null, false, false, new());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Expensive path — recomputes the Stale/Current badge for every deployed
|
/// Expensive path — recomputes the Stale/Current badge for each loaded deployed
|
||||||
/// instance via <c>DeploymentService.GetDeploymentComparisonAsync</c>, which
|
/// instance via <c>DeploymentService.GetDeploymentComparisonAsync</c>, which
|
||||||
/// re-flattens each instance's current config and compares it to the deployed
|
/// re-flattens each instance's current config and compares it to the deployed
|
||||||
/// snapshot. This is the page's dominant cost, so it runs only on initial
|
/// snapshot. This is the page's dominant cost, so it runs only on initial
|
||||||
@@ -378,27 +418,33 @@
|
|||||||
/// DI scope so the parallel flattens never share the circuit-scoped DbContext
|
/// DI scope so the parallel flattens never share the circuit-scoped DbContext
|
||||||
/// (mirrors KpiHistoryQueryService's per-query scope).
|
/// (mirrors KpiHistoryQueryService's per-query scope).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ComputeStalenessAsync()
|
private async Task ComputeStalenessAsync(bool loadGateHeld = false)
|
||||||
{
|
{
|
||||||
var deployedIds = _allInstances
|
if (!loadGateHeld) await _loadGate.WaitAsync();
|
||||||
.Where(i => i.State != InstanceState.NotDeployed)
|
try
|
||||||
.Select(i => i.Id)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (deployedIds.Count == 0)
|
|
||||||
{
|
{
|
||||||
_stalenessMap = new Dictionary<int, bool>();
|
var deployedIds = _allInstances
|
||||||
|
.Where(i => i.State != InstanceState.NotDeployed)
|
||||||
|
.Select(i => i.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (deployedIds.Count == 0)
|
||||||
|
{
|
||||||
|
_stalenessMap = new Dictionary<int, bool>();
|
||||||
|
BuildTree();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new ConcurrentDictionary<int, bool>();
|
||||||
|
using var gate = new SemaphoreSlim(MaxConcurrentStalenessChecks, MaxConcurrentStalenessChecks);
|
||||||
|
|
||||||
|
await Task.WhenAll(deployedIds.Select(id => ComputeOneStalenessAsync(id, gate, results)));
|
||||||
|
|
||||||
|
_stalenessMap = new Dictionary<int, bool>(results);
|
||||||
BuildTree();
|
BuildTree();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
finally { if (!loadGateHeld) _loadGate.Release(); }
|
||||||
var results = new ConcurrentDictionary<int, bool>();
|
|
||||||
using var gate = new SemaphoreSlim(MaxConcurrentStalenessChecks, MaxConcurrentStalenessChecks);
|
|
||||||
|
|
||||||
await Task.WhenAll(deployedIds.Select(id => ComputeOneStalenessAsync(id, gate, results)));
|
|
||||||
|
|
||||||
_stalenessMap = new Dictionary<int, bool>(results);
|
|
||||||
BuildTree();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ComputeOneStalenessAsync(
|
private async Task ComputeOneStalenessAsync(
|
||||||
@@ -469,7 +515,7 @@
|
|||||||
.Select(MakeInstanceNode)
|
.Select(MakeInstanceNode)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var children = areaChildren.Concat(rootInstances).ToList();
|
var children = areaChildren.Concat(rootInstances).Append(MakeLoader(site.Id, null)).ToList();
|
||||||
|
|
||||||
return new TopoNode(
|
return new TopoNode(
|
||||||
Key: $"s:{site.Id}",
|
Key: $"s:{site.Id}",
|
||||||
@@ -500,7 +546,7 @@
|
|||||||
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(MakeInstanceNode)
|
.Select(MakeInstanceNode)
|
||||||
.ToList();
|
.ToList();
|
||||||
var children = childAreas.Concat(areaInstances).ToList();
|
var children = childAreas.Concat(areaInstances).Append(MakeLoader(area.SiteId, area.Id)).ToList();
|
||||||
return new TopoNode(
|
return new TopoNode(
|
||||||
Key: $"a:{area.Id}",
|
Key: $"a:{area.Id}",
|
||||||
Kind: TopoNodeKind.Area,
|
Kind: TopoNodeKind.Area,
|
||||||
@@ -538,6 +584,21 @@
|
|||||||
|
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
{
|
{
|
||||||
|
case TopoNodeKind.Loader:
|
||||||
|
var areaId = node.EntityId == 0 ? (int?)null : node.EntityId;
|
||||||
|
var loaded = _loadedPages.TryGetValue((node.SiteId, areaId), out var offset);
|
||||||
|
@if (!loaded)
|
||||||
|
{
|
||||||
|
<button class="btn btn-link btn-sm" disabled="@_branchLoading" @onclick="() => LoadBranchAsync(node.SiteId, areaId)">Load instances</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="small text-muted">Page @(offset / InstancePageSize + 1)</span>
|
||||||
|
<button class="btn btn-link btn-sm" disabled="@(_branchLoading || offset == 0)" @onclick="() => LoadBranchAsync(node.SiteId, areaId, Math.Max(0, offset - InstancePageSize))">Previous</button>
|
||||||
|
<button class="btn btn-link btn-sm" disabled="@(_branchLoading || _allInstances.Count(i => i.SiteId == node.SiteId && i.AreaId == areaId) < InstancePageSize)" @onclick="() => LoadBranchAsync(node.SiteId, areaId, offset + InstancePageSize)">Next</button>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case TopoNodeKind.Site:
|
case TopoNodeKind.Site:
|
||||||
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
|
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
|
||||||
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
|
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
|
||||||
|
|||||||
@@ -396,19 +396,12 @@
|
|||||||
StopTimer();
|
StopTimer();
|
||||||
_refreshTimer = new Timer(_ =>
|
_refreshTimer = new Timer(_ =>
|
||||||
{
|
{
|
||||||
if (!_pollGate.TryEnter()) return;
|
_ = _pollGate.RunAsync(() => InvokeAsync(async () =>
|
||||||
InvokeAsync(async () =>
|
|
||||||
{
|
{
|
||||||
try
|
if (_disposed) return;
|
||||||
{
|
await RefreshAsync();
|
||||||
await RefreshAsync();
|
StateHasChanged();
|
||||||
StateHasChanged();
|
}), error => System.Diagnostics.Trace.TraceWarning("AlarmSummary refresh failed: {0}", error));
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_pollGate.Exit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
|
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -718,19 +718,12 @@
|
|||||||
|
|
||||||
_refreshTimer = new Timer(_ =>
|
_refreshTimer = new Timer(_ =>
|
||||||
{
|
{
|
||||||
if (!_pollGate.TryEnter()) return;
|
_ = _pollGate.RunAsync(() => InvokeAsync(async () =>
|
||||||
InvokeAsync(async () =>
|
|
||||||
{
|
{
|
||||||
try
|
if (_disposed) return;
|
||||||
{
|
await RefreshAllAsync(forceRefresh: false);
|
||||||
await RefreshAllAsync(forceRefresh: false);
|
StateHasChanged();
|
||||||
StateHasChanged();
|
}), error => System.Diagnostics.Trace.TraceWarning("Health refresh failed: {0}", error));
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_pollGate.Exit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
|
}, null, TimeSpan.FromSeconds(_autoRefreshSeconds), TimeSpan.FromSeconds(_autoRefreshSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,8 +989,11 @@
|
|||||||
_ => "bg-secondary"
|
_ => "bg-secondary"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private volatile bool _disposed;
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_disposed = true;
|
||||||
_refreshTimer?.Dispose();
|
_refreshTimer?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,12 +96,47 @@
|
|||||||
private HashSet<string>? _filterRevealed;
|
private HashSet<string>? _filterRevealed;
|
||||||
private Func<TemplateTreeNode, bool>? _initiallyExpanded;
|
private Func<TemplateTreeNode, bool>? _initiallyExpanded;
|
||||||
|
|
||||||
|
// Compare immutable value snapshots, rather than entity/list reference identity:
|
||||||
|
// callers may rename, move, or edit composition children in place.
|
||||||
|
private sealed record TreeInput(string Kind, int Id, int? ParentId, string Name, bool Derived = false, bool Inherited = false, string? KeyOverride = null);
|
||||||
|
private List<TreeInput>? _treeInputs;
|
||||||
|
private Dictionary<int, IReadOnlyList<TemplateTreeNode>> _extraChildren = new();
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
BuildTree();
|
var inputs = new List<TreeInput>();
|
||||||
|
foreach (var folder in Folders)
|
||||||
|
inputs.Add(new("folder", folder.Id, folder.ParentFolderId, folder.Name));
|
||||||
|
_extraChildren = new();
|
||||||
|
foreach (var template in Templates)
|
||||||
|
{
|
||||||
|
inputs.Add(new("template", template.Id, template.FolderId, template.Name, template.IsDerived));
|
||||||
|
if (!template.IsDerived && ExtraTemplateChildren is not null)
|
||||||
|
{
|
||||||
|
var children = ExtraTemplateChildren(template);
|
||||||
|
_extraChildren[template.Id] = children;
|
||||||
|
CaptureChildren(children, inputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_treeInputs is null || !inputs.SequenceEqual(_treeInputs))
|
||||||
|
{
|
||||||
|
_treeInputs = inputs;
|
||||||
|
BuildTree();
|
||||||
|
}
|
||||||
|
// Filter and RevealKeys are independent and may also be changed in place.
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void CaptureChildren(IReadOnlyList<TemplateTreeNode> children, List<TreeInput> inputs)
|
||||||
|
{
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
inputs.Add(new(child.Kind.ToString(), child.Id, null, child.Name, Inherited: child.IsInherited, KeyOverride: child.KeyOverride));
|
||||||
|
CaptureChildren(child.Children, inputs);
|
||||||
|
inputs.Add(new("end", 0, null, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void BuildTree()
|
private void BuildTree()
|
||||||
{
|
{
|
||||||
var folderNodes = Folders.ToDictionary(
|
var folderNodes = Folders.ToDictionary(
|
||||||
@@ -136,7 +171,7 @@
|
|||||||
|
|
||||||
if (ExtraTemplateChildren != null)
|
if (ExtraTemplateChildren != null)
|
||||||
{
|
{
|
||||||
foreach (var extra in ExtraTemplateChildren(t))
|
foreach (var extra in _extraChildren[t.Id])
|
||||||
{
|
{
|
||||||
tNode.Children.Add(extra);
|
tNode.Children.Add(extra);
|
||||||
}
|
}
|
||||||
@@ -231,6 +266,8 @@
|
|||||||
Kind = node.Kind,
|
Kind = node.Kind,
|
||||||
Id = node.Id,
|
Id = node.Id,
|
||||||
Name = node.Name,
|
Name = node.Name,
|
||||||
|
IsInherited = node.IsInherited,
|
||||||
|
KeyOverride = node.KeyOverride,
|
||||||
};
|
};
|
||||||
foreach (var k in keptChildren) clone.Children.Add(k);
|
foreach (var k in keptChildren) clone.Children.Add(k);
|
||||||
if (keptChildren.Count > 0)
|
if (keptChildren.Count > 0)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public static class ServiceCollectionExtensions
|
|||||||
// Resolves the current user's permitted site set from their SiteId claims
|
// Resolves the current user's permitted site set from their SiteId claims
|
||||||
// so Deployment/Monitoring pages can enforce site scoping.
|
// so Deployment/Monitoring pages can enforce site scoping.
|
||||||
services.AddScoped<SiteScopeService>();
|
services.AddScoped<SiteScopeService>();
|
||||||
|
services.AddScoped<AuthorizedManualFailoverService>();
|
||||||
services.AddScoped<ConfigurationCommandService>();
|
services.AddScoped<ConfigurationCommandService>();
|
||||||
|
|
||||||
// Centralised dialog service: pages inject IDialogService and a single
|
// Centralised dialog service: pages inject IDialogService and a single
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
|
||||||
|
/// <summary>Scoped application boundary that checks current circuit authority before host failover.</summary>
|
||||||
|
public sealed class AuthorizedManualFailoverService(AuthenticationStateProvider auth, IManualFailoverService backend)
|
||||||
|
{
|
||||||
|
/// <summary>Fails over central only for a currently authorized administrator.</summary>
|
||||||
|
public async Task<string?> FailOverCentralAsync()
|
||||||
|
=> await backend.FailOverCentralAsync(await auth.RequireRoleAsync(Roles.Administrator));
|
||||||
|
|
||||||
|
/// <summary>Fails over a site only for a currently authorized administrator.</summary>
|
||||||
|
public async Task<SiteFailoverOutcome> FailOverSiteAsync(string siteId)
|
||||||
|
=> await backend.FailOverSiteAsync(siteId, await auth.RequireRoleAsync(Roles.Administrator));
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
private readonly ManagementActorHolder _holder;
|
private readonly ManagementActorHolder _holder;
|
||||||
private readonly AuthenticationStateProvider _auth;
|
private readonly AuthenticationStateProvider _auth;
|
||||||
private readonly IInboundApiRepository _inboundApi;
|
private readonly IInboundApiRepository _inboundApi;
|
||||||
|
private readonly ISiteRepository _sites;
|
||||||
private readonly ITemplateEngineRepository _templates;
|
private readonly ITemplateEngineRepository _templates;
|
||||||
private readonly IFlatteningPipeline _pipeline;
|
private readonly IFlatteningPipeline _pipeline;
|
||||||
private readonly CommunicationService _comms;
|
private readonly CommunicationService _comms;
|
||||||
@@ -116,8 +117,10 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
ITemplateEngineRepository templates,
|
ITemplateEngineRepository templates,
|
||||||
IFlatteningPipeline pipeline,
|
IFlatteningPipeline pipeline,
|
||||||
CommunicationService comms,
|
CommunicationService comms,
|
||||||
ILogger<FlowDebugService> logger)
|
ILogger<FlowDebugService> logger,
|
||||||
|
ISiteRepository sites)
|
||||||
{
|
{
|
||||||
|
_sites = sites ?? throw new ArgumentNullException(nameof(sites));
|
||||||
_holder = holder ?? throw new ArgumentNullException(nameof(holder));
|
_holder = holder ?? throw new ArgumentNullException(nameof(holder));
|
||||||
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
||||||
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
|
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
|
||||||
@@ -322,6 +325,13 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var site = step.SiteId!;
|
var site = step.SiteId!;
|
||||||
|
var scope = new SiteScopeService(_auth);
|
||||||
|
if (!await scope.IsSystemWideAsync())
|
||||||
|
{
|
||||||
|
var owner = (await _sites.GetAllSitesAsync(ct)).FirstOrDefault(s => s.SiteIdentifier == site);
|
||||||
|
if (owner is null || !await scope.IsSiteAllowedAsync(owner.Id))
|
||||||
|
return new(false, "You are not permitted to act on cached calls for this site.");
|
||||||
|
}
|
||||||
SiteCallRelayOutcome outcome;
|
SiteCallRelayOutcome outcome;
|
||||||
bool siteReachable;
|
bool siteReachable;
|
||||||
string? errorMessage;
|
string? errorMessage;
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ public sealed class PollGate
|
|||||||
/// may proceed; <see langword="false"/> if a previous refresh is still running.</returns>
|
/// may proceed; <see langword="false"/> if a previous refresh is still running.</returns>
|
||||||
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
|
public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
|
||||||
|
|
||||||
|
/// <summary>Runs and observes a posted refresh, releasing the gate even if dispatch is rejected.</summary>
|
||||||
|
public async Task RunAsync(Func<Task> dispatch, Action<Exception> onError)
|
||||||
|
{
|
||||||
|
if (!TryEnter()) return;
|
||||||
|
try { await dispatch().ConfigureAwait(false); }
|
||||||
|
catch (Exception error) { onError(error); }
|
||||||
|
finally { Exit(); }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Releases the gate after a poll tick's refresh has completed.</summary>
|
/// <summary>Releases the gate after a poll tick's refresh has completed.</summary>
|
||||||
public void Exit() => Volatile.Write(ref _inFlight, 0);
|
public void Exit() => Volatile.Write(ref _inFlight, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ public interface IAuditLogRepository
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Bounded + idempotent.</b> Each batch is a <c>DELETE TOP (@batch)</c> so the
|
/// <b>Bounded + idempotent.</b> Each batch is a <c>DELETE TOP (@batch)</c> so the
|
||||||
/// transaction log and lock footprint stay bounded regardless of backlog. Re-running
|
/// transaction log and lock footprint stay bounded regardless of backlog. Re-running
|
||||||
/// the purge is a no-op once every eligible row is gone (the loop exits when a batch
|
/// the purge resumes remaining backlog after reaching <paramref name="maxRows"/>;
|
||||||
/// deletes zero rows), so a crash mid-loop is recoverable by simply running again.
|
/// an empty batch ends it sooner. A crash mid-loop is recoverable by running again.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="channel">Canonical channel name (the <c>Category</c> column value, e.g. <c>ApiOutbound</c>).</param>
|
/// <param name="channel">Canonical channel name (the <c>Category</c> column value, e.g. <c>ApiOutbound</c>).</param>
|
||||||
@@ -183,13 +183,14 @@ public interface IAuditLogRepository
|
|||||||
/// by the ~30s ADO.NET default (arch-review 04, S2).
|
/// by the ~30s ADO.NET default (arch-review 04, S2).
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <param name="maxRows">Maximum total rows deleted in this call; remaining backlog is retained for a later tick.</param>
|
||||||
/// <returns>A task that resolves to the total number of rows deleted across all batches.</returns>
|
/// <returns>A task that resolves to the total number of rows deleted across all batches.</returns>
|
||||||
Task<long> PurgeChannelOlderThanAsync(
|
Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel,
|
string channel,
|
||||||
DateTime threshold,
|
DateTime threshold,
|
||||||
int batchSize,
|
int batchSize,
|
||||||
TimeSpan? commandTimeout = null,
|
TimeSpan? commandTimeout = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default, int maxRows = 50_000);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the set of <c>pf_AuditLog_Month</c> partition lower-bound
|
/// Returns the set of <c>pf_AuditLog_Month</c> partition lower-bound
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ public interface ITemplateEngineRepository
|
|||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A task that resolves to a read-only list of instances for the specified site.</returns>
|
/// <returns>A task that resolves to a read-only list of instances for the specified site.</returns>
|
||||||
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Reads one bounded, untracked page of topology instance rows for a site/area.</summary>
|
||||||
|
Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Retrieves an instance by unique name.</summary>
|
/// <summary>Retrieves an instance by unique name.</summary>
|
||||||
/// <param name="uniqueName">The unique instance name.</param>
|
/// <param name="uniqueName">The unique instance name.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ public interface IPullTraceEventsClient
|
|||||||
/// <param name="sinceUtc">Lower bound on <c>OccurredAtUtc</c> (the run's start is the natural choice).</param>
|
/// <param name="sinceUtc">Lower bound on <c>OccurredAtUtc</c> (the run's start is the natural choice).</param>
|
||||||
/// <param name="maxEvents">Row cap (the site caps at 2000).</param>
|
/// <param name="maxEvents">Row cap (the site caps at 2000).</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <param name="afterEventId">Optional exclusive EventId at sinceUtc for stable keyset paging.</param>
|
||||||
/// <returns>The matching rows ordered <c>(OccurredAtUtc, EventId)</c>; empty on fault or unknown site.</returns>
|
/// <returns>The matching rows ordered <c>(OccurredAtUtc, EventId)</c>; empty on fault or unknown site.</returns>
|
||||||
Task<IReadOnlyList<AuditEvent>> PullAsync(
|
Task<IReadOnlyList<AuditEvent>> PullAsync(
|
||||||
string siteId,
|
string siteId,
|
||||||
IReadOnlyCollection<Guid> executionIds,
|
IReadOnlyCollection<Guid> executionIds,
|
||||||
DateTime sinceUtc,
|
DateTime sinceUtc,
|
||||||
int maxEvents,
|
int maxEvents,
|
||||||
CancellationToken ct);
|
CancellationToken ct, string? afterEventId = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,5 +232,5 @@ public interface ISiteAuditQueue
|
|||||||
/// forwarding state — moves no cursor and flips nothing. At most 64 ids per call.
|
/// forwarding state — moves no cursor and flips nothing. At most 64 ids per call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default);
|
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|||||||
/// <param name="Quality">Quality of the new value (<c>Good</c>/<c>Bad</c>/<c>Uncertain</c>).</param>
|
/// <param name="Quality">Quality of the new value (<c>Good</c>/<c>Bad</c>/<c>Uncertain</c>).</param>
|
||||||
/// <param name="Timestamp">UTC timestamp of the change.</param>
|
/// <param name="Timestamp">UTC timestamp of the change.</param>
|
||||||
/// <param name="SourceExecutionId">
|
/// <param name="SourceExecutionId">
|
||||||
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
|
/// Execution associated with this change. Static writes carry an observed writer id;
|
||||||
/// script execution / inbound API request whose write produced this change, or
|
/// armed device updates carry only a candidate writer id (see
|
||||||
/// <c>null</c> when the change did not originate from an audited execution —
|
/// <paramref name="SourceExecutionInferred"/>), since subscriptions carry no write-request id.
|
||||||
/// which is the case for every value that arrives from the Data Connection
|
/// Other device updates and deployment seeding carry no id.
|
||||||
/// Layer (external device data), for deployment-time seeding, and for the
|
|
||||||
/// confirmed value of a data-sourced write (the device echo arrives on the
|
|
||||||
/// subscription long after the writing execution ended).
|
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The Alarm Actor records this id as the <c>ParentExecutionId</c> of the
|
/// The Alarm Actor records this id as the <c>ParentExecutionId</c> of the
|
||||||
@@ -34,6 +31,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Trace">Flow Debugger trace flag — see design 2026-09-05 §5.2. Additive; defaults to untraced.</param>
|
/// <param name="Trace">Flow Debugger trace flag — see design 2026-09-05 §5.2. Additive; defaults to untraced.</param>
|
||||||
|
/// <param name="SourceExecutionInferred">True when the source is inferred from the first device update within the trace-arm window, regardless of value equality, clamping, or coercion; it is not evidence of a write echo.</param>
|
||||||
public record AttributeValueChanged(
|
public record AttributeValueChanged(
|
||||||
string InstanceUniqueName,
|
string InstanceUniqueName,
|
||||||
string AttributePath,
|
string AttributePath,
|
||||||
@@ -42,4 +40,5 @@ public record AttributeValueChanged(
|
|||||||
string Quality,
|
string Quality,
|
||||||
DateTimeOffset Timestamp,
|
DateTimeOffset Timestamp,
|
||||||
Guid? SourceExecutionId = null,
|
Guid? SourceExecutionId = null,
|
||||||
bool Trace = false) : ISiteStreamEvent;
|
bool Trace = false,
|
||||||
|
bool SourceExecutionInferred = false) : ISiteStreamEvent;
|
||||||
|
|||||||
@@ -66,16 +66,17 @@ public static class FlowGraphBuilder
|
|||||||
// D2: a ScriptLog row is a line, never a node. Partition BEFORE the input-order index below, so
|
// D2: a ScriptLog row is a line, never a node. Partition BEFORE the input-order index below, so
|
||||||
// `seq` indexes only real step rows and a line can never shift an execution's display position.
|
// `seq` indexes only real step rows and a line can never shift an execution's display position.
|
||||||
var lines = new List<FlowLogLine>();
|
var lines = new List<FlowLogLine>();
|
||||||
var stepEvents = new List<AuditEvent>(events.Count);
|
var stepEvents = new List<(AuditEvent Event, AuditDetails Details)>(events.Count);
|
||||||
foreach (var e in events)
|
foreach (var e in events)
|
||||||
{
|
{
|
||||||
if (FlowStepProjector.IsLogLine(e))
|
var details = AuditDetailsCodec.Deserialize(e.DetailsJson);
|
||||||
lines.Add(FlowStepProjector.ToLogLine(e));
|
if (FlowStepProjector.IsLogLine(details))
|
||||||
|
lines.Add(FlowStepProjector.ToLogLine(e, details));
|
||||||
else
|
else
|
||||||
stepEvents.Add(e);
|
stepEvents.Add((e, details));
|
||||||
}
|
}
|
||||||
|
|
||||||
events = stepEvents;
|
events = stepEvents.Select(e => e.Event).ToList();
|
||||||
|
|
||||||
if (events.Count == 0)
|
if (events.Count == 0)
|
||||||
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated,
|
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated,
|
||||||
@@ -105,7 +106,7 @@ public static class FlowGraphBuilder
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
var folded = Fold(events).ToList();
|
var folded = Fold(stepEvents).ToList();
|
||||||
var firstSeqByExecution = folded
|
var firstSeqByExecution = folded
|
||||||
.GroupBy(s => s.ExecutionId ?? Guid.Empty)
|
.GroupBy(s => s.ExecutionId ?? Guid.Empty)
|
||||||
.ToDictionary(g => g.Key, g => g.Min(InputIndex));
|
.ToDictionary(g => g.Key, g => g.Min(InputIndex));
|
||||||
@@ -205,7 +206,7 @@ public static class FlowGraphBuilder
|
|||||||
|
|
||||||
var firstChained = chain.FirstOrDefault(s => !IsAsync(s));
|
var firstChained = chain.FirstOrDefault(s => !IsAsync(s));
|
||||||
if (firstChained is not null && spawner.EventId != firstChained.EventId)
|
if (firstChained is not null && spawner.EventId != firstChained.EventId)
|
||||||
edges.Add(new FlowEdge(spawner.EventId, firstChained.EventId, CrossesBoundary(spawner, firstChained), External: false, Spawn: spawn));
|
edges.Add(new FlowEdge(spawner.EventId, firstChained.EventId, CrossesBoundary(spawner, firstChained), External: spawn && first.Extra?.TriggerSource == "inferred-device-update", Spawn: spawn));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
|
// 3. Satisfied wait ⇐ preceding write in the same execution (inferred device echo).
|
||||||
@@ -219,6 +220,7 @@ public static class FlowGraphBuilder
|
|||||||
for (var j = i - 1; j >= 0; j--)
|
for (var j = i - 1; j >= 0; j--)
|
||||||
{
|
{
|
||||||
if (chain[j].Kind != FlowStepKind.AttributeWrite) continue;
|
if (chain[j].Kind != FlowStepKind.AttributeWrite) continue;
|
||||||
|
edges.RemoveAll(e => e.FromEventId == chain[j].EventId && e.ToEventId == wait.EventId);
|
||||||
edges.Add(new FlowEdge(chain[j].EventId, wait.EventId, CrossesBoundary: false, External: true));
|
edges.Add(new FlowEdge(chain[j].EventId, wait.EventId, CrossesBoundary: false, External: true));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -376,14 +378,14 @@ public static class FlowGraphBuilder
|
|||||||
/// Projects every event and folds the outbound rows of one operation (same kind + <c>CorrelationId</c>) into one step
|
/// Projects every event and folds the outbound rows of one operation (same kind + <c>CorrelationId</c>) into one step
|
||||||
/// carrying the whole lifecycle. Non-outbound rows and null-correlation outbound rows pass through one-to-one.
|
/// carrying the whole lifecycle. Non-outbound rows and null-correlation outbound rows pass through one-to-one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IEnumerable<FlowStep> Fold(IReadOnlyList<AuditEvent> events)
|
private static IEnumerable<FlowStep> Fold(IReadOnlyList<(AuditEvent Event, AuditDetails Details)> events)
|
||||||
{
|
{
|
||||||
var groups = new Dictionary<(FlowStepKind Kind, Guid Correlation), List<(FlowStep Step, AuditEvent Evt)>>();
|
var groups = new Dictionary<(FlowStepKind Kind, Guid Correlation), List<(FlowStep Step, FlowLifecycleRow Row)>>();
|
||||||
var order = new List<object>(events.Count); // FlowStep, or the group key — preserves first-seen order
|
var order = new List<object>(events.Count); // FlowStep, or the group key — preserves first-seen order
|
||||||
|
|
||||||
foreach (var evt in events)
|
foreach (var (evt, details) in events)
|
||||||
{
|
{
|
||||||
var step = FlowStepProjector.Project(evt);
|
var step = FlowStepProjector.Project(evt, details);
|
||||||
if (!FlowStepProjector.IsOutbound(step.Kind))
|
if (!FlowStepProjector.IsOutbound(step.Kind))
|
||||||
{
|
{
|
||||||
order.Add(step);
|
order.Add(step);
|
||||||
@@ -392,7 +394,7 @@ public static class FlowGraphBuilder
|
|||||||
|
|
||||||
if (evt.CorrelationId is not { } correlation)
|
if (evt.CorrelationId is not { } correlation)
|
||||||
{
|
{
|
||||||
order.Add(step with { Lifecycle = [FlowStepProjector.ToLifecycleRow(evt)] });
|
order.Add(step with { Lifecycle = [FlowStepProjector.ToLifecycleRow(evt, details)] });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +405,7 @@ public static class FlowGraphBuilder
|
|||||||
groups[key] = group;
|
groups[key] = group;
|
||||||
order.Add(key);
|
order.Add(key);
|
||||||
}
|
}
|
||||||
group.Add((step, evt));
|
group.Add((step, FlowStepProjector.ToLifecycleRow(evt, details)));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in order)
|
foreach (var item in order)
|
||||||
@@ -416,7 +418,6 @@ public static class FlowGraphBuilder
|
|||||||
|
|
||||||
var key = ((FlowStepKind, Guid))item;
|
var key = ((FlowStepKind, Guid))item;
|
||||||
var rows = groups[key]
|
var rows = groups[key]
|
||||||
.Select(r => (r.Step, Row: FlowStepProjector.ToLifecycleRow(r.Evt)))
|
|
||||||
.OrderBy(r => r.Row.OccurredAtUtc)
|
.OrderBy(r => r.Row.OccurredAtUtc)
|
||||||
.ThenBy(r => LifecycleRank(r.Row.Kind, r.Row.Status))
|
.ThenBy(r => LifecycleRank(r.Row.Kind, r.Row.Status))
|
||||||
.ThenBy(r => r.Row.EventId)
|
.ThenBy(r => r.Row.EventId)
|
||||||
@@ -435,6 +436,7 @@ public static class FlowGraphBuilder
|
|||||||
Subtitle = latest.Subtitle,
|
Subtitle = latest.Subtitle,
|
||||||
OutputJson = latest.OutputJson,
|
OutputJson = latest.OutputJson,
|
||||||
ErrorMessage = latest.ErrorMessage,
|
ErrorMessage = latest.ErrorMessage,
|
||||||
|
ErrorDetail = latest.ErrorDetail,
|
||||||
DurationMs = rows.Count == 1 && !terminal ? null : duration,
|
DurationMs = rows.Count == 1 && !terminal ? null : duration,
|
||||||
Lifecycle = lifecycle,
|
Lifecycle = lifecycle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public enum FlowStepOutcome
|
|||||||
/// <param name="Node">Node that wrote it.</param>
|
/// <param name="Node">Node that wrote it.</param>
|
||||||
/// <param name="ErrorMessage">Error summary on a failed attempt.</param>
|
/// <param name="ErrorMessage">Error summary on a failed attempt.</param>
|
||||||
/// <param name="DurationMs">The row's own duration, when it has one.</param>
|
/// <param name="DurationMs">The row's own duration, when it has one.</param>
|
||||||
|
/// <param name="ErrorDetail">Exception detail from this lifecycle row.</param>
|
||||||
public sealed record FlowLifecycleRow(
|
public sealed record FlowLifecycleRow(
|
||||||
Guid EventId,
|
Guid EventId,
|
||||||
string Kind,
|
string Kind,
|
||||||
@@ -72,7 +73,8 @@ public sealed record FlowLifecycleRow(
|
|||||||
DateTimeOffset OccurredAtUtc,
|
DateTimeOffset OccurredAtUtc,
|
||||||
string? Node,
|
string? Node,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
int? DurationMs);
|
int? DurationMs,
|
||||||
|
string? ErrorDetail = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One log line folded onto a step (<see cref="FlowStep.Logs"/>; design 2026-09-09 D1/D8) — the
|
/// One log line folded onto a step (<see cref="FlowStep.Logs"/>; design 2026-09-09 D1/D8) — the
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ public static class FlowStepProjector
|
|||||||
public static FlowStep Project(AuditEvent evt)
|
public static FlowStep Project(AuditEvent evt)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
return Project(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static FlowStep Project(AuditEvent evt, AuditDetails d)
|
||||||
|
{
|
||||||
var extra = ScriptTraceExtra.TryParse(d.Extra);
|
var extra = ScriptTraceExtra.TryParse(d.Extra);
|
||||||
var kind = ResolveKind(d.Channel, d.Kind);
|
var kind = ResolveKind(d.Channel, d.Kind);
|
||||||
var lane = ResolveLane(kind, d.SourceSiteId);
|
var lane = ResolveLane(kind, d.SourceSiteId);
|
||||||
@@ -52,6 +56,11 @@ public static class FlowStepProjector
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
||||||
|
return IsLogLine(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsLogLine(AuditDetails d)
|
||||||
|
{
|
||||||
return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal)
|
return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal)
|
||||||
&& string.Equals(d.Kind, nameof(AuditKind.ScriptLog), StringComparison.Ordinal);
|
&& string.Equals(d.Kind, nameof(AuditKind.ScriptLog), StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
@@ -60,7 +69,11 @@ public static class FlowStepProjector
|
|||||||
public static FlowLogLine ToLogLine(AuditEvent evt)
|
public static FlowLogLine ToLogLine(AuditEvent evt)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
return ToLogLine(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static FlowLogLine ToLogLine(AuditEvent evt, AuditDetails d)
|
||||||
|
{
|
||||||
var x = ScriptTraceExtra.TryParse(d.Extra);
|
var x = ScriptTraceExtra.TryParse(d.Extra);
|
||||||
return new FlowLogLine(
|
return new FlowLogLine(
|
||||||
EventId: evt.EventId,
|
EventId: evt.EventId,
|
||||||
@@ -79,7 +92,11 @@ public static class FlowStepProjector
|
|||||||
public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt)
|
public static FlowLifecycleRow ToLifecycleRow(AuditEvent evt)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(evt);
|
ArgumentNullException.ThrowIfNull(evt);
|
||||||
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
|
return ToLifecycleRow(evt, AuditDetailsCodec.Deserialize(evt.DetailsJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static FlowLifecycleRow ToLifecycleRow(AuditEvent evt, AuditDetails d)
|
||||||
|
{
|
||||||
return new FlowLifecycleRow(
|
return new FlowLifecycleRow(
|
||||||
EventId: evt.EventId,
|
EventId: evt.EventId,
|
||||||
Kind: d.Kind ?? string.Empty,
|
Kind: d.Kind ?? string.Empty,
|
||||||
@@ -87,7 +104,8 @@ public static class FlowStepProjector
|
|||||||
OccurredAtUtc: evt.OccurredAtUtc,
|
OccurredAtUtc: evt.OccurredAtUtc,
|
||||||
Node: evt.SourceNode,
|
Node: evt.SourceNode,
|
||||||
ErrorMessage: d.ErrorMessage,
|
ErrorMessage: d.ErrorMessage,
|
||||||
DurationMs: d.DurationMs);
|
DurationMs: d.DurationMs,
|
||||||
|
ErrorDetail: d.ErrorDetail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
|
/// <summary>Maps a channel/kind string pair onto a step kind.</summary>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public sealed record ScriptTraceExtra
|
|||||||
/// <summary>Encoded value that fired a value-change run.</summary>
|
/// <summary>Encoded value that fired a value-change run.</summary>
|
||||||
public string? TriggerValue { get; init; }
|
public string? TriggerValue { get; init; }
|
||||||
|
|
||||||
/// <summary>Where the triggering change came from: <c>value-change</c> (the actor cannot tell a static write from a device echo).</summary>
|
/// <summary>Where the triggering change came from: <c>value-change</c> for an observed write, or <c>inferred-device-update</c> for the first device publish in a trace-arm window (not a confirmed echo).</summary>
|
||||||
public string? TriggerSource { get; init; }
|
public string? TriggerSource { get; init; }
|
||||||
|
|
||||||
/// <summary>On a <c>RouteCall</c> row: the routed site run's <c>ExecutionId</c>.</summary>
|
/// <summary>On a <c>RouteCall</c> row: the routed site run's <c>ExecutionId</c>.</summary>
|
||||||
|
|||||||
@@ -554,5 +554,5 @@ public record RegisterNotificationOutbox(IActorRef OutboxProxy);
|
|||||||
|
|
||||||
// NOTE: there is deliberately no RegisterAuditIngest counterpart. The audit-log-ingest
|
// NOTE: there is deliberately no RegisterAuditIngest counterpart. The audit-log-ingest
|
||||||
// singleton proxy goes STRAIGHT to the two gRPC servers that need it
|
// singleton proxy goes STRAIGHT to the two gRPC servers that need it
|
||||||
// (CentralControlGrpcService.SetAuditIngestActor / SiteStreamGrpcServer.SetAuditIngestActor);
|
// (CentralControlGrpcService.SetAuditIngestActor);
|
||||||
// routing audit batches through this actor was a redundant hop and was removed.
|
// routing audit batches through this actor was a redundant hop and was removed.
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class CommunicationOptions
|
public class CommunicationOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>Maximum response-header wait before using the first stream event as the connected signal.</summary>
|
||||||
|
public TimeSpan ConnectedHeaderTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central control-plane gRPC endpoints (preferred first), e.g.
|
/// Central control-plane gRPC endpoints (preferred first), e.g.
|
||||||
/// <c>["http://scadabridge-central-a:8083", "http://scadabridge-central-b:8083"]</c>. Dialled by
|
/// <c>["http://scadabridge-central-a:8083", "http://scadabridge-central-b:8083"]</c>. Dialled by
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase<Communi
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Validate(ValidationBuilder builder, CommunicationOptions options)
|
protected override void Validate(ValidationBuilder builder, CommunicationOptions options)
|
||||||
{
|
{
|
||||||
|
builder.RequireThat(options.ConnectedHeaderTimeout > TimeSpan.Zero && options.ConnectedHeaderTimeout.TotalMilliseconds <= uint.MaxValue - 1,
|
||||||
|
"ScadaBridge:Communication:ConnectedHeaderTimeout must be positive and fit a timer.");
|
||||||
|
|
||||||
builder.RequireThat(options.DeploymentTimeout > TimeSpan.Zero,
|
builder.RequireThat(options.DeploymentTimeout > TimeSpan.Zero,
|
||||||
$"ScadaBridge:Communication:DeploymentTimeout must be a positive duration (was {options.DeploymentTimeout}).");
|
$"ScadaBridge:Communication:DeploymentTimeout must be a positive duration (was {options.DeploymentTimeout}).");
|
||||||
|
|
||||||
|
|||||||
@@ -39,17 +39,13 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|||||||
/// <see cref="SiteStreamGrpcServer.AuditIngestAskTimeout"/>, so the inner Ask expired at the same
|
/// <see cref="SiteStreamGrpcServer.AuditIngestAskTimeout"/>, so the inner Ask expired at the same
|
||||||
/// instant as the outer one and could only add latency plus a
|
/// instant as the outer one and could only add latency plus a
|
||||||
/// <see cref="Akka.Actor.Status.Failure"/> repackaging. The proxy now arrives through
|
/// <see cref="Akka.Actor.Status.Failure"/> repackaging. The proxy now arrives through
|
||||||
/// <see cref="SetAuditIngestActor"/>, exactly as <see cref="SiteStreamGrpcServer"/> has always
|
/// <see cref="SetAuditIngestActor"/>; the redundant actor relay handlers were deleted.
|
||||||
/// taken it, and the relay handlers were deleted.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Fault semantics deliberately differ from <see cref="SiteStreamGrpcServer"/>'s ingest
|
/// <b>Fault semantics.</b> Failed central ingest returns a non-OK status, leaving the site's
|
||||||
/// RPCs.</b> That server answers a failed audit ingest with an EMPTY <c>IngestAck</c>; this one
|
/// rows Pending and making a lost batch visible to the next drain. A proxy not handed over
|
||||||
/// fails the call with a non-OK status. Both leave the site's rows <c>Pending</c> for the next
|
/// yet returns an empty ack. The legacy SiteStreamService ingest methods are retired and
|
||||||
/// drain, so the outcome is the same — but this service replaced the ClusterClient path, whose
|
/// always return Unimplemented; they are not an alternate ingestion path.
|
||||||
/// documented behaviour was to propagate the fault, and preserving that keeps a lost batch
|
|
||||||
/// visible as a failure rather than as a successful call that acked nothing. A proxy that has
|
|
||||||
/// not been handed over yet is NOT a fault and still answers with an empty ack.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Status mapping, and why it is not uniform.</b> A site transport may safely re-send a call
|
/// <b>Status mapping, and why it is not uniform.</b> A site transport may safely re-send a call
|
||||||
@@ -126,9 +122,7 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hands the central <c>AuditLogIngestActor</c> singleton proxy to the service so the two
|
/// Hands the central <c>AuditLogIngestActor</c> singleton proxy to the service so the two
|
||||||
/// ingest RPCs can Ask it DIRECTLY. Mirrors
|
/// ingest RPCs can Ask it directly. SiteStreamService retains only Unimplemented wire stubs.
|
||||||
/// <see cref="SiteStreamGrpcServer.SetAuditIngestActor"/> — the site-stream server has always
|
|
||||||
/// dispatched straight to the singleton, and this service now matches it.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Removing the <c>CentralCommunicationActor</c> relay removes an actor hop, a second Ask,
|
/// Removing the <c>CentralCommunicationActor</c> relay removes an actor hop, a second Ask,
|
||||||
|
|||||||
@@ -52,9 +52,7 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How long a replaced/removed channel is PARKED before it is disposed (arch-review
|
/// How long a replaced/removed channel is PARKED before it is disposed (arch-review
|
||||||
/// 2026-08-19 P3-2). Sized to exceed the longest command deadline
|
/// 2026-08-19 P3-2). Active calls retain the channel beyond this minimum until completion.
|
||||||
/// (<c>CommunicationOptions.DeploymentTimeout</c> and friends, minutes at most), so no call
|
|
||||||
/// that was already in flight when the channel was replaced can still be using it.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5);
|
internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
@@ -68,15 +66,16 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Channels replaced by <see cref="UpdateSite"/> or dropped by <see cref="RemoveSite"/>,
|
/// Channels replaced by <see cref="UpdateSite"/> or dropped by <see cref="RemoveSite"/>,
|
||||||
/// each with the instant it becomes safe to dispose. FIFO order is disposal order because
|
/// each with the earliest idle disposal instant. Active-call retention can extend that instant.
|
||||||
/// the grace window is constant.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly ConcurrentQueue<(GrpcChannel Channel, DateTime DisposeAfterUtc)> _graveyard = new();
|
private readonly ConcurrentQueue<(GrpcChannel Channel, DateTime DisposeAfterUtc)> _graveyard = new();
|
||||||
|
private readonly ConcurrentDictionary<GrpcChannel, int> _activeCalls = new();
|
||||||
|
|
||||||
private readonly CancellationTokenSource _shutdown = new();
|
private readonly CancellationTokenSource _shutdown = new();
|
||||||
private Task? _failbackLoop;
|
private Task? _failbackLoop;
|
||||||
private readonly object _loopGate = new();
|
private readonly object _loopGate = new();
|
||||||
private bool _disposed;
|
private volatile bool _disposed;
|
||||||
|
private readonly object _lifecycleGate = new();
|
||||||
|
|
||||||
/// <summary>Creates the provider.</summary>
|
/// <summary>Creates the provider.</summary>
|
||||||
/// <param name="pskProvider">Resolves each site's preshared key for channel credentials.</param>
|
/// <param name="pskProvider">Resolves each site's preshared key for channel credentials.</param>
|
||||||
@@ -120,38 +119,43 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
/// <param name="nodeBEndpoint">NodeB gRPC base address, or null when unconfigured.</param>
|
/// <param name="nodeBEndpoint">NodeB gRPC base address, or null when unconfigured.</param>
|
||||||
public void UpdateSite(string siteId, string? nodeAEndpoint, string? nodeBEndpoint)
|
public void UpdateSite(string siteId, string? nodeAEndpoint, string? nodeBEndpoint)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
lock (_lifecycleGate)
|
||||||
SweepGraveyard();
|
|
||||||
|
|
||||||
var pair = _sites.GetOrAdd(siteId, id => new SitePair(id));
|
|
||||||
lock (pair.Gate)
|
|
||||||
{
|
{
|
||||||
var changedA = !string.Equals(pair.NodeAEndpoint, nodeAEndpoint, StringComparison.Ordinal);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
var changedB = !string.Equals(pair.NodeBEndpoint, nodeBEndpoint, StringComparison.Ordinal);
|
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
|
||||||
if (!changedA && !changedB)
|
SweepGraveyard();
|
||||||
|
|
||||||
|
var pair = _sites.GetOrAdd(siteId, id => new SitePair(id));
|
||||||
|
lock (pair.Gate)
|
||||||
{
|
{
|
||||||
return;
|
var changedA = !string.Equals(pair.NodeAEndpoint, nodeAEndpoint, StringComparison.Ordinal);
|
||||||
|
var changedB = !string.Equals(pair.NodeBEndpoint, nodeBEndpoint, StringComparison.Ordinal);
|
||||||
|
if (!changedA && !changedB)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedA)
|
||||||
|
{
|
||||||
|
Park(pair.ChannelA);
|
||||||
|
pair.ChannelA = string.IsNullOrWhiteSpace(nodeAEndpoint) ? null : BuildChannel(nodeAEndpoint, siteId);
|
||||||
|
pair.NodeAEndpoint = nodeAEndpoint;
|
||||||
|
}
|
||||||
|
if (changedB)
|
||||||
|
{
|
||||||
|
Park(pair.ChannelB);
|
||||||
|
pair.ChannelB = string.IsNullOrWhiteSpace(nodeBEndpoint) ? null : BuildChannel(nodeBEndpoint, siteId);
|
||||||
|
pair.NodeBEndpoint = nodeBEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addresses changed — return to the preferred node and reset failback backoff.
|
||||||
|
pair.CurrentIsA = pair.ChannelA is not null;
|
||||||
|
pair.ResetFailback();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"gRPC channel pair for site {SiteId} refreshed (nodeA={HasA}, nodeB={HasB})",
|
||||||
|
siteId, pair.ChannelA is not null, pair.ChannelB is not null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedA)
|
|
||||||
{
|
|
||||||
Park(pair.ChannelA);
|
|
||||||
pair.ChannelA = string.IsNullOrWhiteSpace(nodeAEndpoint) ? null : BuildChannel(nodeAEndpoint, siteId);
|
|
||||||
pair.NodeAEndpoint = nodeAEndpoint;
|
|
||||||
}
|
|
||||||
if (changedB)
|
|
||||||
{
|
|
||||||
Park(pair.ChannelB);
|
|
||||||
pair.ChannelB = string.IsNullOrWhiteSpace(nodeBEndpoint) ? null : BuildChannel(nodeBEndpoint, siteId);
|
|
||||||
pair.NodeBEndpoint = nodeBEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Addresses changed — return to the preferred node and reset failback backoff.
|
|
||||||
pair.CurrentIsA = pair.ChannelA is not null;
|
|
||||||
pair.ResetFailback();
|
|
||||||
_logger.LogInformation(
|
|
||||||
"gRPC channel pair for site {SiteId} refreshed (nodeA={HasA}, nodeB={HasB})",
|
|
||||||
siteId, pair.ChannelA is not null, pair.ChannelB is not null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,24 +166,29 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
/// <param name="siteId">Site identifier to remove.</param>
|
/// <param name="siteId">Site identifier to remove.</param>
|
||||||
public void RemoveSite(string siteId)
|
public void RemoveSite(string siteId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(siteId))
|
lock (_lifecycleGate)
|
||||||
{
|
{
|
||||||
return;
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
}
|
if (string.IsNullOrWhiteSpace(siteId))
|
||||||
|
|
||||||
SweepGraveyard();
|
|
||||||
|
|
||||||
if (_sites.TryRemove(siteId, out var pair))
|
|
||||||
{
|
|
||||||
lock (pair.Gate)
|
|
||||||
{
|
{
|
||||||
Park(pair.ChannelA);
|
return;
|
||||||
Park(pair.ChannelB);
|
|
||||||
pair.ChannelA = null;
|
|
||||||
pair.ChannelB = null;
|
|
||||||
}
|
}
|
||||||
_pskProvider.Invalidate(siteId);
|
|
||||||
_logger.LogInformation("gRPC channel pair for site {SiteId} removed", siteId);
|
SweepGraveyard();
|
||||||
|
|
||||||
|
if (_sites.TryRemove(siteId, out var pair))
|
||||||
|
{
|
||||||
|
lock (pair.Gate)
|
||||||
|
{
|
||||||
|
Park(pair.ChannelA);
|
||||||
|
Park(pair.ChannelB);
|
||||||
|
pair.ChannelA = null;
|
||||||
|
pair.ChannelB = null;
|
||||||
|
}
|
||||||
|
_pskProvider.Invalidate(siteId);
|
||||||
|
_logger.LogInformation("gRPC channel pair for site {SiteId} removed", siteId);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +228,8 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
// Recompute which node `primary` actually is (the current side may be null).
|
// Recompute which node `primary` actually is (the current side may be null).
|
||||||
primaryIsA = ReferenceEquals(primary, pair.ChannelA);
|
primaryIsA = ReferenceEquals(primary, pair.ChannelA);
|
||||||
secondary = primaryIsA ? pair.ChannelB : pair.ChannelA;
|
secondary = primaryIsA ? pair.ChannelB : pair.ChannelA;
|
||||||
|
_activeCalls.AddOrUpdate(primary, 1, (_, count) => count + 1);
|
||||||
|
if (secondary is not null) _activeCalls.AddOrUpdate(secondary, 1, (_, count) => count + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -242,6 +253,12 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
throw GrpcRetrySafety.OutcomeUnknown(retryError);
|
throw GrpcRetrySafety.OutcomeUnknown(retryError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_activeCalls.AddOrUpdate(primary, 0, (_, count) => count - 1);
|
||||||
|
if (secondary is not null) _activeCalls.AddOrUpdate(secondary, 0, (_, count) => count - 1);
|
||||||
|
if (_disposed) SweepGraveyard(force: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -360,13 +377,8 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
/// and the command simply failed.
|
/// and the command simply failed.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Why a timed park rather than reference counting.</b> A refcount would have to be taken
|
/// Active calls retain both selected channels until completion, including the retry channel.
|
||||||
/// and released around every <c>ExecuteAsync</c> call — bookkeeping on the hot path, plus a
|
/// The grace is only an idle-channel minimum; dynamic WaitForAttribute deadlines can exceed it.
|
||||||
/// quiescence handshake — to buy nothing a fixed window does not already give: the window
|
|
||||||
/// only has to outlast the longest command deadline. It also matches the precedent set by
|
|
||||||
/// <see cref="SiteStreamGrpcClientFactory"/>, which likewise keeps a superseded endpoint's
|
|
||||||
/// channel alive rather than disposing it under a concurrent session. Cost is a handful of
|
|
||||||
/// idle HTTP/2 channels for at most <see cref="ChannelDisposalGrace"/>.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="channel">The channel to park; null is ignored.</param>
|
/// <param name="channel">The channel to park; null is ignored.</param>
|
||||||
@@ -383,25 +395,27 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
/// path (<see cref="UpdateSite"/>/<see cref="RemoveSite"/>) and from the failback loop, so a
|
/// path (<see cref="UpdateSite"/>/<see cref="RemoveSite"/>) and from the failback loop, so a
|
||||||
/// park always drains even on a provider that never runs a call again.
|
/// park always drains even on a provider that never runs a call again.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="force">True to dispose every parked channel regardless of its window (shutdown, and tests).</param>
|
/// <param name="force">True to ignore idle grace (shutdown and tests); active calls always retain their channels.</param>
|
||||||
internal void SweepGraveyard(bool force = false)
|
internal void SweepGraveyard(bool force = false)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
lock (_lifecycleGate)
|
||||||
while (_graveyard.TryPeek(out var parked) && (force || now >= parked.DisposeAfterUtc))
|
|
||||||
{
|
{
|
||||||
if (!_graveyard.TryDequeue(out parked))
|
var now = DateTime.UtcNow;
|
||||||
|
var count = _graveyard.Count;
|
||||||
|
// Visit each parked channel once: an occupied channel must not starve later entries.
|
||||||
|
for (var i = 0; i < count && _graveyard.TryDequeue(out var parked); i++)
|
||||||
{
|
{
|
||||||
break;
|
if ((!force && now < parked.DisposeAfterUtc)
|
||||||
|
|| _activeCalls.GetValueOrDefault(parked.Channel) > 0)
|
||||||
|
{
|
||||||
|
_graveyard.Enqueue(parked);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_activeCalls.TryRemove(parked.Channel, out _);
|
||||||
|
try { parked.Channel.Dispose(); }
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Disposing a parked gRPC channel faulted"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
parked.Channel.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Disposing a parked gRPC channel faulted");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,39 +475,29 @@ public sealed class SitePairChannelProvider : IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
lock (_lifecycleGate)
|
||||||
{
|
{
|
||||||
return;
|
if (_disposed) return;
|
||||||
}
|
_disposed = true;
|
||||||
_disposed = true;
|
foreach (var pair in _sites.Values)
|
||||||
|
|
||||||
_shutdown.Cancel();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_failbackLoop?.Wait(TimeSpan.FromSeconds(2));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Best-effort loop drain on shutdown.
|
|
||||||
}
|
|
||||||
_shutdown.Dispose();
|
|
||||||
|
|
||||||
foreach (var pair in _sites.Values)
|
|
||||||
{
|
|
||||||
lock (pair.Gate)
|
|
||||||
{
|
{
|
||||||
pair.ChannelA?.Dispose();
|
lock (pair.Gate)
|
||||||
pair.ChannelB?.Dispose();
|
{
|
||||||
|
Park(pair.ChannelA);
|
||||||
|
Park(pair.ChannelB);
|
||||||
|
pair.ChannelA = null;
|
||||||
|
pair.ChannelB = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
_sites.Clear();
|
||||||
}
|
}
|
||||||
_sites.Clear();
|
_shutdown.Cancel();
|
||||||
|
try { _failbackLoop?.Wait(TimeSpan.FromSeconds(2)); }
|
||||||
// Process shutdown — nothing can still be in flight, so drain the graveyard regardless
|
catch { /* shutdown is best effort; active calls retain their channels */ }
|
||||||
// of the remaining grace on each entry.
|
_shutdown.Dispose();
|
||||||
SweepGraveyard(force: true);
|
SweepGraveyard(force: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Per-site mutable channel state, guarded by <see cref="Gate"/>.</summary>
|
|
||||||
private sealed class SitePair(string siteId)
|
private sealed class SitePair(string siteId)
|
||||||
{
|
{
|
||||||
public string SiteId { get; } = siteId;
|
public string SiteId { get; } = siteId;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
|||||||
ISitePskProvider? pskProvider,
|
ISitePskProvider? pskProvider,
|
||||||
string? siteIdentifier)
|
string? siteIdentifier)
|
||||||
{
|
{
|
||||||
|
ConnectedHeaderTimeout = options.ConnectedHeaderTimeout;
|
||||||
Endpoint = endpoint;
|
Endpoint = endpoint;
|
||||||
KeepAlivePingDelay = options.GrpcKeepAlivePingDelay;
|
KeepAlivePingDelay = options.GrpcKeepAlivePingDelay;
|
||||||
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout;
|
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout;
|
||||||
@@ -122,7 +123,12 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
|||||||
/// Unsubscribe and Dispose behavior without needing a real endpoint.
|
/// Unsubscribe and Dispose behavior without needing a real endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A <see cref="SiteStreamGrpcClient"/> with no channel or client, for testing only.</returns>
|
/// <returns>A <see cref="SiteStreamGrpcClient"/> with no channel or client, for testing only.</returns>
|
||||||
internal static SiteStreamGrpcClient CreateForTesting() => new();
|
internal static SiteStreamGrpcClient CreateForTesting(CommunicationOptions? options = null) => new(options ?? new CommunicationOptions());
|
||||||
|
|
||||||
|
private SiteStreamGrpcClient(CommunicationOptions options)
|
||||||
|
{
|
||||||
|
ConnectedHeaderTimeout = options.ConnectedHeaderTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a CancellationTokenSource for a correlation ID. Test-only.
|
/// Registers a CancellationTokenSource for a correlation ID. Test-only.
|
||||||
@@ -457,7 +463,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
|||||||
/// with its first message would otherwise hold the connected signal — and with it the
|
/// with its first message would otherwise hold the connected signal — and with it the
|
||||||
/// aggregator's re-seed — for as long as the site happens to be quiet.
|
/// aggregator's re-seed — for as long as the site happens to be quiet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static TimeSpan ConnectedHeaderTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
internal TimeSpan ConnectedHeaderTimeout { get; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Awaits the call's response headers, bounded by <see cref="ConnectedHeaderTimeout"/>.
|
/// Awaits the call's response headers, bounded by <see cref="ConnectedHeaderTimeout"/>.
|
||||||
@@ -475,7 +481,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
|||||||
/// timeout was added for is covered by the reconcile backstop.
|
/// timeout was added for is covered by the reconcile backstop.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<bool> AwaitHeadersAsync(
|
private async Task<bool> AwaitHeadersAsync(
|
||||||
AsyncServerStreamingCall<SiteStreamEvent> call, CancellationToken ct)
|
AsyncServerStreamingCall<SiteStreamEvent> call, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var headers = call.ResponseHeadersAsync;
|
var headers = call.ResponseHeadersAsync;
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
// true, never reset (the server is single-lifetime per host).
|
// true, never reset (the server is single-lifetime per host).
|
||||||
private volatile bool _shuttingDown;
|
private volatile bool _shuttingDown;
|
||||||
private long _actorCounter;
|
private long _actorCounter;
|
||||||
// Central-side ingest actor proxy. Set by the host
|
|
||||||
// after the cluster singleton starts. When null the
|
|
||||||
// IngestAuditEvents RPC replies with an empty IngestAck so sites can
|
|
||||||
// safely retry — wiring-incomplete is treated as transient, never fatal.
|
|
||||||
private IActorRef? _auditIngestActor;
|
|
||||||
// Ask timeout is 30 s. The ingest actor's repo
|
// Ask timeout is 30 s. The ingest actor's repo
|
||||||
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow
|
// calls are sub-100 ms in steady state; a generous timeout absorbs a slow
|
||||||
// MSSQL connection without surfacing as a gRPC failure on a healthy site.
|
// MSSQL connection without surfacing as a gRPC failure on a healthy site.
|
||||||
@@ -153,26 +148,12 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
_ready = true;
|
_ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hands the central-side <c>AuditLogIngestActor</c> proxy to the gRPC
|
|
||||||
/// server so the <see cref="IngestAuditEvents"/> RPC can route incoming
|
|
||||||
/// site batches. Mirrors the way
|
|
||||||
/// <c>CommunicationService.SetNotificationOutbox</c> takes the Notification
|
|
||||||
/// Outbox singleton proxy. The actor is supplied after the cluster
|
|
||||||
/// singleton starts.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="proxy">The audit log ingest actor proxy.</param>
|
|
||||||
public void SetAuditIngestActor(IActorRef proxy)
|
|
||||||
{
|
|
||||||
_auditIngestActor = proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hands the site-local <see cref="ISiteAuditQueue"/> (the same
|
/// Hands the site-local <see cref="ISiteAuditQueue"/> (the same
|
||||||
/// <c>SqliteAuditWriter</c> singleton that backs <see cref="IAuditWriter"/>
|
/// <c>SqliteAuditWriter</c> singleton that backs <see cref="IAuditWriter"/>
|
||||||
/// on the script thread) to the gRPC server so the
|
/// on the script thread) to the gRPC server so the
|
||||||
/// <see cref="PullAuditEvents"/> RPC can serve central's reconciliation
|
/// <see cref="PullAuditEvents"/> RPC can serve central's reconciliation
|
||||||
/// pulls. Mirrors <see cref="SetAuditIngestActor"/>: wired post-construction
|
/// pulls. Wired post-construction
|
||||||
/// because the queue and the gRPC server are both DI singletons brought up
|
/// because the queue and the gRPC server are both DI singletons brought up
|
||||||
/// in independent orders on site startup.
|
/// in independent orders on site startup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -535,111 +516,18 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Retained wire method; ingest is served only by CentralControlService.</summary>
|
||||||
public override async Task<IngestAck> IngestAuditEvents(
|
public override Task<IngestAck> IngestAuditEvents(AuditEventBatch request, ServerCallContext context)
|
||||||
AuditEventBatch request,
|
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
|
||||||
ServerCallContext context)
|
StatusCode.Unimplemented, "Use CentralControlService.IngestAuditEvents on central.")));
|
||||||
{
|
|
||||||
// Empty batch is a no-op; reply immediately so the client moves on.
|
|
||||||
if (request.Events.Count == 0)
|
|
||||||
{
|
|
||||||
return new IngestAck();
|
|
||||||
}
|
|
||||||
|
|
||||||
var actor = _auditIngestActor;
|
/// <summary>Retained wire method; combined ingest is served only by CentralControlService.</summary>
|
||||||
if (actor is null)
|
public override Task<IngestAck> IngestCachedTelemetry(CachedTelemetryBatch request, ServerCallContext context)
|
||||||
{
|
=> Task.FromException<IngestAck>(new RpcException(new GrpcStatus(
|
||||||
// Wiring incomplete (host startup race). Sites treat an empty
|
StatusCode.Unimplemented, "Use CentralControlService.IngestCachedTelemetry on central.")));
|
||||||
// ack as "nothing was acked, leave rows Pending, retry next
|
|
||||||
// drain" — exactly the right behaviour during host bring-up.
|
|
||||||
_logger.LogWarning(
|
|
||||||
"IngestAuditEvents received {Count} events before SetAuditIngestActor was called; returning empty ack.",
|
|
||||||
request.Events.Count);
|
|
||||||
return new IngestAck();
|
|
||||||
}
|
|
||||||
|
|
||||||
var entities = new List<AuditEvent>(request.Events.Count);
|
/// <summary>Maximum rows requested by one reconciliation pull, before the wire-byte budget.</summary>
|
||||||
foreach (var dto in request.Events)
|
public const int MaxReconciliationRowsPerPull = 2000;
|
||||||
{
|
|
||||||
entities.Add(AuditEventDtoMapper.FromDto(dto));
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd = new IngestAuditEventsCommand(entities);
|
|
||||||
IngestAuditEventsReply reply;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
reply = await actor.Ask<IngestAuditEventsReply>(
|
|
||||||
cmd, AuditIngestAskTimeout, context.CancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Audit ingest is best-effort; failing this RPC at the gRPC layer
|
|
||||||
// would surface as a transport error and force the site to retry
|
|
||||||
// (which it would do anyway). Logging + an empty ack keeps the
|
|
||||||
// semantics consistent with the "wiring incomplete" path above.
|
|
||||||
_logger.LogError(ex,
|
|
||||||
"AuditLogIngestActor Ask failed for batch of {Count} events; returning empty ack.",
|
|
||||||
request.Events.Count);
|
|
||||||
return new IngestAck();
|
|
||||||
}
|
|
||||||
|
|
||||||
var ack = new IngestAck();
|
|
||||||
foreach (var id in reply.AcceptedEventIds)
|
|
||||||
{
|
|
||||||
ack.AcceptedEventIds.Add(id.ToString());
|
|
||||||
}
|
|
||||||
return ack;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<IngestAck> IngestCachedTelemetry(
|
|
||||||
CachedTelemetryBatch request,
|
|
||||||
ServerCallContext context)
|
|
||||||
{
|
|
||||||
if (request.Packets.Count == 0)
|
|
||||||
{
|
|
||||||
return new IngestAck();
|
|
||||||
}
|
|
||||||
|
|
||||||
var actor = _auditIngestActor;
|
|
||||||
if (actor is null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"IngestCachedTelemetry received {Count} packets before SetAuditIngestActor was called; returning empty ack.",
|
|
||||||
request.Packets.Count);
|
|
||||||
return new IngestAck();
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries = new List<CachedTelemetryEntry>(request.Packets.Count);
|
|
||||||
foreach (var packet in request.Packets)
|
|
||||||
{
|
|
||||||
var auditEvent = AuditEventDtoMapper.FromDto(packet.AuditEvent);
|
|
||||||
var siteCall = SiteCallDtoMapper.FromDto(packet.Operational);
|
|
||||||
entries.Add(new CachedTelemetryEntry(auditEvent, siteCall));
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd = new IngestCachedTelemetryCommand(entries);
|
|
||||||
IngestCachedTelemetryReply reply;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
reply = await actor.Ask<IngestCachedTelemetryReply>(
|
|
||||||
cmd, AuditIngestAskTimeout, context.CancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex,
|
|
||||||
"AuditLogIngestActor Ask failed for combined telemetry batch of {Count} packets; returning empty ack.",
|
|
||||||
request.Packets.Count);
|
|
||||||
return new IngestAck();
|
|
||||||
}
|
|
||||||
|
|
||||||
var ack = new IngestAck();
|
|
||||||
foreach (var id in reply.AcceptedEventIds)
|
|
||||||
{
|
|
||||||
ack.AcceptedEventIds.Add(id.ToString());
|
|
||||||
}
|
|
||||||
return ack;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Flow Debugger PullTraceEvents: server-side row cap (design 2026-09-05 §5.4).</summary>
|
/// <summary>Flow Debugger PullTraceEvents: server-side row cap (design 2026-09-05 §5.4).</summary>
|
||||||
public const int MaxTraceEventsPerPull = 2000;
|
public const int MaxTraceEventsPerPull = 2000;
|
||||||
@@ -694,7 +582,8 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
IReadOnlyList<AuditEvent> events;
|
IReadOnlyList<AuditEvent> events;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
events = await queue.ReadByExecutionIdsAsync(ids, since, request.MaxEvents, context.CancellationToken);
|
events = await queue.ReadByExecutionIdsAsync(ids, since, request.MaxEvents, context.CancellationToken,
|
||||||
|
string.IsNullOrEmpty(request.AfterId) ? null : request.AfterId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -725,13 +614,13 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
return new PullAuditEventsResponse();
|
return new PullAuditEventsResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.BatchSize <= 0)
|
if (request.BatchSize <= 0 || request.BatchSize > MaxReconciliationRowsPerPull)
|
||||||
{
|
{
|
||||||
// Mirrors the SubscribeInstance guard: reject malformed requests
|
// Mirrors the SubscribeInstance guard: reject malformed requests
|
||||||
// cleanly with InvalidArgument so the caller doesn't see a generic
|
// cleanly with InvalidArgument so the caller doesn't see a generic
|
||||||
// RpcException from the underlying SQLite parameter validation.
|
// RpcException from the underlying SQLite parameter validation.
|
||||||
throw new RpcException(new GrpcStatus(
|
throw new RpcException(new GrpcStatus(
|
||||||
StatusCode.InvalidArgument, "batch_size must be > 0"));
|
StatusCode.InvalidArgument, $"batch_size must be 1..{MaxReconciliationRowsPerPull}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// sinceUtc defaults to DateTime.MinValue when the wrapper is absent —
|
// sinceUtc defaults to DateTime.MinValue when the wrapper is absent —
|
||||||
@@ -818,13 +707,13 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
|||||||
return new PullSiteCallsResponse();
|
return new PullSiteCallsResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.BatchSize <= 0)
|
if (request.BatchSize <= 0 || request.BatchSize > MaxReconciliationRowsPerPull)
|
||||||
{
|
{
|
||||||
// Mirrors PullAuditEvents: reject malformed requests cleanly with
|
// Mirrors PullAuditEvents: reject malformed requests cleanly with
|
||||||
// InvalidArgument so the caller doesn't see a generic RpcException
|
// InvalidArgument so the caller doesn't see a generic RpcException
|
||||||
// from the underlying SQLite parameter validation.
|
// from the underlying SQLite parameter validation.
|
||||||
throw new RpcException(new GrpcStatus(
|
throw new RpcException(new GrpcStatus(
|
||||||
StatusCode.InvalidArgument, "batch_size must be > 0"));
|
StatusCode.InvalidArgument, $"batch_size must be 1..{MaxReconciliationRowsPerPull}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// since_utc defaults to DateTime.MinValue when the wrapper is absent —
|
// since_utc defaults to DateTime.MinValue when the wrapper is absent —
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ message PullAuditEventsResponse {
|
|||||||
// OccurredAtUtc >= since_utc, ordered (OccurredAtUtc, EventId), at most max_events (server
|
// OccurredAtUtc >= since_utc, ordered (OccurredAtUtc, EventId), at most max_events (server
|
||||||
// caps at 2000). Ids capped at 64 per call.
|
// caps at 2000). Ids capped at 64 per call.
|
||||||
message PullTraceEventsRequest {
|
message PullTraceEventsRequest {
|
||||||
|
string after_id = 4; // Exclusive EventId at since_utc; empty preserves the inclusive legacy read.
|
||||||
repeated string execution_ids = 1; // "D" GUID form
|
repeated string execution_ids = 1; // "D" GUID form
|
||||||
google.protobuf.Timestamp since_utc = 2;
|
google.protobuf.Timestamp since_utc = 2;
|
||||||
int32 max_events = 3;
|
int32 max_events = 3;
|
||||||
|
|||||||
@@ -90,38 +90,38 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
"KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmJhdGNoX3NpemUY",
|
"KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmJhdGNoX3NpemUY",
|
||||||
"AiABKAUSEAoIYWZ0ZXJfaWQYAyABKAkiXAoXUHVsbEF1ZGl0RXZlbnRzUmVz",
|
"AiABKAUSEAoIYWZ0ZXJfaWQYAyABKAkiXAoXUHVsbEF1ZGl0RXZlbnRzUmVz",
|
||||||
"cG9uc2USKQoGZXZlbnRzGAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50",
|
"cG9uc2USKQoGZXZlbnRzGAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50",
|
||||||
"RHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIgASgIInIKFlB1bGxUcmFjZUV2ZW50",
|
"RHRvEhYKDm1vcmVfYXZhaWxhYmxlGAIgASgIIoQBChZQdWxsVHJhY2VFdmVu",
|
||||||
"c1JlcXVlc3QSFQoNZXhlY3V0aW9uX2lkcxgBIAMoCRItCglzaW5jZV91dGMY",
|
"dHNSZXF1ZXN0EhAKCGFmdGVyX2lkGAQgASgJEhUKDWV4ZWN1dGlvbl9pZHMY",
|
||||||
"AiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCm1heF9ldmVu",
|
"ASADKAkSLQoJc2luY2VfdXRjGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp",
|
||||||
"dHMYAyABKAUiRAoXUHVsbFRyYWNlRXZlbnRzUmVzcG9uc2USKQoGZXZlbnRz",
|
"bWVzdGFtcBISCgptYXhfZXZlbnRzGAMgASgFIkQKF1B1bGxUcmFjZUV2ZW50",
|
||||||
"GAEgAygLMhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvImsKFFB1bGxTaXRl",
|
"c1Jlc3BvbnNlEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRF",
|
||||||
"Q2FsbHNSZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90",
|
"dmVudER0byJrChRQdWxsU2l0ZUNhbGxzUmVxdWVzdBItCglzaW5jZV91dGMY",
|
||||||
"b2J1Zi5UaW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBRIQCghhZnRlcl9p",
|
"ASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmJhdGNoX3Np",
|
||||||
"ZBgDIAEoCSJpChVQdWxsU2l0ZUNhbGxzUmVzcG9uc2USOAoMb3BlcmF0aW9u",
|
"emUYAiABKAUSEAoIYWZ0ZXJfaWQYAyABKAkiaQoVUHVsbFNpdGVDYWxsc1Jl",
|
||||||
"YWxzGAEgAygLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRv",
|
"c3BvbnNlEjgKDG9wZXJhdGlvbmFscxgBIAMoCzIiLnNpdGVzdHJlYW0uU2l0",
|
||||||
"EhYKDm1vcmVfYXZhaWxhYmxlGAIgASgIKlwKB1F1YWxpdHkSFwoTUVVBTElU",
|
"ZUNhbGxPcGVyYXRpb25hbER0bxIWCg5tb3JlX2F2YWlsYWJsZRgCIAEoCCpc",
|
||||||
"WV9VTlNQRUNJRklFRBAAEhAKDFFVQUxJVFlfR09PRBABEhUKEVFVQUxJVFlf",
|
"CgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQABIQCgxRVUFMSVRZ",
|
||||||
"VU5DRVJUQUlOEAISDwoLUVVBTElUWV9CQUQQAypdCg5BbGFybVN0YXRlRW51",
|
"X0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8KC1FVQUxJVFlfQkFE",
|
||||||
"bRIbChdBTEFSTV9TVEFURV9VTlNQRUNJRklFRBAAEhYKEkFMQVJNX1NUQVRF",
|
"EAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RBVEVfVU5TUEVDSUZJ",
|
||||||
"X05PUk1BTBABEhYKEkFMQVJNX1NUQVRFX0FDVElWRRACKoUBCg5BbGFybUxl",
|
"RUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJBTEFSTV9TVEFURV9B",
|
||||||
"dmVsRW51bRIUChBBTEFSTV9MRVZFTF9OT05FEAASEwoPQUxBUk1fTEVWRUxf",
|
"Q1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxBUk1fTEVWRUxfTk9O",
|
||||||
"TE9XEAESFwoTQUxBUk1fTEVWRUxfTE9XX0xPVxACEhQKEEFMQVJNX0xFVkVM",
|
"RRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJNX0xFVkVMX0xPV19M",
|
||||||
"X0hJR0gQAxIZChVBTEFSTV9MRVZFTF9ISUdIX0hJR0gQBDLiBAoRU2l0ZVN0",
|
"T1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxBUk1fTEVWRUxfSElH",
|
||||||
"cmVhbVNlcnZpY2USVQoRU3Vic2NyaWJlSW5zdGFuY2USIS5zaXRlc3RyZWFt",
|
"SF9ISUdIEAQy4gQKEVNpdGVTdHJlYW1TZXJ2aWNlElUKEVN1YnNjcmliZUlu",
|
||||||
"Lkluc3RhbmNlU3RyZWFtUmVxdWVzdBobLnNpdGVzdHJlYW0uU2l0ZVN0cmVh",
|
"c3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVhbVJlcXVlc3QaGy5z",
|
||||||
"bUV2ZW50MAESTQoNU3Vic2NyaWJlU2l0ZRIdLnNpdGVzdHJlYW0uU2l0ZVN0",
|
"aXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEk0KDVN1YnNjcmliZVNpdGUS",
|
||||||
"cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcK",
|
"HS5zaXRlc3RyZWFtLlNpdGVTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T",
|
||||||
"EUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0",
|
"aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz",
|
||||||
"Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxl",
|
"dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS",
|
||||||
"bWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0",
|
"UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU",
|
||||||
"ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0",
|
"ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB",
|
||||||
"cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxs",
|
"dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz",
|
||||||
"QXVkaXRFdmVudHNSZXNwb25zZRJaCg9QdWxsVHJhY2VFdmVudHMSIi5zaXRl",
|
"dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2USWgoPUHVs",
|
||||||
"c3RyZWFtLlB1bGxUcmFjZUV2ZW50c1JlcXVlc3QaIy5zaXRlc3RyZWFtLlB1",
|
"bFRyYWNlRXZlbnRzEiIuc2l0ZXN0cmVhbS5QdWxsVHJhY2VFdmVudHNSZXF1",
|
||||||
"bGxUcmFjZUV2ZW50c1Jlc3BvbnNlElQKDVB1bGxTaXRlQ2FsbHMSIC5zaXRl",
|
"ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsVHJhY2VFdmVudHNSZXNwb25zZRJUCg1Q",
|
||||||
"c3RyZWFtLlB1bGxTaXRlQ2FsbHNSZXF1ZXN0GiEuc2l0ZXN0cmVhbS5QdWxs",
|
"dWxsU2l0ZUNhbGxzEiAuc2l0ZXN0cmVhbS5QdWxsU2l0ZUNhbGxzUmVxdWVz",
|
||||||
"U2l0ZUNhbGxzUmVzcG9uc2VCK6oCKFpCLk1PTS5XVy5TY2FkYUJyaWRnZS5D",
|
"dBohLnNpdGVzdHJlYW0uUHVsbFNpdGVDYWxsc1Jlc3BvbnNlQiuqAihaQi5N",
|
||||||
"b21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
|
"T00uV1cuU2NhZGFCcmlkZ2UuQ29tbXVuaWNhdGlvbi5HcnBjYgZwcm90bzM="));
|
||||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
|
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
|
||||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.Quality), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateEnum), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.Quality), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmStateEnum), typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||||
@@ -139,7 +139,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch.Parser, new[]{ "Packets" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch.Parser, new[]{ "Packets" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest.Parser, new[]{ "SinceUtc", "BatchSize", "AfterId" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsRequest.Parser, new[]{ "SinceUtc", "BatchSize", "AfterId" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse.Parser, new[]{ "Events", "MoreAvailable" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullAuditEventsResponse.Parser, new[]{ "Events", "MoreAvailable" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsRequest.Parser, new[]{ "ExecutionIds", "SinceUtc", "MaxEvents" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsRequest.Parser, new[]{ "AfterId", "ExecutionIds", "SinceUtc", "MaxEvents" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsResponse.Parser, new[]{ "Events" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullTraceEventsResponse.Parser, new[]{ "Events" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest.Parser, new[]{ "SinceUtc", "BatchSize", "AfterId" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest.Parser, new[]{ "SinceUtc", "BatchSize", "AfterId" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse.Parser, new[]{ "Operationals", "MoreAvailable" }, null, null, null, null)
|
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse.Parser, new[]{ "Operationals", "MoreAvailable" }, null, null, null, null)
|
||||||
@@ -1418,8 +1418,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
private string sourceExecutionId_ = "";
|
private string sourceExecutionId_ = "";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Flow Debugger (design 2026-09-05 §5.3/§5.4): the writing execution's id ("D" GUID
|
/// Flow Debugger (design 2026-09-05 §5.3/§5.4): the writing execution's id ("D" GUID
|
||||||
/// form; empty represents null) and the trace flag, so the debug stream can hand the
|
/// form; empty represents null) and the trace flag. Additive; an older site never sets them.
|
||||||
/// debugger the spawned run's parent. Additive; an older site never sets them.
|
/// RESERVED — populated by the site, consumed by nothing (arch-review F12): the value-change
|
||||||
|
/// entry polls the site's PullTraceEvents RPC instead of subscribing to the debug stream, so
|
||||||
|
/// these two fields are kept for a future debug-stream path. Do not reuse the field numbers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
@@ -5942,6 +5944,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
public PullTraceEventsRequest(PullTraceEventsRequest other) : this() {
|
public PullTraceEventsRequest(PullTraceEventsRequest other) : this() {
|
||||||
|
afterId_ = other.afterId_;
|
||||||
executionIds_ = other.executionIds_.Clone();
|
executionIds_ = other.executionIds_.Clone();
|
||||||
sinceUtc_ = other.sinceUtc_ != null ? other.sinceUtc_.Clone() : null;
|
sinceUtc_ = other.sinceUtc_ != null ? other.sinceUtc_.Clone() : null;
|
||||||
maxEvents_ = other.maxEvents_;
|
maxEvents_ = other.maxEvents_;
|
||||||
@@ -5954,6 +5957,21 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
return new PullTraceEventsRequest(this);
|
return new PullTraceEventsRequest(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Field number for the "after_id" field.</summary>
|
||||||
|
public const int AfterIdFieldNumber = 4;
|
||||||
|
private string afterId_ = "";
|
||||||
|
/// <summary>
|
||||||
|
/// Exclusive EventId at since_utc; empty preserves the inclusive legacy read.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
|
public string AfterId {
|
||||||
|
get { return afterId_; }
|
||||||
|
set {
|
||||||
|
afterId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Field number for the "execution_ids" field.</summary>
|
/// <summary>Field number for the "execution_ids" field.</summary>
|
||||||
public const int ExecutionIdsFieldNumber = 1;
|
public const int ExecutionIdsFieldNumber = 1;
|
||||||
private static readonly pb::FieldCodec<string> _repeated_executionIds_codec
|
private static readonly pb::FieldCodec<string> _repeated_executionIds_codec
|
||||||
@@ -6007,6 +6025,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
if (ReferenceEquals(other, this)) {
|
if (ReferenceEquals(other, this)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (AfterId != other.AfterId) return false;
|
||||||
if(!executionIds_.Equals(other.executionIds_)) return false;
|
if(!executionIds_.Equals(other.executionIds_)) return false;
|
||||||
if (!object.Equals(SinceUtc, other.SinceUtc)) return false;
|
if (!object.Equals(SinceUtc, other.SinceUtc)) return false;
|
||||||
if (MaxEvents != other.MaxEvents) return false;
|
if (MaxEvents != other.MaxEvents) return false;
|
||||||
@@ -6017,6 +6036,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
public override int GetHashCode() {
|
public override int GetHashCode() {
|
||||||
int hash = 1;
|
int hash = 1;
|
||||||
|
if (AfterId.Length != 0) hash ^= AfterId.GetHashCode();
|
||||||
hash ^= executionIds_.GetHashCode();
|
hash ^= executionIds_.GetHashCode();
|
||||||
if (sinceUtc_ != null) hash ^= SinceUtc.GetHashCode();
|
if (sinceUtc_ != null) hash ^= SinceUtc.GetHashCode();
|
||||||
if (MaxEvents != 0) hash ^= MaxEvents.GetHashCode();
|
if (MaxEvents != 0) hash ^= MaxEvents.GetHashCode();
|
||||||
@@ -6047,6 +6067,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
output.WriteRawTag(24);
|
output.WriteRawTag(24);
|
||||||
output.WriteInt32(MaxEvents);
|
output.WriteInt32(MaxEvents);
|
||||||
}
|
}
|
||||||
|
if (AfterId.Length != 0) {
|
||||||
|
output.WriteRawTag(34);
|
||||||
|
output.WriteString(AfterId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(output);
|
_unknownFields.WriteTo(output);
|
||||||
}
|
}
|
||||||
@@ -6066,6 +6090,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
output.WriteRawTag(24);
|
output.WriteRawTag(24);
|
||||||
output.WriteInt32(MaxEvents);
|
output.WriteInt32(MaxEvents);
|
||||||
}
|
}
|
||||||
|
if (AfterId.Length != 0) {
|
||||||
|
output.WriteRawTag(34);
|
||||||
|
output.WriteString(AfterId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(ref output);
|
_unknownFields.WriteTo(ref output);
|
||||||
}
|
}
|
||||||
@@ -6076,6 +6104,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
public int CalculateSize() {
|
public int CalculateSize() {
|
||||||
int size = 0;
|
int size = 0;
|
||||||
|
if (AfterId.Length != 0) {
|
||||||
|
size += 1 + pb::CodedOutputStream.ComputeStringSize(AfterId);
|
||||||
|
}
|
||||||
size += executionIds_.CalculateSize(_repeated_executionIds_codec);
|
size += executionIds_.CalculateSize(_repeated_executionIds_codec);
|
||||||
if (sinceUtc_ != null) {
|
if (sinceUtc_ != null) {
|
||||||
size += 1 + pb::CodedOutputStream.ComputeMessageSize(SinceUtc);
|
size += 1 + pb::CodedOutputStream.ComputeMessageSize(SinceUtc);
|
||||||
@@ -6095,6 +6126,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
if (other == null) {
|
if (other == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (other.AfterId.Length != 0) {
|
||||||
|
AfterId = other.AfterId;
|
||||||
|
}
|
||||||
executionIds_.Add(other.executionIds_);
|
executionIds_.Add(other.executionIds_);
|
||||||
if (other.sinceUtc_ != null) {
|
if (other.sinceUtc_ != null) {
|
||||||
if (sinceUtc_ == null) {
|
if (sinceUtc_ == null) {
|
||||||
@@ -6139,6 +6173,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
MaxEvents = input.ReadInt32();
|
MaxEvents = input.ReadInt32();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 34: {
|
||||||
|
AfterId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -6173,6 +6211,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc {
|
|||||||
MaxEvents = input.ReadInt32();
|
MaxEvents = input.ReadInt32();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 34: {
|
||||||
|
AfterId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ VALUES
|
|||||||
DateTime threshold,
|
DateTime threshold,
|
||||||
int batchSize,
|
int batchSize,
|
||||||
TimeSpan? commandTimeout = null,
|
TimeSpan? commandTimeout = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default, int maxRows = 50_000)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(channel))
|
if (string.IsNullOrWhiteSpace(channel))
|
||||||
{
|
{
|
||||||
@@ -661,6 +661,9 @@ VALUES
|
|||||||
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
|
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (maxRows <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxRows), maxRows, "Row budget must be > 0.");
|
||||||
|
|
||||||
var thresholdUtc = DateTime.SpecifyKind(threshold.ToUniversalTime(), DateTimeKind.Utc);
|
var thresholdUtc = DateTime.SpecifyKind(threshold.ToUniversalTime(), DateTimeKind.Utc);
|
||||||
|
|
||||||
// Maintenance timeout in whole seconds; null leaves the provider default (arch-review 04 S2).
|
// Maintenance timeout in whole seconds; null leaves the provider default (arch-review 04 S2).
|
||||||
@@ -675,7 +678,7 @@ VALUES
|
|||||||
// targeting AuditLog still trips the guard.
|
// targeting AuditLog still trips the guard.
|
||||||
//
|
//
|
||||||
// Bounded + idempotent: DELETE TOP (@batch) caps the log/lock footprint per
|
// Bounded + idempotent: DELETE TOP (@batch) caps the log/lock footprint per
|
||||||
// statement; the loop repeats until a batch deletes zero rows, so re-running
|
// statement; the loop stops at its row budget or an empty batch, so re-running
|
||||||
// after a crash mid-loop simply resumes. Category is the canonical
|
// after a crash mid-loop simply resumes. Category is the canonical
|
||||||
// channel-name column (e.g. 'ApiOutbound'); Action holds "{channel}.{kind}" so
|
// channel-name column (e.g. 'ApiOutbound'); Action holds "{channel}.{kind}" so
|
||||||
// it is NOT the right column to match a bare channel name against.
|
// it is NOT the right column to match a bare channel name against.
|
||||||
@@ -698,7 +701,7 @@ VALUES
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (true)
|
while (totalDeleted < maxRows)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -711,7 +714,7 @@ VALUES
|
|||||||
|
|
||||||
var pBatch = cmd.CreateParameter();
|
var pBatch = cmd.CreateParameter();
|
||||||
pBatch.ParameterName = "@batch";
|
pBatch.ParameterName = "@batch";
|
||||||
pBatch.Value = batchSize;
|
pBatch.Value = (int)Math.Min(batchSize, maxRows - totalDeleted);
|
||||||
cmd.Parameters.Add(pBatch);
|
cmd.Parameters.Add(pBatch);
|
||||||
|
|
||||||
var pChannel = cmd.CreateParameter();
|
var pChannel = cmd.CreateParameter();
|
||||||
|
|||||||
+12
@@ -456,6 +456,18 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<Instance>> GetTopologyInstancesAsync(int siteId, int? areaId, int skip, int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(skip);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(take, 1);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThan(take, 50);
|
||||||
|
return await _context.Instances.AsNoTracking()
|
||||||
|
.Where(i => i.SiteId == siteId && i.AreaId == areaId)
|
||||||
|
.OrderBy(i => i.UniqueName).ThenBy(i => i.Id)
|
||||||
|
.Skip(skip).Take(take).ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -675,9 +675,16 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
List<DataShard> touched,
|
List<DataShard> touched,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
foreach (var (index, nodeId, startNode) in resolved)
|
// Plan once for this chunk while the apply gate keeps shard occupancy stable.
|
||||||
|
var placement = MonitoredItemShardPlanner.Plan(
|
||||||
|
_dataShards.Select(s => s.ItemCount).ToArray(), ItemsPerShard, resolved.Count);
|
||||||
|
for (var itemIndex = 0; itemIndex < resolved.Count; itemIndex++)
|
||||||
{
|
{
|
||||||
var shard = await PlaceOnDataShardAsync(cancellationToken);
|
var (index, nodeId, startNode) = resolved[itemIndex];
|
||||||
|
var shardIndex = placement[itemIndex];
|
||||||
|
var shard = shardIndex < _dataShards.Count
|
||||||
|
? _dataShards[shardIndex]
|
||||||
|
: await CreateDataShardAsync(cancellationToken);
|
||||||
var handle = Guid.NewGuid().ToString();
|
var handle = Guid.NewGuid().ToString();
|
||||||
var monitoredItem = new MonitoredItem(shard.Subscription.DefaultItem)
|
var monitoredItem = new MonitoredItem(shard.Subscription.DefaultItem)
|
||||||
{
|
{
|
||||||
@@ -760,20 +767,6 @@ public class RealOpcUaClient : IOpcUaClient
|
|||||||
created.Count);
|
created.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the shard this item belongs on per
|
|
||||||
/// <see cref="MonitoredItemShardPlanner"/> — first shard with free capacity, else a
|
|
||||||
/// freshly created one. Callers must hold <see cref="_applyGate"/>.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<DataShard> PlaceOnDataShardAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var index = MonitoredItemShardPlanner.Plan(
|
|
||||||
_dataShards.Select(s => s.ItemCount).ToList(), ItemsPerShard, 1)[0];
|
|
||||||
return index < _dataShards.Count
|
|
||||||
? _dataShards[index]
|
|
||||||
: await CreateDataShardAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drops a monitored item that failed to create from the shard it was placed on.
|
/// Drops a monitored item that failed to create from the shard it was placed on.
|
||||||
/// Callers must hold <see cref="_applyGate"/>. No ApplyChanges is issued: the server
|
/// Callers must hold <see cref="_applyGate"/>. No ApplyChanges is issued: the server
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||||
|
|
||||||
|
/// <summary>Validates the MxGateway client identity before connections start.</summary>
|
||||||
|
public sealed class MxGatewayGlobalOptionsValidator : IValidateOptions<MxGatewayGlobalOptions>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValidateOptionsResult Validate(string? name, MxGatewayGlobalOptions options)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.ClientNamePrefix))
|
||||||
|
return ValidateOptionsResult.Fail("ScadaBridge:MxGateway:ClientNamePrefix must not be blank.");
|
||||||
|
return ValidateOptionsResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
||||||
|
|
||||||
|
/// <summary>Validates the OPC UA application identity before connections start.</summary>
|
||||||
|
public sealed class OpcUaGlobalOptionsValidator : IValidateOptions<OpcUaGlobalOptions>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValidateOptionsResult Validate(string? name, OpcUaGlobalOptions options)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.ApplicationName))
|
||||||
|
return ValidateOptionsResult.Fail("ScadaBridge:OpcUa:ApplicationName must not be blank.");
|
||||||
|
return ValidateOptionsResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,16 +24,18 @@ public static class ServiceCollectionExtensions
|
|||||||
services.TryAddEnumerable(
|
services.TryAddEnumerable(
|
||||||
ServiceDescriptor.Singleton<IValidateOptions<DataConnectionOptions>, DataConnectionOptionsValidator>());
|
ServiceDescriptor.Singleton<IValidateOptions<DataConnectionOptions>, DataConnectionOptionsValidator>());
|
||||||
|
|
||||||
// OpcUaGlobalOptions / MxGatewayGlobalOptions carry only string identity/path
|
// Validate identities before the first connection attempts to register with a server.
|
||||||
// fields (no numeric or duration knobs), so there is nothing to range-validate
|
|
||||||
// — no dedicated validator is registered for them. Canonical sections are
|
|
||||||
// ScadaBridge:OpcUa / ScadaBridge:MxGateway (NF8): the bare "OpcUa"/"MxGateway"
|
|
||||||
// sections exist in no appsettings, so binding them read nothing.
|
|
||||||
services.AddOptions<OpcUaGlobalOptions>()
|
services.AddOptions<OpcUaGlobalOptions>()
|
||||||
.BindConfiguration("ScadaBridge:OpcUa");
|
.BindConfiguration("ScadaBridge:OpcUa")
|
||||||
|
.ValidateOnStart();
|
||||||
|
services.TryAddEnumerable(
|
||||||
|
ServiceDescriptor.Singleton<IValidateOptions<OpcUaGlobalOptions>, OpcUaGlobalOptionsValidator>());
|
||||||
|
|
||||||
services.AddOptions<MxGatewayGlobalOptions>()
|
services.AddOptions<MxGatewayGlobalOptions>()
|
||||||
.BindConfiguration("ScadaBridge:MxGateway");
|
.BindConfiguration("ScadaBridge:MxGateway")
|
||||||
|
.ValidateOnStart();
|
||||||
|
services.TryAddEnumerable(
|
||||||
|
ServiceDescriptor.Singleton<IValidateOptions<MxGatewayGlobalOptions>, MxGatewayGlobalOptionsValidator>());
|
||||||
|
|
||||||
// Register the factory for protocol extensibility
|
// Register the factory for protocol extensibility
|
||||||
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
|
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
|
||||||
|
|||||||
@@ -10,8 +10,19 @@ internal static class ConfigLoader
|
|||||||
/// <summary>Deserialize the <c>appsettings.json</c> text via the source-gen context.</summary>
|
/// <summary>Deserialize the <c>appsettings.json</c> text via the source-gen context.</summary>
|
||||||
/// <param name="jsonText">The raw JSON text of <c>appsettings.json</c>.</param>
|
/// <param name="jsonText">The raw JSON text of <c>appsettings.json</c>.</param>
|
||||||
/// <returns>The deserialized <see cref="NotifierConfig"/>, or a default instance if deserialization yields <c>null</c>.</returns>
|
/// <returns>The deserialized <see cref="NotifierConfig"/>, or a default instance if deserialization yields <c>null</c>.</returns>
|
||||||
public static NotifierConfig Load(string jsonText) =>
|
public static NotifierConfig Load(string jsonText)
|
||||||
JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig) ?? new NotifierConfig();
|
{
|
||||||
|
var config = JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig) ?? new NotifierConfig();
|
||||||
|
var urls = SplitBaseUrls(config.ScadaBridge.BaseUrls);
|
||||||
|
for (var i = 0; i < urls.Length; i++)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(urls[i], UriKind.Absolute, out var uri)
|
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||||
|
|| string.IsNullOrWhiteSpace(uri.Host))
|
||||||
|
throw new InvalidOperationException($"ScadaBridge:BaseUrls entry {i + 1} must be an absolute HTTP(S) URI.");
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Read and parse the <c>appsettings.json</c> sitting next to the executable.</summary>
|
/// <summary>Read and parse the <c>appsettings.json</c> sitting next to the executable.</summary>
|
||||||
/// <returns>The deserialized <see cref="NotifierConfig"/>.</returns>
|
/// <returns>The deserialized <see cref="NotifierConfig"/>.</returns>
|
||||||
|
|||||||
@@ -561,18 +561,10 @@ akka {{
|
|||||||
// 30 s timeout, so it could only add a hop and latency to every audit batch.
|
// 30 s timeout, so it could only add a hop and latency to every audit batch.
|
||||||
centralControlGrpc?.SetAuditIngestActor(auditIngest.Proxy);
|
centralControlGrpc?.SetAuditIngestActor(auditIngest.Proxy);
|
||||||
|
|
||||||
// Hand the proxy to the SiteStreamGrpcServer (if registered on this node)
|
|
||||||
// so the IngestAuditEvents RPC routes incoming site batches to the singleton.
|
|
||||||
// The gRPC server is currently only registered on Site nodes; on a central
|
|
||||||
// node this resolves to null and the wiring is a no-op unless a future
|
|
||||||
// central-hosted gRPC server (or a real site→central client) is added.
|
|
||||||
var grpcServer = _serviceProvider.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
|
|
||||||
grpcServer?.SetAuditIngestActor(auditIngest.Proxy);
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"AuditLogIngestActor singleton created (control-plane bound: {ControlBound}, "
|
"AuditLogIngestActor singleton created (control-plane bound: {ControlBound})",
|
||||||
+ "site-stream server bound: {GrpcBound})",
|
centralControlGrpc is not null);
|
||||||
centralControlGrpc is not null,
|
|
||||||
grpcServer is not null);
|
|
||||||
|
|
||||||
// Subscribe the per-site stalled
|
// Subscribe the per-site stalled
|
||||||
// telemetry tracker to the actor system EventStream NOW that the
|
// telemetry tracker to the actor system EventStream NOW that the
|
||||||
|
|||||||
@@ -121,8 +121,10 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
var cluster = Akka.Cluster.Cluster.Get(_system());
|
var cluster = Akka.Cluster.Cluster.Get(_system());
|
||||||
var addresses = order.Select(Address.Parse).ToArray();
|
var addresses = order.Select(Address.Parse).ToArray();
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
cluster.JoinSeedNodes(addresses);
|
cluster.JoinSeedNodes(addresses);
|
||||||
|
|
||||||
// Peer-first is a one-way commitment: JoinSeedNodeProcess never self-forms. If the founder
|
// Peer-first is a one-way commitment: JoinSeedNodeProcess never self-forms. If the founder
|
||||||
@@ -184,7 +186,7 @@ public sealed class ClusterBootstrapCoordinator : IHostedService
|
|||||||
if (await TryConnectAsync(host, port, ct).ConfigureAwait(false)) return true;
|
if (await TryConnectAsync(host, port, ct).ConfigureAwait(false)) return true;
|
||||||
if (sw.Elapsed >= window) return false;
|
if (sw.Elapsed >= window) return false;
|
||||||
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
||||||
catch (OperationCanceledException) { return false; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Health;
|
|||||||
/// whole check stays cheap and readiness polling stays fast.
|
/// whole check stays cheap and readiness polling stays fast.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Required-always vs if-applicable.</b> All five central singleton proxies are
|
/// <b>Required versus best-effort.</b> Seven central singleton proxies are created
|
||||||
/// created unconditionally on a central node (there is no feature/config gate around
|
/// unconditionally. The five listed in <see cref="RequiredSingletonProxyNames"/>
|
||||||
/// any of them), so all five are treated as required-always here. If a future
|
/// are required for readiness. The KPI-history recorder and pending-deployment purge
|
||||||
/// singleton is created behind a feature flag, it should NOT be added to
|
/// are deliberately excluded: they provide best-effort observability and hygiene,
|
||||||
/// <see cref="RequiredSingletonProxyNames"/> — "if applicable" means skip when its
|
/// respectively, and their availability is not a correctness prerequisite. Future
|
||||||
/// feature is off.
|
/// additions must be classified by readiness impact, not just unconditional creation;
|
||||||
|
/// a feature-gated singleton must also be skipped when its feature is disabled.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Failover flakiness.</b> During a brief singleton handover the singleton may be
|
/// <b>Failover flakiness.</b> During a brief singleton handover the singleton may be
|
||||||
@@ -60,7 +61,7 @@ public sealed class RequiredSingletonsHealthCheck : IHealthCheck
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Local actor names (under <c>/user</c>) of the <c>ClusterSingletonProxy</c>
|
/// Local actor names (under <c>/user</c>) of the <c>ClusterSingletonProxy</c>
|
||||||
/// actors for the singletons that must always be running on a central node.
|
/// actors for the singletons that must always be running on a central node.
|
||||||
/// Matches the unconditional proxy registrations in
|
/// Selects the five required services from the seven unconditional registrations in
|
||||||
/// <c>AkkaHostedService.RegisterCentralActors</c>.
|
/// <c>AkkaHostedService.RegisterCentralActors</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly IReadOnlyList<string> RequiredSingletonProxyNames = new[]
|
public static readonly IReadOnlyList<string> RequiredSingletonProxyNames = new[]
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ try
|
|||||||
|
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
{
|
{
|
||||||
|
options.Limits.Http2.KeepAlivePingDelay = SiteKestrelSetup.Http2KeepAlivePingDelay;
|
||||||
|
options.Limits.Http2.KeepAlivePingTimeout = SiteKestrelSetup.Http2KeepAlivePingTimeout;
|
||||||
// The HTTP/1.1 (+ HTTP/2) surface from the configured URLs, re-declared so it
|
// The HTTP/1.1 (+ HTTP/2) surface from the configured URLs, re-declared so it
|
||||||
// survives the explicit-endpoints switch above.
|
// survives the explicit-endpoints switch above.
|
||||||
foreach (var httpPort in httpPorts)
|
foreach (var httpPort in httpPorts)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
|||||||
/// active (a transient during failover) bounces at most one hop, never ping-pongs.</para>
|
/// active (a transient during failover) bounces at most one hop, never ping-pongs.</para>
|
||||||
///
|
///
|
||||||
/// <para><b>Fidelity.</b> Method, path, query string, body and headers are relayed
|
/// <para><b>Fidelity.</b> Method, path, query string, body and headers are relayed
|
||||||
/// verbatim (hop-by-hop headers and <c>Host</c> excepted), so the active node
|
/// verbatim (hop-by-hop headers, <c>Host</c> and <c>Accept-Encoding</c> excepted), so the active node
|
||||||
/// authenticates the caller's own <c>Authorization</c>/<c>X-API-Key</c> credential and
|
/// authenticates the caller's own <c>Authorization</c>/<c>X-API-Key</c> credential and
|
||||||
/// applies its own body cap and method timeout. The active node's status, content type
|
/// applies its own body cap and method timeout. The active node's status, content type
|
||||||
/// and body are replayed to the caller unchanged. The body is buffered once on the
|
/// and body are replayed to the caller unchanged. The body is buffered once on the
|
||||||
@@ -61,6 +61,8 @@ public sealed class HttpStandbyRequestForwarder : IStandbyRequestForwarder
|
|||||||
{
|
{
|
||||||
"Host", "Content-Length", "Transfer-Encoding", "Connection", "Keep-Alive",
|
"Host", "Content-Length", "Transfer-Encoding", "Connection", "Keep-Alive",
|
||||||
"Proxy-Connection", "Upgrade", "TE", "Trailer", "Expect",
|
"Proxy-Connection", "Upgrade", "TE", "Trailer", "Expect",
|
||||||
|
// The relay copies raw response bytes and Content-Type, not Content-Encoding.
|
||||||
|
"Accept-Encoding",
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public sealed class KpiHistoryOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60);
|
public TimeSpan SampleInterval { get; set; } = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
/// <summary>Maximum wait for one KPI source (default 10 seconds). Unfinished sources remain owned and are skipped on later ticks.</summary>
|
||||||
|
public TimeSpan SourceTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central retention window in days for recorded KPI samples (default 90,
|
/// Central retention window in days for recorded KPI samples (default 90,
|
||||||
/// range <c>[1, 3650]</c>). Rows older than this are dropped by the purge
|
/// range <c>[1, 3650]</c>). Rows older than this are dropped by the purge
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase<KpiHistory
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
|
protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
|
||||||
{
|
{
|
||||||
|
builder.RequireThat(options.SourceTimeout > TimeSpan.Zero && options.SourceTimeout <= TimeSpan.FromDays(1),
|
||||||
|
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SourceTimeout)} must be > 0 and <= 1 day.");
|
||||||
|
|
||||||
builder.RequireThat(options.SampleInterval > TimeSpan.Zero,
|
builder.RequireThat(options.SampleInterval > TimeSpan.Zero,
|
||||||
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({options.SampleInterval}) " +
|
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.SampleInterval)} ({options.SampleInterval}) " +
|
||||||
"must be > 0; it is the recorder's sampling cadence.");
|
"must be > 0; it is the recorder's sampling cadence.");
|
||||||
|
|||||||
@@ -58,9 +58,10 @@ namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>DI scopes.</b> <see cref="IKpiSampleSource"/>s and
|
/// <b>DI scopes.</b> <see cref="IKpiSampleSource"/>s and
|
||||||
/// <see cref="IKpiHistoryRepository"/> are scoped EF Core-backed services; the
|
/// <see cref="IKpiHistoryRepository"/> are scoped EF Core-backed services; the
|
||||||
/// recorder opens a fresh <see cref="IServiceScope"/> per tick and resolves
|
/// recorder opens a fresh <see cref="IServiceScope"/> for the tick's repository
|
||||||
/// there, asynchronously disposing it (<c>CreateAsyncScope</c>) so EF Core's
|
/// and a separate scope for each source collection. A timed-out collection owns
|
||||||
/// async connection cleanup does not block.
|
/// its scope until actual completion, while later sources use separate DbContexts.
|
||||||
|
/// Scopes are asynchronously disposed (<c>CreateAsyncScope</c>).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Singleton wiring.</b> The <c>Props</c> is built in the Host on the
|
/// <b>Singleton wiring.</b> The <c>Props</c> is built in the Host on the
|
||||||
@@ -87,6 +88,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly TimeSpan BackfillInitialDelay = TimeSpan.FromSeconds(15);
|
private static readonly TimeSpan BackfillInitialDelay = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
// Shared by every recorder incarnation on this root provider.
|
||||||
|
private readonly KpiSourceAdmission _sourceAdmission;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly KpiHistoryOptions _options;
|
private readonly KpiHistoryOptions _options;
|
||||||
private readonly ILogger<KpiHistoryRecorderActor> _logger;
|
private readonly ILogger<KpiHistoryRecorderActor> _logger;
|
||||||
@@ -176,6 +179,7 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
|||||||
ILogger<KpiHistoryRecorderActor> logger)
|
ILogger<KpiHistoryRecorderActor> logger)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||||
|
_sourceAdmission = serviceProvider.GetRequiredService<KpiSourceAdmission>();
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
@@ -307,9 +311,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs a single sampling pass: opens a DI scope, enumerates every registered
|
/// Runs a single sampling pass: enumerates every registered <see cref="IKpiSampleSource"/>,
|
||||||
/// <see cref="IKpiSampleSource"/>, collects each source's samples (isolating per-source
|
/// waits a bounded time for each separately scoped collection, and bulk-writes the combined batch. The whole body is wrapped so the
|
||||||
/// faults), and bulk-writes the combined batch. The whole body is wrapped so the
|
|
||||||
/// returned task never faults — best-effort observability must never disrupt anything.
|
/// returned task never faults — best-effort observability must never disrupt anything.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task RunSamplePass(DateTime capturedAt, CancellationToken cancellationToken)
|
private async Task RunSamplePass(DateTime capturedAt, CancellationToken cancellationToken)
|
||||||
@@ -317,17 +320,32 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var scope = _serviceProvider.CreateAsyncScope();
|
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||||
var sources = scope.ServiceProvider.GetServices<IKpiSampleSource>();
|
var sources = scope.ServiceProvider.GetServices<IKpiSampleSource>().ToArray();
|
||||||
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
|
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
|
||||||
|
|
||||||
var samples = new List<KpiSample>();
|
var samples = new List<KpiSample>();
|
||||||
foreach (var source in sources)
|
for (var sourceIndex = 0; sourceIndex < sources.Length; sourceIndex++)
|
||||||
{
|
{
|
||||||
|
var sourceName = sources[sourceIndex].Source;
|
||||||
|
// Each call owns a separate scope, so a cancellation-ignoring source
|
||||||
|
// neither shares a busy DbContext with healthy sources nor loses its scope.
|
||||||
|
var index = sourceIndex;
|
||||||
|
if (!_sourceAdmission.TryStart(index,
|
||||||
|
() => CollectSourceAsync(index, capturedAt, cancellationToken), out var collection))
|
||||||
|
continue;
|
||||||
|
_ = collection.ContinueWith(t =>
|
||||||
|
_logger.LogError(t.Exception, "KPI source {Source} collection failed.", sourceName),
|
||||||
|
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var collected = await source.CollectAsync(capturedAt, cancellationToken);
|
var collected = await collection.WaitAsync(_options.SourceTimeout, cancellationToken);
|
||||||
samples.AddRange(collected);
|
samples.AddRange(collected);
|
||||||
}
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("KPI source {Source} exceeded its collection deadline; unfinished work remains owned and will not be restarted.", sourceName);
|
||||||
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Shutdown interrupted collection; abandon the rest of the pass. The next
|
// Shutdown interrupted collection; abandon the rest of the pass. The next
|
||||||
@@ -337,7 +355,7 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// A throwing source must NOT abort the pass or the other sources.
|
// A throwing source must NOT abort the pass or the other sources.
|
||||||
_logger.LogError(ex, "KPI source {Source} failed to collect samples.", source.Source);
|
_logger.LogError(ex, "KPI source {Source} failed to collect samples.", sourceName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +385,17 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Owns a source's scope and cancellation source until its actual collection completes, even after the caller stops waiting.</summary>
|
||||||
|
private async Task<IReadOnlyList<KpiSample>> CollectSourceAsync(
|
||||||
|
int sourceIndex, DateTime capturedAt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = _serviceProvider.CreateAsyncScope();
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
deadline.CancelAfter(_options.SourceTimeout);
|
||||||
|
var source = scope.ServiceProvider.GetServices<IKpiSampleSource>().ElementAt(sourceIndex);
|
||||||
|
return await source.CollectAsync(capturedAt, deadline.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles a purge tick: computes both retention cut-offs on the actor thread (raw samples
|
/// Handles a purge tick: computes both retention cut-offs on the actor thread (raw samples
|
||||||
/// at <see cref="KpiHistoryOptions.RetentionDays"/>, hourly rollups at the longer
|
/// at <see cref="KpiHistoryOptions.RetentionDays"/>, hourly rollups at the longer
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Root-provider-owned admission for source collections. Actor replacement must not
|
||||||
|
/// start another call while an earlier cancellation-ignoring collection still owns its scope.
|
||||||
|
/// Registration indices are stable because the root provider's service collection is immutable.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class KpiSourceAdmission
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<int, Task<IReadOnlyList<KpiSample>>> _tasks = new();
|
||||||
|
|
||||||
|
internal bool TryStart(
|
||||||
|
int registrationIndex,
|
||||||
|
Func<Task<IReadOnlyList<KpiSample>>> collect,
|
||||||
|
out Task<IReadOnlyList<KpiSample>> collection)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_tasks.TryGetValue(registrationIndex, out var existing) && !existing.IsCompleted)
|
||||||
|
{
|
||||||
|
collection = existing;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
collection = Task.Run(collect);
|
||||||
|
_tasks[registrationIndex] = collection;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using ZB.MOM.WW.Configuration;
|
using ZB.MOM.WW.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
|
||||||
@@ -22,7 +23,8 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers the KPI History component services: the validated
|
/// Registers the KPI History component services: the validated
|
||||||
/// <see cref="KpiHistoryOptions"/> binding. Idempotent re-registration of the
|
/// <see cref="KpiHistoryOptions"/> binding and provider-owned source admission.
|
||||||
|
/// Idempotent re-registration of the
|
||||||
/// validator is handled by the shared <c>AddValidatedOptions</c> helper
|
/// validator is handled by the shared <c>AddValidatedOptions</c> helper
|
||||||
/// (<c>TryAddEnumerable</c>); the options binding itself is call-once per
|
/// (<c>TryAddEnumerable</c>); the options binding itself is call-once per
|
||||||
/// <see cref="IServiceCollection"/>.
|
/// <see cref="IServiceCollection"/>.
|
||||||
@@ -38,6 +40,7 @@ public static class ServiceCollectionExtensions
|
|||||||
// Binds the "ScadaBridge:KpiHistory" section, registers the validator,
|
// Binds the "ScadaBridge:KpiHistory" section, registers the validator,
|
||||||
// and enables ValidateOnStart in one call — same shape as AddAuditLog.
|
// and enables ValidateOnStart in one call — same shape as AddAuditLog.
|
||||||
services.AddValidatedOptions<KpiHistoryOptions, KpiHistoryOptionsValidator>(configuration, OptionsSection);
|
services.AddValidatedOptions<KpiHistoryOptions, KpiHistoryOptionsValidator>(configuration, OptionsSection);
|
||||||
|
services.TryAddSingleton<KpiSourceAdmission>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ public sealed class FlowDebugRunner
|
|||||||
/// loop running on the node after the caller's Ask has already expired, holding its scoped
|
/// loop running on the node after the caller's Ask has already expired, holding its scoped
|
||||||
/// provider and pulling from the site for a result nobody can receive.
|
/// provider and pulling from the site for a result nobody can receive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int MaxWaitSeconds = 300;
|
public const int MaxWaitSeconds = 295;
|
||||||
|
|
||||||
|
/// <summary>Time reserved for serializing and returning a management reply.</summary>
|
||||||
|
public static readonly TimeSpan ResponseGrace = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Arch-review F2: <see cref="InboundScriptExecutor"/>'s code for a routed call refused
|
/// Arch-review F2: <see cref="InboundScriptExecutor"/>'s code for a routed call refused
|
||||||
@@ -83,22 +86,8 @@ public sealed class FlowDebugRunner
|
|||||||
public static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
|
public static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Row cap asked of the site on each <see cref="IPullTraceEventsClient.PullAsync"/> poll — the
|
/// Site page ceiling. Full pages are drained with an exclusive timestamp/event-id cursor;
|
||||||
/// site's own ceiling (<c>SiteStreamGrpcServer.MaxTraceEventsPerPull</c> = 2000; a larger
|
/// execution IDs are split into independent batches of at most 64.
|
||||||
/// <c>max_events</c> is rejected <c>InvalidArgument</c>), so this asks for everything the site
|
|
||||||
/// will serve.
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// Final review I1: this was 500, which was ample before step logs but is not any more. The
|
|
||||||
/// site query is <c>ORDER BY OccurredAtUtc ASC LIMIT</c>, so the page is the OLDEST rows in
|
|
||||||
/// the window — and a single traced run now emits up to 201 <c>ScriptLog</c> rows (design
|
|
||||||
/// §3, <c>MaxLogLinesPerRun</c> + the closing dropped-count row). Two chatty runs could
|
|
||||||
/// therefore fill a 500-row page and push a later spawned run's <c>ScriptRun</c> row past the
|
|
||||||
/// end of it, making <see cref="WriteAndPollAsync"/> report a false
|
|
||||||
/// <see cref="NoTriggerCode"/> for a run that did happen. 2000 makes that ~10x harder; the
|
|
||||||
/// real fix (paging by last-seen timestamp, or excluding <c>ScriptLog</c> from the awaited-run
|
|
||||||
/// poll) is deferred-work register row 117.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int PullTraceEventsMax = 2000;
|
public const int PullTraceEventsMax = 2000;
|
||||||
|
|
||||||
@@ -136,8 +125,12 @@ public sealed class FlowDebugRunner
|
|||||||
/// registered none (a test composition root) collects raw but is still capped.
|
/// registered none (a test composition root) collects raw but is still capped.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<FlowDebugRunResult> RunMethodAsync(
|
public Task<FlowDebugRunResult> RunMethodAsync(
|
||||||
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, CancellationToken ct)
|
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, CancellationToken ct)
|
||||||
|
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunMethodCoreAsync(sp, cmd, user, deadline, token), ct);
|
||||||
|
|
||||||
|
private async Task<FlowDebugRunResult> RunMethodCoreAsync(
|
||||||
|
IServiceProvider sp, FlowDebugRunMethodCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(sp);
|
ArgumentNullException.ThrowIfNull(sp);
|
||||||
ArgumentNullException.ThrowIfNull(cmd);
|
ArgumentNullException.ThrowIfNull(cmd);
|
||||||
@@ -240,8 +233,12 @@ public sealed class FlowDebugRunner
|
|||||||
/// Live run of a value-change trigger (design §5.3/§5.4): traced routed write through the
|
/// Live run of a value-change trigger (design §5.3/§5.4): traced routed write through the
|
||||||
/// site's normal path, then poll the site's trace rows for the spawned run(s).
|
/// site's normal path, then poll the site's trace rows for the spawned run(s).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<FlowDebugRunResult> RunValueChangeAsync(
|
public Task<FlowDebugRunResult> RunValueChangeAsync(
|
||||||
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, CancellationToken ct)
|
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, CancellationToken ct)
|
||||||
|
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunValueChangeCoreAsync(sp, cmd, user, deadline, token), ct);
|
||||||
|
|
||||||
|
private async Task<FlowDebugRunResult> RunValueChangeCoreAsync(
|
||||||
|
IServiceProvider sp, FlowDebugRunValueChangeCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(sp);
|
ArgumentNullException.ThrowIfNull(sp);
|
||||||
ArgumentNullException.ThrowIfNull(cmd);
|
ArgumentNullException.ThrowIfNull(cmd);
|
||||||
@@ -259,7 +256,7 @@ public sealed class FlowDebugRunner
|
|||||||
?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);
|
?? throw new ManagementCommandException($"Site with ID {instance.SiteId} not found.", NotFoundCode);
|
||||||
|
|
||||||
return await WriteAndPollAsync(
|
return await WriteAndPollAsync(
|
||||||
sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, ct)
|
sp, user, instance, site, cmd.AttributeName, cmd.Value, cmd.WaitSeconds, triggerScript: null, commandDeadline, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,8 +269,12 @@ public sealed class FlowDebugRunner
|
|||||||
/// rather than through <see cref="RouteHelper"/> is deliberate: the helper emits its own
|
/// rather than through <see cref="RouteHelper"/> is deliberate: the helper emits its own
|
||||||
/// always-on RouteCall row with no operator actor, which would give the graph two roots.
|
/// always-on RouteCall row with no operator actor, which would give the graph two roots.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<FlowDebugRunResult> RunScriptAsync(
|
public Task<FlowDebugRunResult> RunScriptAsync(
|
||||||
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct)
|
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, CancellationToken ct)
|
||||||
|
=> WithinCommandBudgetAsync(sp, (deadline, token) => RunScriptCoreAsync(sp, cmd, user, deadline, token), ct);
|
||||||
|
|
||||||
|
private async Task<FlowDebugRunResult> RunScriptCoreAsync(
|
||||||
|
IServiceProvider sp, FlowDebugRunScriptCommand cmd, AuthenticatedUser user, DateTimeOffset commandDeadline, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(sp);
|
ArgumentNullException.ThrowIfNull(sp);
|
||||||
ArgumentNullException.ThrowIfNull(cmd);
|
ArgumentNullException.ThrowIfNull(cmd);
|
||||||
@@ -312,7 +313,7 @@ public sealed class FlowDebugRunner
|
|||||||
if (cmd.Value is null)
|
if (cmd.Value is null)
|
||||||
throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode);
|
throw new ManagementCommandException($"Value is required to fire '{script.CanonicalName}' (writes '{attribute}').", ValidationFailedCode);
|
||||||
return await WriteAndPollAsync(
|
return await WriteAndPollAsync(
|
||||||
sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, ct).ConfigureAwait(false);
|
sp, user, instance, site, attribute!, cmd.Value, cmd.WaitSeconds, script.CanonicalName, commandDeadline, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolveRef = await SchemaRefResolver.BuildAsync(
|
var resolveRef = await SchemaRefResolver.BuildAsync(
|
||||||
@@ -431,7 +432,7 @@ public sealed class FlowDebugRunner
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<FlowDebugRunResult> WriteAndPollAsync(
|
private async Task<FlowDebugRunResult> WriteAndPollAsync(
|
||||||
IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site,
|
IServiceProvider sp, AuthenticatedUser user, Instance instance, Site site,
|
||||||
string attributeName, string? value, int waitSeconds, string? triggerScript, CancellationToken ct)
|
string attributeName, string? value, int waitSeconds, string? triggerScript, DateTimeOffset commandDeadline, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var root = Guid.NewGuid();
|
var root = Guid.NewGuid();
|
||||||
var startedAt = DateTimeOffset.UtcNow;
|
var startedAt = DateTimeOffset.UtcNow;
|
||||||
@@ -496,22 +497,53 @@ public sealed class FlowDebugRunner
|
|||||||
var puller = sp.GetRequiredService<IPullTraceEventsClient>();
|
var puller = sp.GetRequiredService<IPullTraceEventsClient>();
|
||||||
var wait = Math.Clamp(waitSeconds, 1, MaxWaitSeconds);
|
var wait = Math.Clamp(waitSeconds, 1, MaxWaitSeconds);
|
||||||
var deadline = _now() + TimeSpan.FromSeconds(wait);
|
var deadline = _now() + TimeSpan.FromSeconds(wait);
|
||||||
|
if (deadline > commandDeadline) deadline = commandDeadline;
|
||||||
var known = new Dictionary<Guid, AuditEvent>();
|
var known = new Dictionary<Guid, AuditEvent>();
|
||||||
DateTimeOffset? lastNew = null;
|
DateTimeOffset? lastNew = null;
|
||||||
var since = startedAt.UtcDateTime.AddSeconds(-5);
|
// Root is a fresh GUID for this command, so matching it and its discovered descendants
|
||||||
|
// requires no cross-node timestamp lower bound. Site clocks may be behind central.
|
||||||
|
var since = DateTime.MinValue;
|
||||||
|
// Stable ID batches avoid silently dropping descendants beyond the per-read ID cap.
|
||||||
|
var assigned = new HashSet<Guid>();
|
||||||
|
var batches = new List<TraceReadBatch>();
|
||||||
while (!ct.IsCancellationRequested)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var ids = new HashSet<Guid> { root };
|
var discovered = new HashSet<Guid> { root };
|
||||||
foreach (var e in known.Values)
|
foreach (var e in known.Values)
|
||||||
{
|
{
|
||||||
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
|
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
|
||||||
if (d.ExecutionId is { } x) ids.Add(x);
|
if (d.ExecutionId is { } x) discovered.Add(x);
|
||||||
}
|
}
|
||||||
var pulled = await puller.PullAsync(site.SiteIdentifier, ids.Take(64).ToList(), since, PullTraceEventsMax, ct).ConfigureAwait(false);
|
foreach (var ids in discovered.Where(id => assigned.Add(id)).Chunk(64))
|
||||||
|
batches.Add(new TraceReadBatch(ids, since));
|
||||||
|
|
||||||
var added = false;
|
var added = false;
|
||||||
foreach (var e in pulled)
|
foreach (var batch in batches)
|
||||||
{
|
{
|
||||||
if (known.TryAdd(e.EventId, e)) added = true;
|
// ScriptRun/operation closing rows are inserted at completion but stamped with
|
||||||
|
// their start time, potentially behind a cursor that already saw live log rows.
|
||||||
|
// Drain every page within this scan, then intentionally rescan on the next poll.
|
||||||
|
// This trades repeated transfer for correct late/backdated-row discovery.
|
||||||
|
batch.SinceUtc = since;
|
||||||
|
batch.AfterEventId = null;
|
||||||
|
while (!ct.IsCancellationRequested && _now() < deadline)
|
||||||
|
{
|
||||||
|
var pulled = await puller.PullAsync(site.SiteIdentifier, batch.ExecutionIds,
|
||||||
|
batch.SinceUtc, PullTraceEventsMax, ct, batch.AfterEventId).ConfigureAwait(false);
|
||||||
|
foreach (var e in pulled)
|
||||||
|
if (known.TryAdd(e.EventId, e)) added = true;
|
||||||
|
if (pulled.Count == 0) break;
|
||||||
|
var last = pulled[^1];
|
||||||
|
var nextTime = last.OccurredAtUtc.UtcDateTime;
|
||||||
|
var nextId = last.EventId.ToString("D");
|
||||||
|
// An old peer ignoring the additive cursor must not cause a tight repeat loop.
|
||||||
|
var advanced = nextTime > batch.SinceUtc || nextTime == batch.SinceUtc
|
||||||
|
&& (batch.AfterEventId is null || string.CompareOrdinal(nextId, batch.AfterEventId) > 0);
|
||||||
|
if (!advanced) break;
|
||||||
|
batch.SinceUtc = nextTime;
|
||||||
|
batch.AfterEventId = nextId;
|
||||||
|
if (pulled.Count < PullTraceEventsMax) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (added) lastNew = _now();
|
if (added) lastNew = _now();
|
||||||
|
|
||||||
@@ -522,8 +554,11 @@ public sealed class FlowDebugRunner
|
|||||||
await _delay(PollInterval, ct).ConfigureAwait(false);
|
await _delay(PollInterval, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var ordered = new List<AuditEvent> { redactedRoot };
|
var collector = new FlowTraceCollector();
|
||||||
ordered.AddRange(known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId));
|
collector.Add(redactedRoot);
|
||||||
|
foreach (var evt in known.Values.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId))
|
||||||
|
collector.Add(evt, lowPriority: FlowStepProjector.IsLogLine(evt));
|
||||||
|
var ordered = collector.Complete();
|
||||||
var completed = known.Values.Any(IsAwaitedRun);
|
var completed = known.Values.Any(IsAwaitedRun);
|
||||||
return new FlowDebugRunResult(
|
return new FlowDebugRunResult(
|
||||||
root, completed,
|
root, completed,
|
||||||
@@ -531,7 +566,7 @@ public sealed class FlowDebugRunner
|
|||||||
? $"No traced run was spawned within {wait}s (no value-change trigger on '{attributeName}', or no device echo)."
|
? $"No traced run was spawned within {wait}s (no value-change trigger on '{attributeName}', or no device echo)."
|
||||||
: $"No traced run of '{triggerScript}' was spawned within {wait}s after writing '{attributeName}' (trigger condition not met, or no device echo).",
|
: $"No traced run of '{triggerScript}' was spawned within {wait}s after writing '{attributeName}' (trigger condition not met, or no device echo).",
|
||||||
completed ? null : NoTriggerCode,
|
completed ? null : NoTriggerCode,
|
||||||
ordered, false, startedAt, DateTimeOffset.UtcNow);
|
ordered, collector.Truncated, startedAt, DateTimeOffset.UtcNow, LogsTruncated: collector.LogsTruncated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Replay (design §7.1): the stored ScriptTrace chain for any execution id.</summary>
|
/// <summary>Replay (design §7.1): the stored ScriptTrace chain for any execution id.</summary>
|
||||||
@@ -590,6 +625,34 @@ public sealed class FlowDebugRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class TraceReadBatch(Guid[] executionIds, DateTime sinceUtc)
|
||||||
|
{
|
||||||
|
public Guid[] ExecutionIds { get; } = executionIds;
|
||||||
|
public DateTime SinceUtc { get; set; } = sinceUtc;
|
||||||
|
public string? AfterEventId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FlowDebugRunResult> WithinCommandBudgetAsync(
|
||||||
|
IServiceProvider sp, Func<DateTimeOffset, CancellationToken, Task<FlowDebugRunResult>> run, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sp);
|
||||||
|
var timeout = sp.GetService<Microsoft.Extensions.Options.IOptions<ManagementServiceOptions>>()?.Value.LongRunningCommandTimeout
|
||||||
|
?? TimeSpan.FromMinutes(5);
|
||||||
|
var budget = timeout - ResponseGrace;
|
||||||
|
if (budget <= TimeSpan.Zero)
|
||||||
|
throw new ManagementCommandException("Long-running command timeout must exceed the response grace.", ValidationFailedCode);
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
deadline.CancelAfter(budget);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await run(_now() + budget, deadline.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new ManagementCommandException("Flow Debugger command exceeded its total execution budget.", TimeoutCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureActiveNode(IServiceProvider sp)
|
private static void EnsureActiveNode(IServiceProvider sp)
|
||||||
{
|
{
|
||||||
var gate = sp.GetService<IActiveNodeGate>();
|
var gate = sp.GetService<IActiveNodeGate>();
|
||||||
@@ -651,6 +714,10 @@ public sealed class FlowDebugRunner
|
|||||||
{
|
{
|
||||||
await writer.WriteAsync(evt, ct).ConfigureAwait(false);
|
await writer.WriteAsync(evt, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Flow Debugger central audit write failed for {Action}", evt.Action);
|
_logger.LogWarning(ex, "Flow Debugger central audit write failed for {Action}", evt.Action);
|
||||||
|
|||||||
@@ -730,7 +730,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
private static List<T> Page<T>(IEnumerable<T> source, int skip, int? take) =>
|
private static List<T> Page<T>(IEnumerable<T> source, int skip, int? take) =>
|
||||||
source
|
source
|
||||||
.Skip(Math.Max(0, skip))
|
.Skip(Math.Max(0, skip))
|
||||||
.Take(take is > 0 and <= 1000 ? take.Value : int.MaxValue)
|
.Take(take is > 0 ? Math.Min(take.Value, 1000) : int.MaxValue)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
private static async Task<object?> HandleGetTemplate(IServiceProvider sp, GetTemplateCommand cmd)
|
private static async Task<object?> HandleGetTemplate(IServiceProvider sp, GetTemplateCommand cmd)
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ public sealed class ManagementServiceOptionsValidator : OptionsValidatorBase<Man
|
|||||||
$"ScadaBridge:ManagementService:CommandTimeout must be a positive duration " +
|
$"ScadaBridge:ManagementService:CommandTimeout must be a positive duration " +
|
||||||
$"(was {options.CommandTimeout}); it is the Ask timeout for management commands.");
|
$"(was {options.CommandTimeout}); it is the Ask timeout for management commands.");
|
||||||
|
|
||||||
builder.RequireThat(options.LongRunningCommandTimeout > TimeSpan.Zero,
|
builder.RequireThat(options.LongRunningCommandTimeout > FlowDebug.FlowDebugRunner.ResponseGrace,
|
||||||
$"ScadaBridge:ManagementService:LongRunningCommandTimeout must be a positive duration " +
|
$"ScadaBridge:ManagementService:LongRunningCommandTimeout must exceed the 5-second response grace " +
|
||||||
$"(was {options.LongRunningCommandTimeout}); it is the Ask timeout for transport/deploy commands.");
|
$"(was {options.LongRunningCommandTimeout}); it is the Ask timeout for transport/deploy commands.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ public sealed class SiteCallAuditOptionsValidator : OptionsValidatorBase<SiteCal
|
|||||||
$"ScadaBridge:SiteCallAudit:ReconciliationInterval must be a positive duration " +
|
$"ScadaBridge:SiteCallAudit:ReconciliationInterval must be a positive duration " +
|
||||||
$"(was {options.ReconciliationInterval}); it is the period of the per-site reconciliation pull.");
|
$"(was {options.ReconciliationInterval}); it is the period of the per-site reconciliation pull.");
|
||||||
|
|
||||||
builder.RequireThat(options.ReconciliationBatchSize > 0,
|
builder.RequireThat(options.ReconciliationBatchSize is > 0 and <= Communication.Grpc.SiteStreamGrpcServer.MaxReconciliationRowsPerPull,
|
||||||
$"ScadaBridge:SiteCallAudit:ReconciliationBatchSize must be positive " +
|
$"ScadaBridge:SiteCallAudit:ReconciliationBatchSize must be 1..2000 " +
|
||||||
$"(was {options.ReconciliationBatchSize}); it is the max rows requested per PullSiteCalls RPC.");
|
$"(was {options.ReconciliationBatchSize}); it is the max rows requested per PullSiteCalls RPC.");
|
||||||
|
|
||||||
builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
|
builder.RequireThat(options.PurgeInterval > TimeSpan.Zero,
|
||||||
|
|||||||
@@ -279,6 +279,9 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
||||||
{
|
{
|
||||||
|
// Alarm audit rows have no inferred-edge metadata: never assert that an armed
|
||||||
|
// device publish was caused by the candidate writer. Observed static writes retain it.
|
||||||
|
var observedSource = changed.SourceExecutionInferred ? null : changed.SourceExecutionId;
|
||||||
// Expression triggers evaluate against a snapshot of every attribute,
|
// Expression triggers evaluate against a snapshot of every attribute,
|
||||||
// not a single monitored attribute. Keep the snapshot current for every
|
// not a single monitored attribute. Keep the snapshot current for every
|
||||||
// change before the IsMonitoredAttribute gate (which does not apply).
|
// change before the IsMonitoredAttribute gate (which does not apply).
|
||||||
@@ -288,7 +291,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
// (ParentExecutionId tag-cascade): remember which
|
// (ParentExecutionId tag-cascade): remember which
|
||||||
// execution (if any) wrote the newest value folded into the snapshot,
|
// execution (if any) wrote the newest value folded into the snapshot,
|
||||||
// so the off-dispatcher evaluation can attribute its raise to it.
|
// so the off-dispatcher evaluation can attribute its raise to it.
|
||||||
_latestSourceExecutionId = changed.SourceExecutionId;
|
_latestSourceExecutionId = observedSource;
|
||||||
}
|
}
|
||||||
else if (!IsMonitoredAttribute(changed.AttributeName))
|
else if (!IsMonitoredAttribute(changed.AttributeName))
|
||||||
{
|
{
|
||||||
@@ -300,7 +303,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
if (_triggerType == AlarmTriggerType.HiLo)
|
if (_triggerType == AlarmTriggerType.HiLo)
|
||||||
{
|
{
|
||||||
HandleHiLoTransition(EvaluateHiLo(changed.Value), changed.SourceExecutionId);
|
HandleHiLoTransition(EvaluateHiLo(changed.Value), observedSource);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +325,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
ApplyTriggeredState(isTriggered, changed.SourceExecutionId);
|
ApplyTriggeredState(isTriggered, observedSource);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
/// <c>Trace</c>, and the arm is consumed. Bounded by the attribute count (one entry per
|
/// <c>Trace</c>, and the arm is consumed. Bounded by the attribute count (one entry per
|
||||||
/// attribute, newest wins); no sweep — an unconsumed arm is overwritten or expires in place.
|
/// attribute, newest wins); no sweep — an unconsumed arm is overwritten or expires in place.
|
||||||
/// Armed at ISSUE, disarmed on a failed/timed-out write (arch-review F6, <see cref="DisarmTrace"/>);
|
/// Armed at ISSUE, disarmed on a failed/timed-out write (arch-review F6, <see cref="DisarmTrace"/>);
|
||||||
/// a successful write's echo consumes it.
|
/// the first subsequent device update consumes it, with explicitly inferred linkage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<string, (Guid SourceExecutionId, DateTimeOffset ExpiresAt)> _traceArms = new();
|
private readonly Dictionary<string, (Guid SourceExecutionId, DateTimeOffset ExpiresAt)> _traceArms = new();
|
||||||
// Maps each tag path to every attribute canonical name that references it.
|
// Maps each tag path to every attribute canonical name that references it.
|
||||||
@@ -337,7 +337,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
Receive<RetryTagSubscribe>(msg => SendTagSubscribe(msg.ConnectionName));
|
Receive<RetryTagSubscribe>(msg => SendTagSubscribe(msg.ConnectionName));
|
||||||
|
|
||||||
// Arch-review F6: a traced write the DCL rejected (or that timed out) drops its own arm,
|
// Arch-review F6: a traced write the DCL rejected (or that timed out) drops its own arm,
|
||||||
// so an unrelated later update is not stamped as that write's device echo.
|
// so a later update is not associated with that failed write.
|
||||||
Receive<DisarmTrace>(msg =>
|
Receive<DisarmTrace>(msg =>
|
||||||
{
|
{
|
||||||
if (_traceArms.TryGetValue(msg.AttributeName, out var arm)
|
if (_traceArms.TryGetValue(msg.AttributeName, out var arm)
|
||||||
@@ -751,10 +751,10 @@ public class InstanceActor : ReceiveActor
|
|||||||
DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
// Flow Debugger trace arm (design §5.3): only a TRACED write with a known writer arms;
|
// Flow Debugger trace arm (design §5.3): only a TRACED write with a known writer arms;
|
||||||
// the device echo that follows is stamped in HandleTagValueUpdate. Untraced writes
|
// the next device update is marked as inferred in HandleTagValueUpdate. Untraced writes
|
||||||
// never touch the map, so production behaviour is unchanged. The arm is taken at ISSUE
|
// never touch the map, so production behaviour is unchanged. The arm is taken at ISSUE
|
||||||
// and dropped again on a failed/timed-out write (arch-review F6, via the DisarmTrace
|
// and dropped again on a failed/timed-out write (arch-review F6, via the DisarmTrace
|
||||||
// self-message below); a successful write's echo consumes it.
|
// self-message below); the first subsequent device update consumes it, with explicitly inferred linkage.
|
||||||
Guid? armed = null;
|
Guid? armed = null;
|
||||||
if (command.Trace && command.SourceExecutionId is { } traceSource)
|
if (command.Trace && command.SourceExecutionId is { } traceSource)
|
||||||
{
|
{
|
||||||
@@ -1063,9 +1063,9 @@ public class InstanceActor : ReceiveActor
|
|||||||
// O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
|
// O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
|
||||||
_resolvedAttributeByName.TryGetValue(attrName, out var resolved);
|
_resolvedAttributeByName.TryGetValue(attrName, out var resolved);
|
||||||
|
|
||||||
// Flow Debugger trace arm (design §5.3): consume the arm for this attribute — the
|
// A device subscription has no write-request identity. Associate only the first
|
||||||
// echo is stamped whatever its value (a device that clamps or rejects the write
|
// update inside the arm window, explicitly as inferred: a periodic publish may
|
||||||
// still shows what it did) — unless the arm has already expired.
|
// win this race, and equality cannot establish causality (devices can coerce values).
|
||||||
Guid? traceSource = null;
|
Guid? traceSource = null;
|
||||||
var traced = false;
|
var traced = false;
|
||||||
if (_traceArms.Remove(attrName, out var arm) && arm.ExpiresAt > DateTimeOffset.UtcNow)
|
if (_traceArms.Remove(attrName, out var arm) && arm.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
@@ -1085,7 +1085,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
HandleAttributeValueChanged(new AttributeValueChanged(
|
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||||
_instanceUniqueName, update.TagPath, attrName,
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
typedList, QualityToString(update.Quality), update.Timestamp,
|
typedList, QualityToString(update.Quality), update.Timestamp,
|
||||||
SourceExecutionId: traceSource, Trace: traced));
|
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1103,7 +1103,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
PublishAndNotifyChildren(new AttributeValueChanged(
|
PublishAndNotifyChildren(new AttributeValueChanged(
|
||||||
_instanceUniqueName, update.TagPath, attrName,
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
currentValue, "Bad", update.Timestamp,
|
currentValue, "Bad", update.Timestamp,
|
||||||
SourceExecutionId: traceSource, Trace: traced), evaluateWaiters: false);
|
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced), evaluateWaiters: false);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1118,7 +1118,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
HandleAttributeValueChanged(new AttributeValueChanged(
|
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||||
_instanceUniqueName, update.TagPath, attrName,
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
value, QualityToString(update.Quality), update.Timestamp,
|
value, QualityToString(update.Quality), update.Timestamp,
|
||||||
SourceExecutionId: traceSource, Trace: traced));
|
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
TriggerKind = "valuechange",
|
TriggerKind = "valuechange",
|
||||||
TriggerAttribute = changed.AttributeName,
|
TriggerAttribute = changed.AttributeName,
|
||||||
TriggerValue = AttributeValueCodec.Encode(changed.Value),
|
TriggerValue = AttributeValueCodec.Encode(changed.Value),
|
||||||
TriggerSource = "value-change",
|
TriggerSource = changed.SourceExecutionInferred ? "inferred-device-update" : "value-change",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal const int InstancesPerScriptThread = 8;
|
internal const int InstancesPerScriptThread = 8;
|
||||||
|
|
||||||
/// <summary>Thread-name prefix; also the inlining guard in <see cref="TryExecuteTaskInline"/>.</summary>
|
/// <summary>Thread-name prefix for diagnostics; inlining is guarded by scheduler identity.</summary>
|
||||||
private const string ThreadNamePrefix = "script-execution-";
|
private const string ThreadNamePrefix = "script-execution-";
|
||||||
|
|
||||||
private readonly BlockingCollection<Task> _queue = new();
|
private readonly BlockingCollection<Task> _queue = new();
|
||||||
@@ -338,8 +338,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ThreadStatic]
|
||||||
|
private static ScriptExecutionScheduler? CurrentScheduler;
|
||||||
|
|
||||||
private void WorkerLoop(int index)
|
private void WorkerLoop(int index)
|
||||||
{
|
{
|
||||||
|
CurrentScheduler = this;
|
||||||
CurrentWorkerSlot = index;
|
CurrentWorkerSlot = index;
|
||||||
var slot = _slots[index];
|
var slot = _slots[index];
|
||||||
try
|
try
|
||||||
@@ -375,6 +379,7 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
CurrentWorkerSlot = null;
|
CurrentWorkerSlot = null;
|
||||||
|
CurrentScheduler = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +391,7 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
|||||||
{
|
{
|
||||||
// Only inline if we are already on one of this scheduler's worker threads,
|
// Only inline if we are already on one of this scheduler's worker threads,
|
||||||
// so script work never escapes onto a thread-pool thread.
|
// so script work never escapes onto a thread-pool thread.
|
||||||
if (Thread.CurrentThread.Name?.StartsWith(ThreadNamePrefix, StringComparison.Ordinal) != true)
|
if (!ReferenceEquals(CurrentScheduler, this))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return TryExecuteTask(task);
|
return TryExecuteTask(task);
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ public class ScriptRuntimeContext
|
|||||||
{
|
{
|
||||||
// The collector is a debug-only observer: it must never fail a script call, and it
|
// The collector is a debug-only observer: it must never fail a script call, and it
|
||||||
// must never keep the durable audit row from reaching the writer (review, 2026-09-06).
|
// must never keep the durable audit row from reaching the writer (review, 2026-09-06).
|
||||||
_logger.LogWarning(ex, "Flow trace collector rejected {Action} ({EventId})", evt.Action, evt.EventId);
|
_innerLogger.LogWarning(ex, "Flow trace collector rejected {Action} ({EventId})", evt.Action, evt.EventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +548,7 @@ public class ScriptRuntimeContext
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Flow trace event build failed for {Kind}", kind);
|
_innerLogger.LogWarning(ex, "Flow trace event build failed for {Kind}", kind);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +560,7 @@ public class ScriptRuntimeContext
|
|||||||
if (!t.IsCompletedSuccessfully)
|
if (!t.IsCompletedSuccessfully)
|
||||||
{
|
{
|
||||||
_ = t.ContinueWith(
|
_ = t.ContinueWith(
|
||||||
x => _logger.LogWarning(x.Exception, "Flow trace write failed for {Kind}", kind),
|
x => _innerLogger.LogWarning(x.Exception, "Flow trace write failed for {Kind}", kind),
|
||||||
CancellationToken.None,
|
CancellationToken.None,
|
||||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||||
TaskScheduler.Default);
|
TaskScheduler.Default);
|
||||||
@@ -568,7 +568,7 @@ public class ScriptRuntimeContext
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Flow trace write failed for {Kind}", kind);
|
_innerLogger.LogWarning(ex, "Flow trace write failed for {Kind}", kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,17 +1057,22 @@ public class ScriptRuntimeContext
|
|||||||
return result.ReturnValue;
|
return result.ReturnValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private ScriptCallHelper? _scripts;
|
||||||
/// Provides access to shared script execution via the Scripts property.
|
private ExternalSystemHelper? _externalSystem;
|
||||||
/// </summary>
|
private DatabaseHelper? _database;
|
||||||
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
private NotifyHelper? _notify;
|
||||||
|
private AlarmsAccessor? _alarms;
|
||||||
|
private TrackingHelper? _tracking;
|
||||||
|
|
||||||
|
/// <summary>Provides access to shared script execution via a helper cached for this context.</summary>
|
||||||
|
public ScriptCallHelper Scripts => _scripts ??= new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides access to external system calls.
|
/// Provides access to external system calls.
|
||||||
/// ExternalSystem.Call("systemName", "methodName", params)
|
/// ExternalSystem.Call("systemName", "methodName", params)
|
||||||
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ExternalSystemHelper ExternalSystem => new(
|
public ExternalSystemHelper ExternalSystem => _externalSystem ??= new(
|
||||||
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
|
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
|
||||||
// Emit CachedSubmit telemetry
|
// Emit CachedSubmit telemetry
|
||||||
// on every ExternalSystem.CachedCall enqueue.
|
// on every ExternalSystem.CachedCall enqueue.
|
||||||
@@ -1089,7 +1094,7 @@ public class ScriptRuntimeContext
|
|||||||
/// Database.Connection("name")
|
/// Database.Connection("name")
|
||||||
/// Database.CachedWrite("name", "sql", params)
|
/// Database.CachedWrite("name", "sql", params)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DatabaseHelper Database => new(
|
public DatabaseHelper Database => _database ??= new(
|
||||||
_databaseGateway,
|
_databaseGateway,
|
||||||
_instanceName,
|
_instanceName,
|
||||||
_logger,
|
_logger,
|
||||||
@@ -1127,7 +1132,7 @@ public class ScriptRuntimeContext
|
|||||||
/// Best-effort per alog.md §7 — a thrown writer never aborts the script's
|
/// Best-effort per alog.md §7 — a thrown writer never aborts the script's
|
||||||
/// <c>Send</c>.
|
/// <c>Send</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public NotifyHelper Notify => new(
|
public NotifyHelper Notify => _notify ??= new(
|
||||||
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
|
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
|
||||||
_executionId, _auditWriter,
|
_executionId, _auditWriter,
|
||||||
// (ParentExecutionId): the spawning execution's id,
|
// (ParentExecutionId): the spawning execution's id,
|
||||||
@@ -1146,7 +1151,7 @@ public class ScriptRuntimeContext
|
|||||||
/// Gateway) conditions alike — so a <c>Call</c> script can answer "what is in alarm
|
/// Gateway) conditions alike — so a <c>Call</c> script can answer "what is in alarm
|
||||||
/// right now?" without an on-trigger <c>Alarm</c> context.
|
/// right now?" without an on-trigger <c>Alarm</c> context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AlarmsAccessor Alarms => new(this);
|
public AlarmsAccessor Alarms => _alarms ??= new(this);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's
|
/// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's
|
||||||
@@ -1210,7 +1215,7 @@ public class ScriptRuntimeContext
|
|||||||
/// returns a <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when the
|
/// returns a <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when the
|
||||||
/// id is unknown / has already been purged.
|
/// id is unknown / has already been purged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TrackingHelper Tracking => new(_operationTrackingStore, _logger);
|
public TrackingHelper Tracking => _tracking ??= new(_operationTrackingStore, _logger);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper class for Scripts.CallShared() syntax.
|
/// Helper class for Scripts.CallShared() syntax.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|||||||
public sealed class TriggerEvalGate : IDisposable
|
public sealed class TriggerEvalGate : IDisposable
|
||||||
{
|
{
|
||||||
private readonly SemaphoreSlim _gate;
|
private readonly SemaphoreSlim _gate;
|
||||||
|
private bool _processOwned;
|
||||||
|
|
||||||
private static volatile TriggerEvalGate? _shared;
|
private static volatile TriggerEvalGate? _shared;
|
||||||
private static readonly object SharedLock = new();
|
private static readonly object SharedLock = new();
|
||||||
@@ -48,7 +49,8 @@ public sealed class TriggerEvalGate : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The process-wide gate, used when no gate is injected. Lazily created from
|
/// The process-wide gate, used when no gate is injected. Lazily created from
|
||||||
/// <see cref="SiteRuntimeOptions.TriggerEvalMaxConcurrency"/>; the first caller wins.
|
/// <see cref="SiteRuntimeOptions.TriggerEvalMaxConcurrency"/>; the first caller wins. The shared gate is process-owned; calling
|
||||||
|
/// <see cref="Dispose"/> on it does not release the shared semaphore.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">Site runtime options supplying the concurrency limit.</param>
|
/// <param name="options">Site runtime options supplying the concurrency limit.</param>
|
||||||
/// <returns>The shared gate instance.</returns>
|
/// <returns>The shared gate instance.</returns>
|
||||||
@@ -59,7 +61,7 @@ public sealed class TriggerEvalGate : IDisposable
|
|||||||
|
|
||||||
lock (SharedLock)
|
lock (SharedLock)
|
||||||
{
|
{
|
||||||
return _shared ??= new TriggerEvalGate(options.TriggerEvalMaxConcurrency);
|
return _shared ??= new TriggerEvalGate(options.TriggerEvalMaxConcurrency) { _processOwned = true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,10 @@ public sealed class TriggerEvalGate : IDisposable
|
|||||||
/// <summary>Returns a permit. Must be called exactly once per successful <see cref="WaitAsync"/>.</summary>
|
/// <summary>Returns a permit. Must be called exactly once per successful <see cref="WaitAsync"/>.</summary>
|
||||||
public void Release() => _gate.Release();
|
public void Release() => _gate.Release();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Disposes a caller-created gate. The process-owned <see cref="Shared"/> gate remains available.</summary>
|
||||||
public void Dispose() => _gate.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_processOwned)
|
||||||
|
_gate.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ public class SharedScriptService
|
|||||||
if (syntaxError != null)
|
if (syntaxError != null)
|
||||||
return Result<SharedScript>.Failure(syntaxError);
|
return Result<SharedScript>.Failure(syntaxError);
|
||||||
|
|
||||||
|
var compilation = new Validation.ScriptCompiler().TryCompile(code, name);
|
||||||
|
if (compilation.IsFailure)
|
||||||
|
return Result<SharedScript>.Failure(compilation.Error);
|
||||||
|
|
||||||
var script = new SharedScript(name, code)
|
var script = new SharedScript(name, code)
|
||||||
{
|
{
|
||||||
ParameterDefinitions = parameterDefinitions,
|
ParameterDefinitions = parameterDefinitions,
|
||||||
@@ -110,6 +114,10 @@ public class SharedScriptService
|
|||||||
if (syntaxError != null)
|
if (syntaxError != null)
|
||||||
return Result<SharedScript>.Failure(syntaxError);
|
return Result<SharedScript>.Failure(syntaxError);
|
||||||
|
|
||||||
|
var compilation = new Validation.ScriptCompiler().TryCompile(code, script.Name);
|
||||||
|
if (compilation.IsFailure)
|
||||||
|
return Result<SharedScript>.Failure(compilation.Error);
|
||||||
|
|
||||||
script.Code = code;
|
script.Code = code;
|
||||||
script.ParameterDefinitions = parameterDefinitions;
|
script.ParameterDefinitions = parameterDefinitions;
|
||||||
script.ReturnDefinition = returnDefinition;
|
script.ReturnDefinition = returnDefinition;
|
||||||
@@ -171,7 +179,7 @@ public class SharedScriptService
|
|||||||
/// comment-aware (see <see cref="Validation.CSharpDelimiterScanner"/>) so a
|
/// comment-aware (see <see cref="Validation.CSharpDelimiterScanner"/>) so a
|
||||||
/// delimiter inside a regular/verbatim/interpolated/raw string literal, a
|
/// delimiter inside a regular/verbatim/interpolated/raw string literal, a
|
||||||
/// char literal, or a comment does not produce a false syntax error.
|
/// char literal, or a comment does not produce a false syntax error.
|
||||||
/// Full Roslyn compilation would be added in a later phase when the scripting sandbox is available.
|
/// Create and update additionally apply the cached Roslyn compile and trust gate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="code">The C# code to validate.</param>
|
/// <param name="code">The C# code to validate.</param>
|
||||||
/// <returns>An error message if validation fails; null if valid.</returns>
|
/// <returns>An error message if validation fails; null if valid.</returns>
|
||||||
|
|||||||
+2
-2
@@ -449,8 +449,8 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
|
|||||||
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct);
|
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||||
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
|
||||||
DateTime threshold, CancellationToken ct = default) =>
|
DateTime threshold, CancellationToken ct = default) =>
|
||||||
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
_inner.GetPartitionBoundariesOlderThanAsync(threshold, ct);
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
@@ -470,8 +470,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
|||||||
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
_inner.SwitchOutPartitionAsync(monthBoundary, commandTimeout, ct);
|
||||||
|
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct);
|
_inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, commandTimeout, ct, maxRows);
|
||||||
|
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
// M5.5 (T3): records every per-channel purge call as
|
// M5.5 (T3): records every per-channel purge call as
|
||||||
// (channel, threshold, batchSize) so tests can assert which channels the
|
// (channel, threshold, batchSize) so tests can assert which channels the
|
||||||
// actor chose to purge and with what window.
|
// actor chose to purge and with what window.
|
||||||
public List<(string Channel, DateTime Threshold, int BatchSize)> ChannelPurges { get; } = new();
|
public List<(string Channel, DateTime Threshold, int BatchSize, int MaxRows)> ChannelPurges { get; } = new();
|
||||||
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
public Func<string, long> RowsPerChannel { get; set; } = _ => 0L;
|
||||||
|
|
||||||
// The actor enumerator returns whichever list is configured here.
|
// The actor enumerator returns whichever list is configured here.
|
||||||
@@ -96,9 +96,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default)
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
|
||||||
{
|
{
|
||||||
ChannelPurges.Add((channel, threshold, batchSize));
|
ChannelPurges.Add((channel, threshold, batchSize, maxRows));
|
||||||
ChannelTimeouts.Add(commandTimeout);
|
ChannelTimeouts.Add(commandTimeout);
|
||||||
return Task.FromResult(RowsPerChannel(channel));
|
return Task.FromResult(RowsPerChannel(channel));
|
||||||
}
|
}
|
||||||
@@ -479,6 +479,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
|
var repo = new RecordingRepo { Boundaries = new List<DateTime>() };
|
||||||
var purgeOptions = FastTickOptions();
|
var purgeOptions = FastTickOptions();
|
||||||
purgeOptions.ChannelPurgeBatchSizeConfigured = 1234;
|
purgeOptions.ChannelPurgeBatchSizeConfigured = 1234;
|
||||||
|
purgeOptions.MaxPurgedRowsPerChannelPerTick = 4321;
|
||||||
|
|
||||||
// Build the options OUTSIDE the Props expression tree — a collection/dictionary
|
// Build the options OUTSIDE the Props expression tree — a collection/dictionary
|
||||||
// initializer is not legal inside an expression-tree lambda (CS8074).
|
// initializer is not legal inside an expression-tree lambda (CS8074).
|
||||||
@@ -505,6 +506,7 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
|
|
||||||
var purge = repo.ChannelPurges.First(p => p.Channel == "ApiOutbound");
|
var purge = repo.ChannelPurges.First(p => p.Channel == "ApiOutbound");
|
||||||
Assert.Equal(1234, purge.BatchSize);
|
Assert.Equal(1234, purge.BatchSize);
|
||||||
|
Assert.Equal(4321, purge.MaxRows);
|
||||||
|
|
||||||
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
|
var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
|
||||||
Assert.True(
|
Assert.True(
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ public class CentralAuditWriteFailuresTests : TestKit
|
|||||||
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
string sentinel, DateTime before, int batchSize, CancellationToken ct = default) =>
|
||||||
|
|||||||
+4
-4
@@ -93,7 +93,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
@@ -159,7 +159,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
@@ -225,7 +225,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default) =>
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000) =>
|
||||||
Task.FromResult(0L);
|
Task.FromResult(0L);
|
||||||
|
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
@@ -628,7 +628,7 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
|||||||
|
|
||||||
public Task<long> PurgeChannelOlderThanAsync(
|
public Task<long> PurgeChannelOlderThanAsync(
|
||||||
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
|
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default, int maxRows = 50_000)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<long> BackfillSourceNodeAsync(
|
public Task<long> BackfillSourceNodeAsync(
|
||||||
|
|||||||
+9
@@ -12,6 +12,15 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteAuditReconciliationOptionsValidatorTests
|
public class SiteAuditReconciliationOptionsValidatorTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, true)]
|
||||||
|
[InlineData(2000, true)]
|
||||||
|
[InlineData(2001, false)]
|
||||||
|
[InlineData(int.MaxValue, false)]
|
||||||
|
public void BatchSize_MatchesRpcCeiling(int size, bool valid)
|
||||||
|
=> Assert.Equal(valid, new SiteAuditReconciliationOptionsValidator().Validate(null,
|
||||||
|
new SiteAuditReconciliationOptions { BatchSize = size }).Succeeded);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DefaultOptions_AreValid()
|
public void DefaultOptions_AreValid()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ public class SiteAuditRetentionServiceTests
|
|||||||
private sealed class RecordingSiteAuditQueue : ISiteAuditQueue
|
private sealed class RecordingSiteAuditQueue : ISiteAuditQueue
|
||||||
{
|
{
|
||||||
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
public Task<IReadOnlyList<AuditEvent>> ReadByExecutionIdsAsync(
|
||||||
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default)
|
IReadOnlyCollection<Guid> executionIds, DateTime sinceUtc, int limit, CancellationToken ct = default, string? afterEventId = null)
|
||||||
=> throw new NotSupportedException("not exercised by the retention service");
|
=> throw new NotSupportedException("not exercised by the retention service");
|
||||||
|
|
||||||
private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
|
private readonly ConcurrentQueue<DateTime> _purgeCalls = new();
|
||||||
|
|||||||
+68
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||||
@@ -32,6 +33,73 @@ public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
|||||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
||||||
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(64)]
|
||||||
|
public async Task TraceReadPlan_UsesExecutionIndexesInsteadOfRetainedStoreScan(int idCount)
|
||||||
|
{
|
||||||
|
await using var writer = CreateWriter();
|
||||||
|
using var connection = new SqliteConnection($"Data Source={_dbPath}");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
var names = Enumerable.Range(0, idCount).Select(i => $"$e{i}").ToArray();
|
||||||
|
command.CommandText = "EXPLAIN QUERY PLAN " + SqliteAuditWriter.BuildTraceReadSql(string.Join(", ", names));
|
||||||
|
foreach (var name in names) command.Parameters.AddWithValue(name, Guid.NewGuid().ToString("D"));
|
||||||
|
command.Parameters.AddWithValue("$since", DateTime.MinValue.ToString("o"));
|
||||||
|
command.Parameters.AddWithValue("$after", DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$limit", 2000);
|
||||||
|
var plan = new List<string>();
|
||||||
|
using var reader = await command.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync()) plan.Add(reader.GetString(3));
|
||||||
|
Assert.Contains(plan, p => p.Contains("SEARCH audit_event USING INDEX IX_audit_event_execution", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(plan, p => p.Contains("SEARCH audit_event USING INDEX IX_audit_event_parent_execution", StringComparison.Ordinal));
|
||||||
|
Assert.DoesNotContain(plan, p => p.StartsWith("SCAN audit_event", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LegacyMalformedDetails_DoNotBreakStartupOrTraceReads()
|
||||||
|
{
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
var row = Row(T0, root, null);
|
||||||
|
await using (var writer = CreateWriter()) await writer.WriteAsync(row);
|
||||||
|
using (var connection = new SqliteConnection($"Data Source={_dbPath}"))
|
||||||
|
{
|
||||||
|
await connection.OpenAsync();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
// Simulate a pre-upgrade file, including malformed payloads from an older writer.
|
||||||
|
command.CommandText = "DROP INDEX IF EXISTS IX_audit_event_execution; DROP INDEX IF EXISTS IX_audit_event_parent_execution; "
|
||||||
|
+ "INSERT INTO audit_event(EventId,OccurredAtUtc,Actor,Action,Outcome,DetailsJson) VALUES ($id,$at,'legacy','legacy','Success','{broken');";
|
||||||
|
command.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("D"));
|
||||||
|
command.Parameters.AddWithValue("$at", T0.ToString("o"));
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
await using var reopened = CreateWriter();
|
||||||
|
var result = await reopened.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000);
|
||||||
|
Assert.Equal(row.EventId, Assert.Single(result).EventId);
|
||||||
|
// Index initialization remains idempotent on the upgraded database.
|
||||||
|
await using var reopenedAgain = CreateWriter();
|
||||||
|
Assert.Equal(row.EventId, Assert.Single(await reopenedAgain.ReadByExecutionIdsAsync([root], DateTime.MinValue, 2000)).EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cursor_PagesEqualTimestampsWithoutRepeatingOrSkippingRows()
|
||||||
|
{
|
||||||
|
var root = Guid.NewGuid();
|
||||||
|
await using var writer = CreateWriter();
|
||||||
|
var rows = Enumerable.Range(0, 7).Select(_ => Row(T0, root, null)).ToArray();
|
||||||
|
foreach (var row in rows) await writer.WriteAsync(row);
|
||||||
|
var first = await writer.ReadByExecutionIdsAsync([root], T0, 3);
|
||||||
|
// Reflection lets the pre-fix implementation demonstrate its repeated oldest page.
|
||||||
|
var method = typeof(SqliteAuditWriter).GetMethod(nameof(SqliteAuditWriter.ReadByExecutionIdsAsync))!;
|
||||||
|
object[] args = method.GetParameters().Length == 5
|
||||||
|
? [new[] { root }, T0, 3, CancellationToken.None, first[^1].EventId.ToString("D")]
|
||||||
|
: [new[] { root }, T0, 3, CancellationToken.None];
|
||||||
|
var second = await (Task<IReadOnlyList<AuditEvent>>)method.Invoke(writer, args)!;
|
||||||
|
Assert.Equal(6, first.Concat(second).Select(e => e.EventId).Distinct().Count());
|
||||||
|
Assert.Equal(rows.OrderBy(e => e.EventId.ToString("D"), StringComparer.Ordinal).Take(6).Select(e => e.EventId),
|
||||||
|
first.Concat(second).Select(e => e.EventId));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-7
@@ -11,13 +11,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<!--
|
<!-- Use the central SqlClient pin, matching the production EF dependency floor. -->
|
||||||
Bundle D D2 needs Microsoft.Data.SqlClient for the MsSqlMigrationFixture
|
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||||
(mirroring ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests). Pinning 6.1.1 here for
|
|
||||||
the same reason: EF SqlServer 10.0.7 needs >= 6.1.1 but the central pin
|
|
||||||
is 6.0.2 (production ExternalSystemGateway). Override is test-only.
|
|
||||||
-->
|
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||||
<!-- Security pin: GHSA-2m69-gcr7-jv3q. Microsoft.Data.Sqlite pulls the
|
<!-- Security pin: GHSA-2m69-gcr7-jv3q. Microsoft.Data.Sqlite pulls the
|
||||||
vulnerable native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not help. -->
|
vulnerable native 2.1.11; 2.1.12 patches it. Bumping Sqlite does not help. -->
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class CommandTreeTests
|
|||||||
InstanceCommands.Build(Url, Format, Username, Password),
|
InstanceCommands.Build(Url, Format, Username, Password),
|
||||||
SiteCommands.Build(Url, Format, Username, Password),
|
SiteCommands.Build(Url, Format, Username, Password),
|
||||||
DeployCommands.Build(Url, Format, Username, Password),
|
DeployCommands.Build(Url, Format, Username, Password),
|
||||||
DataConnectionCommands.Build(Url, Format, Username, Password),
|
BuildDataConnectionGroup(),
|
||||||
ExternalSystemCommands.Build(Url, Format, Username, Password),
|
ExternalSystemCommands.Build(Url, Format, Username, Password),
|
||||||
NotificationCommands.Build(Url, Format, Username, Password),
|
NotificationCommands.Build(Url, Format, Username, Password),
|
||||||
SecurityCommands.Build(Url, Format, Username, Password),
|
SecurityCommands.Build(Url, Format, Username, Password),
|
||||||
@@ -41,9 +41,30 @@ public class CommandTreeTests
|
|||||||
ApiMethodCommands.Build(Url, Format, Username, Password),
|
ApiMethodCommands.Build(Url, Format, Username, Password),
|
||||||
BundleCommands.Build(Url, Format, Username, Password),
|
BundleCommands.Build(Url, Format, Username, Password),
|
||||||
CachedCallCommands.Build(Url, Format, Username, Password),
|
CachedCallCommands.Build(Url, Format, Username, Password),
|
||||||
|
SecuredWriteCommands.Build(Url, Format, Username, Password),
|
||||||
FlowDebugCommands.Build(Url, Format, Username, Password),
|
FlowDebugCommands.Build(Url, Format, Username, Password),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static Command BuildDataConnectionGroup()
|
||||||
|
{
|
||||||
|
var command = DataConnectionCommands.Build(Url, Format, Username, Password);
|
||||||
|
ConnectionBrowseCommands.AddTo(command, Url, Format, Username, Password);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisteredOperationalGroups_IncludeSecuredWritesAndConnectionTools()
|
||||||
|
{
|
||||||
|
var groups = AllCommandGroups().ToList();
|
||||||
|
var secured = groups.Single(g => g.Name == "secured-write");
|
||||||
|
Assert.Equal("list", Assert.Single(secured.Subcommands).Name);
|
||||||
|
var connection = groups.Single(g => g.Name == "data-connection");
|
||||||
|
foreach (var name in new[] { "browse", "search", "verify-endpoint", "certs" })
|
||||||
|
Assert.Contains(connection.Subcommands, c => c.Name == name);
|
||||||
|
var certs = connection.Subcommands.Single(c => c.Name == "certs");
|
||||||
|
Assert.Equal(new[] { "list", "remove", "trust" }, certs.Subcommands.Select(c => c.Name).Order());
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<Command> LeafCommands(Command command)
|
private static IEnumerable<Command> LeafCommands(Command command)
|
||||||
{
|
{
|
||||||
if (command.Subcommands.Count == 0)
|
if (command.Subcommands.Count == 0)
|
||||||
@@ -62,11 +83,11 @@ public class CommandTreeTests
|
|||||||
{
|
{
|
||||||
var groups = AllCommandGroups().ToList();
|
var groups = AllCommandGroups().ToList();
|
||||||
// CLI-022: bump this count whenever a new top-level command group is
|
// CLI-022: bump this count whenever a new top-level command group is
|
||||||
// registered in Program.cs. Current registered groups (18):
|
// registered in Program.cs. Current registered groups (19):
|
||||||
// template, instance, site, deploy, data-connection, external-system,
|
// template, instance, site, deploy, data-connection, external-system,
|
||||||
// notification, security, audit-config, audit, health, debug,
|
// notification, security, audit-config, audit, health, debug,
|
||||||
// shared-script, db-connection, api-method, bundle, cached-call, flow-debug.
|
// shared-script, db-connection, api-method, bundle, cached-call, secured-write, flow-debug.
|
||||||
Assert.Equal(18, groups.Count);
|
Assert.Equal(19, groups.Count);
|
||||||
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -304,7 +304,18 @@ public class TopologyAreaTests : IClassFixture<DeploymentFixture>
|
|||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
var live = page.Locator("#live-updates");
|
var live = page.Locator("#live-updates");
|
||||||
if (await live.IsCheckedAsync()) await live.UncheckAsync(); // stop the 15s tree reload
|
if (await live.IsCheckedAsync()) await live.UncheckAsync(); // stop the 15s tree reload
|
||||||
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
|
// Lazy topology loads a bounded first page per branch. These small authoring
|
||||||
return page;
|
// fixtures need their sites/areas loaded before addressing labels or instances.
|
||||||
|
for (var branch = 0; branch < 300; branch++)
|
||||||
|
{
|
||||||
|
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
|
||||||
|
var load = page.GetByRole(AriaRole.Button, new() { Name = "Load instances", Exact = true });
|
||||||
|
if (await load.CountAsync() == 0) return page;
|
||||||
|
var pages = page.GetByText("Page 1", new() { Exact = true });
|
||||||
|
var count = await pages.CountAsync();
|
||||||
|
await load.First.ClickAsync();
|
||||||
|
await Assertions.Expect(pages).ToHaveCountAsync(count + 1);
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("Topology fixture exceeded 300 branches.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class LoginTests
|
|||||||
const resp = await fetch('/auth/login', {
|
const resp = await fetch('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: 'username=baduser&password=badpass',
|
body: 'username=baduser&password=badpass&__RequestVerificationToken=' + encodeURIComponent(document.querySelector('input[name=__RequestVerificationToken]').value),
|
||||||
redirect: 'follow'
|
redirect: 'follow'
|
||||||
});
|
});
|
||||||
return resp.status;
|
return resp.status;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
||||||
|
|
||||||
|
/// <summary>Read-only deployed smoke for CSRF login, bounded lazy topology, and debugger navigation.</summary>
|
||||||
|
[Collection("Playwright")]
|
||||||
|
public class LowReviewSmokeTests(PlaywrightFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LowReview_CsrfLogin_LazyTopology_AndFlowDebuggerNavigation()
|
||||||
|
{
|
||||||
|
var page = await fixture.NewAuthenticatedPageAsync();
|
||||||
|
var errors = new ConcurrentQueue<string>();
|
||||||
|
page.PageError += (_, error) => errors.Enqueue(error);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fixture fetch posts the real login form's antiforgery token with its cookie.
|
||||||
|
Assert.DoesNotContain("/login", page.Url);
|
||||||
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
|
||||||
|
await Assertions.Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Topology", Exact = true })).ToBeVisibleAsync();
|
||||||
|
var live = page.Locator("#live-updates");
|
||||||
|
if (await live.IsCheckedAsync()) await live.UncheckAsync();
|
||||||
|
await Assertions.Expect(page.Locator("i.bi-box")).ToHaveCountAsync(0);
|
||||||
|
await Assertions.Expect(page.GetByText("0 loaded instance(s)", new() { Exact = false })).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
var site = page.Locator("li[role='treeitem'][aria-level='1']").First;
|
||||||
|
await Assertions.Expect(site).ToBeVisibleAsync();
|
||||||
|
var toggle = site.Locator(":scope > .tv-row .tv-toggle").First;
|
||||||
|
if (await toggle.GetAttributeAsync("aria-expanded") == "false") await toggle.ClickAsync();
|
||||||
|
await site.GetByRole(AriaRole.Button, new() { Name = "Load instances", Exact = true }).First.ClickAsync();
|
||||||
|
await Assertions.Expect(site.GetByText("Page 1", new() { Exact = true })).ToBeVisibleAsync();
|
||||||
|
await Assertions.Expect(site.GetByRole(AriaRole.Button, new() { Name = "Previous", Exact = true })).ToBeDisabledAsync();
|
||||||
|
await Assertions.Expect(site.GetByRole(AriaRole.Button, new() { Name = "Next", Exact = true })).ToBeVisibleAsync();
|
||||||
|
Assert.InRange(await site.Locator("i.bi-box").CountAsync(), 0, 50);
|
||||||
|
await Assertions.Expect(page.Locator("#blazor-error-ui")).ToBeHiddenAsync();
|
||||||
|
await Assertions.Expect(page.Locator(".alert-danger")).ToHaveCountAsync(0);
|
||||||
|
|
||||||
|
await PlaywrightFixture.ExpandAllNavSectionsAsync(page);
|
||||||
|
await page.GetByRole(AriaRole.Link, new() { Name = "Flow Debugger", Exact = true }).ClickAsync();
|
||||||
|
await PlaywrightFixture.WaitForPathAsync(page, "/deployment/flow-debugger");
|
||||||
|
await Assertions.Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Flow Debugger", Exact = true })).ToBeVisibleAsync();
|
||||||
|
await Assertions.Expect(page.Locator("#blazor-error-ui")).ToBeHiddenAsync();
|
||||||
|
Assert.Empty(errors);
|
||||||
|
}
|
||||||
|
finally { await page.Context.CloseAsync(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,12 +112,17 @@ public class PlaywrightFixture : IAsyncLifetime
|
|||||||
// This sets the auth cookie in the browser context automatically.
|
// This sets the auth cookie in the browser context automatically.
|
||||||
var finalUrl = await page.EvaluateAsync<string>(@"
|
var finalUrl = await page.EvaluateAsync<string>(@"
|
||||||
async ([u, p]) => {
|
async ([u, p]) => {
|
||||||
|
const token = document.querySelector('input[name=__RequestVerificationToken]');
|
||||||
|
if (!token?.value) throw new Error('Login form did not render an antiforgery token.');
|
||||||
|
const body = new URLSearchParams({ username: u, password: p });
|
||||||
|
body.set(token.name, token.value);
|
||||||
const resp = await fetch('/auth/login', {
|
const resp = await fetch('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: 'username=' + encodeURIComponent(u) + '&password=' + encodeURIComponent(p),
|
body: body.toString(),
|
||||||
redirect: 'follow'
|
redirect: 'follow'
|
||||||
});
|
});
|
||||||
|
if (!resp.ok) throw new Error('Login returned HTTP ' + resp.status);
|
||||||
return resp.url;
|
return resp.url;
|
||||||
}
|
}
|
||||||
", new object[] { username, password });
|
", new object[] { username, password });
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -67,16 +73,51 @@ public class AuthEndpointsCsrfTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PostAuthLogin_StillDisablesAntiforgery_PreAuthIsAcceptable()
|
public async Task PostAuthLogin_ValidTokenAuthenticatesAndSetsCookie()
|
||||||
{
|
{
|
||||||
// Login is a pre-auth endpoint; disabling antiforgery there is acceptable
|
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
||||||
// and intentional. This pins that the fix did not over-correct.
|
var services = new ServiceCollection();
|
||||||
var endpoints = BuildEndpoints();
|
services.AddLogging();
|
||||||
var login = Find(endpoints, "/auth/login", "POST");
|
services.AddAntiforgery();
|
||||||
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
|
||||||
|
services.AddSingleton(new LoginThrottle(TimeProvider.System, Options.Create(new SecurityOptions())));
|
||||||
|
var ldap = Substitute.For<ILdapAuthService>();
|
||||||
|
ldap.AuthenticateAsync("alice", "password", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(LdapAuthResult.Success("alice", "Alice", []));
|
||||||
|
services.AddSingleton(ldap);
|
||||||
|
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
||||||
|
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new GroupRoleMapping<string>([Roles.Viewer], Scope: null));
|
||||||
|
services.AddSingleton(mapper);
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var tokenContext = new DefaultHttpContext { RequestServices = provider };
|
||||||
|
var tokens = provider.GetRequiredService<IAntiforgery>().GetAndStoreTokens(tokenContext);
|
||||||
|
var cookie = tokenContext.Response.Headers.SetCookie.Single()!.Split(';')[0];
|
||||||
|
var context = new DefaultHttpContext { RequestServices = provider };
|
||||||
|
context.Request.Headers.Cookie = cookie;
|
||||||
|
context.Request.Method = "POST";
|
||||||
|
context.Request.ContentType = "application/x-www-form-urlencoded";
|
||||||
|
var form = $"username=alice&password=password&{tokens.FormFieldName}={Uri.EscapeDataString(tokens.RequestToken!)}";
|
||||||
|
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(form));
|
||||||
|
await endpoint.RequestDelegate!(context);
|
||||||
|
Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode);
|
||||||
|
Assert.Equal("/", context.Response.Headers.Location.ToString());
|
||||||
|
Assert.Contains(context.Response.Headers.SetCookie, value => value!.StartsWith(".AspNetCore.Cookies="));
|
||||||
|
}
|
||||||
|
|
||||||
Assert.NotNull(login);
|
[Fact]
|
||||||
var antiforgery = login!.Metadata.GetMetadata<IAntiforgeryMetadata>();
|
public async Task PostAuthLogin_MissingTokenRejectsBeforeCredentialLookup()
|
||||||
Assert.NotNull(antiforgery);
|
{
|
||||||
Assert.False(antiforgery!.RequiresValidation);
|
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddAntiforgery();
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var context = new DefaultHttpContext { RequestServices = provider };
|
||||||
|
context.Request.Method = "POST";
|
||||||
|
context.Request.ContentType = "application/x-www-form-urlencoded";
|
||||||
|
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("username=test&password=password"));
|
||||||
|
await endpoint.RequestDelegate!(context);
|
||||||
|
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||||
|
|
||||||
|
public class RoutePolicyCoverageTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryRoutableComponent_HasItsReviewedPolicy_IncludingExternalSecrets()
|
||||||
|
{
|
||||||
|
var expected = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["/"] = "authenticated",
|
||||||
|
["/admin/api-keys"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/api-keys/create"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/api-keys/{KeyId}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/ldap-mappings"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/ldap-mappings/create"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/ldap-mappings/{Id:int}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/sites"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/sites/create"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/admin/sites/{Id:int}/edit"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/audit/configuration"] = AuthorizationPolicies.OperationalAudit,
|
||||||
|
["/audit/execution-tree"] = AuthorizationPolicies.OperationalAudit,
|
||||||
|
["/audit/log"] = AuthorizationPolicies.OperationalAudit,
|
||||||
|
["/deployment/debug-view"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/deployment/deployments"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/deployment/flow-debugger"] = AuthorizationPolicies.RequireFlowDebugger,
|
||||||
|
["/deployment/instances"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/deployment/instances/create"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/deployment/instances/{Id:int}/configure"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/deployment/topology"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/design/api-methods/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/api-methods/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/connections"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/connections/{Id:int}/certificates"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/design/connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/data-connections"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/data-connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/data-connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/db-connections/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/db-connections/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/external-systems"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/external-systems/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/external-systems/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/schema-library"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/shared-scripts"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/shared-scripts/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/shared-scripts/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/templates"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/templates/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/templates/{Id:int}"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/transport/export"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/design/transport/import"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/login"] = "anonymous",
|
||||||
|
["/monitoring/alarms"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/monitoring/event-logs"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/monitoring/health"] = "authenticated",
|
||||||
|
["/monitoring/parked-messages"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/notifications/kpis"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/notifications/lists"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/notifications/lists/create"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/notifications/lists/{Id:int}/edit"] = AuthorizationPolicies.RequireDesign,
|
||||||
|
["/notifications/report"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/notifications/sms"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/notifications/smtp"] = AuthorizationPolicies.RequireAdmin,
|
||||||
|
["/operations/secured-writes"] = AuthorizationPolicies.RequireSecuredWriteAccess,
|
||||||
|
["/site-calls/report"] = AuthorizationPolicies.RequireDeployment,
|
||||||
|
["/admin/secrets"] = "secrets:manage",
|
||||||
|
};
|
||||||
|
var actual = new Dictionary<string, string>();
|
||||||
|
foreach (var type in new[] { typeof(MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }
|
||||||
|
.SelectMany(a => a.GetTypes()))
|
||||||
|
{
|
||||||
|
foreach (var route in type.GetCustomAttributes<RouteAttribute>())
|
||||||
|
{
|
||||||
|
var anonymous = type.IsDefined(typeof(AllowAnonymousAttribute));
|
||||||
|
var authorization = type.GetCustomAttributes<AuthorizeAttribute>().ToArray();
|
||||||
|
Assert.True(anonymous || authorization.Length > 0, $"Missing authorization: {route.Template}");
|
||||||
|
actual.Add(route.Template, anonymous ? "anonymous" : Assert.Single(authorization).Policy ?? "authenticated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.Equal(expected.OrderBy(x => x.Key), actual.OrderBy(x => x.Key));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ internal static class ConfigurationMutationTestExtensions
|
|||||||
public static IServiceCollection AddTestConfigurationMutations(this IServiceCollection services)
|
public static IServiceCollection AddTestConfigurationMutations(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.TryAddScoped<ConfigurationCommandService>();
|
services.TryAddScoped<ConfigurationCommandService>();
|
||||||
|
services.TryAddScoped<AuthorizedManualFailoverService>();
|
||||||
services.TryAddSingleton<IAuditService>(_ => Substitute.For<IAuditService>());
|
services.TryAddSingleton<IAuditService>(_ => Substitute.For<IAuditService>());
|
||||||
services.TryAddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>();
|
services.TryAddSingleton<IConfigurationCommandTransaction, RepositoryTransaction>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user