47f0b98c6e0eeb663ee14d4e6fa063623c74408d
1451 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
47f0b98c6e |
chore(deps): pin ZB.MOM.WW.LocalDb 0.3.8 (scadaproj#57)
v2-ci / unit-tests (push) Has been cancelled
LocalDb 0.3.8 ends open sync streams on ApplicationStopping and refuses new ones Unavailable while stopping (lmxopcua#906, library side). The host-side ShutdownStreamCancellationInterceptor stays: it also covers the mesh telemetry/command streams. GracefulStopWithOpenSyncStreamTests' positive control started passing the stop in ~950 ms under 0.3.8, because the library alone now ends the stream. The control registers LocalDbSyncService without the host lifetime (the 0.3.7 behaviour), so it still reproduces the 8 s drain. Release build 0 warnings. Host.Tests 288/288, Host.IntegrationTests 350 passed / 2 skipped, Runtime.Tests 1063 passed / 3 skipped, Core.AlarmHistorian.Tests 53/53. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
c569f80694 |
fix(717): Warm crash survivor stays Active through auto-down; verdict reads its own events
Two defects on the crash-failover path, found by the T13 live gate on an isolated docker pair: 1. When auto-down removed the unreachable Primary (~15 s after the survivor promoted), the snapshot's Primary left the unreachable set, so (3') failed and the survivor demoted to Standby (BadOutOfService, ServiceLevel 50) for ~15 s, until its own singleton published after the hand-over retries. A Primary that is Down, or that this node saw removed, now counts as gone (WarmRoleAuthority.SnapshotPrimaryGone). A member that is merely Leaving/Exiting, or that this node never saw, is not. 2. The verdict read Cluster.State / SelfMember (Akka's ClusterReadView, a separate subscriber to the same events), which can lag the event being handled. Live, the survivor stayed Standby 4.3 s after its own UnreachableMember event (promotion waited for the next health tick). The Warm verdict now uses a membership view built from this actor's own member/reachability subscription, falling back to the read view only before the first event. Tests: WarmRoleAuthorityTests.A_snapshot_Primary_that_auto_down_removed_is_still_gone_and_the_survivor_stays_Active, OpcUaPublishActorWarmTests.The_crash_survivor_promotes_on_its_own_unreachable_event_and_stays_Active_through_the_removal (synthetic events the read view never sees). Mutations: read-view-only verdict and the removed arm switched off each fail the actor test. Runtime.Tests 1058 passed, 3 skipped. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
687256d68e |
test(717): deferred Warm coverage - 2-node handover, DataEncoding, deleted/reused items, clone matrix, ACL stand-down, per-counter metrics
v2-ci / unit-tests (push) Has been cancelled
- T3 WarmSingletonHandoverTests: real 2-node cluster, singleton under ClusterSingletonManager on both nodes; the old incarnation's held-back publish (older revision, higher sequence) reaches a real Warm OpcUaPublishActor after the handover and never demotes the new Primary. Pin: a removed node cannot rejoin from its old ActorSystem; a new one rejoins younger (higher up-number). - T6 DataEncoding: Binary/XML encoded reads of a structured tag projected on the Standby; scalar encoding error kept; a DefaultXml item is projected, then promoted through its own encoding (TypeId i=888). - T7 item deleted between queue and publish; ClientHandle reused by i=2267 on a payload recycled through the SDK 2.0.1 pool (precondition asserted); WarmStandbyCloneMatrixTests over DataChange+DiagnosticInfos/EventNotificationList/ StatusChange, clone/cached immutability, encode round-trip, real-pool stale marker. - T8 ACL-stood-down item receives nothing from demotion or promotion; restored grant reaches it again. - T10 per-counter MeterListener tests (by instrument instance): warm_transition, warm_role gauge, snapshot_rejected (publish actor both reasons + DriverHostActor's ordering gate, first actor test of it), projected_notifications, suppressed_alarm_events. Process-wide state tests run in non-parallel collections. - Every test mutation-checked (26 mutations killed); no product bug found. Plan status updated. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
f817c77e02 |
test(717): deferred-event boundary, ACL-filtered promotion refresh, native-ack refusal, effective-URI XML load; docs + T13 overlay
v2-ci / unit-tests (push) Has been cancelled
- Wire: an event reported while Active but deferred (#838) is delivered after demotion; the promotion refresh withholds an ACL-denied condition (positive control alongside); ConditionRefresh2 and model-change events on the Standby; a native Acknowledge is refused and never routed; Warm validates and advertises the EFFECTIVE ApplicationUri from an ApplicationConfigPath XML load. - docs/Redundancy.md: Warm section, the Unset/None i=11314 behaviour (orphan readable by NodeId under Unset, deleted under None), instruments; OpcUaServer.md, Configuration.md, security.md, cluster-topology rule. - docker-dev/docker-compose.warm.yml for the T13 gate (central pair, Mode=Warm). - Plan: implementation status and the test gaps left for T13/follow-up. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
7829b937d1 |
feat(717): Warm non-transparent redundancy, default-off (T1-T10)
OpcUa:Redundancy:Mode (Unset default = today's server exactly; None; Warm; Hot and unknown values fail start). - T1 options + validator + effective-URI check + boot banner; Warm needs the driver role. - T2 metadata in OnNodeManagerStartedAsync: Warm -> NonTransparentRedundancyType, read-only ServerUriArray [self, peers], RedundancySupport=Warm, boot ServiceLevel 1; None -> RedundancySupport=None and the SDK's orphan i=11314 deleted; Unset untouched. - T3 RedundancyStateChanged.Revision (up-number, incarnation tick, sequence); subscribers drop older revisions under Warm (OpcUaPublishActor, DriverHostActor); unrevisioned never makes a node Active. - T4 WarmRoleAuthority + ComputeWarm bands (Standby 90/50, Active 250/200/100), PromoteWhilePrimaryUnreachable. - T5 IStandbyProjection + DeferredStandbyProjection; OpcUaPublishActor runs promotion/demotion in the spec 4.7 order (Active level last and retried; Standby level first, data degraded regardless). - T6 EquipmentVariableState read projection (BadOutOfService/null); Warm Standby refuses writes BadInvalidState. - T7 D4 mechanism adopted after T0: ordinary-tag marker on MonitoredItemNotification.Message validated by (ClientHandle, DataValue); first-delivery decision keyed by (SubscriptionId, SequenceNumber); the SDK's cached copy is never mutated. - T8 queue current/BadOutOfService to live ordinary-tag items ignoring filters. - T9 alarm stamp/deliver split (suppressed on Standby), Part 9 methods refused on Standby, promotion refresh via ISubscription.ConditionRefresh2Async on every live event item. - T10 deploy-time RedundancyMode cross-check + six instruments. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
dcff5ae994 |
fix(host): leave the cluster before Kestrel drains and end open streams on stop (#906)
v2-ci / unit-tests (push) Waiting to run
A planned stop of the Primary took ~30 s on QA: WebApplication stops the web server before Akka.Hosting's service, and Kestrel's graceful drain waited the whole ShutdownTimeout for the peer's open LocalDb sync stream (the library's LocalDbSyncService only observes the call token, which fires at the END of the drain). Only then did CoordinatedShutdown run, so the stopping node held the Primary role and the peer sat at ServiceLevel 240 for the whole window. - GracefulClusterLeaveService (IHostedLifecycleService): StoppingAsync runs before any hosted service's StopAsync; it raises ApplicationStopping, then runs and awaits CoordinatedShutdown with ClrExitReason (not Cluster.Leave, whose ClusterLeavingReason makes Akka.Hosting set ExitCode -1). The cluster is left while Kestrel is still up. - ShutdownStreamCancellationInterceptor (innermost on the shared gRPC pipeline): every streaming call gets a context whose token is linked with ApplicationStopping; streams arriving once stopping are refused Unavailable. Host-side mitigation until LocalDb 0.3.8 is published and pinned (scadaproj 3ddb083, scadaproj#57); it also covers the mesh streams. Test GracefulStopWithOpenSyncStreamTests (real WebApplication + Kestrel h2c + MapZbLocalDbSync + single-node Akka cluster + real peer initiator, 8 s drain budget): stop 895 ms, cluster left at 892 ms, drain began at 894 ms. Positive control without the fix: stop 8863 ms, cluster left at 8841 ms after the drain began at 0 ms. Mutations: no CoordinatedShutdown in StoppingAsync -> order assertion fails (drain at 1 ms, removed at 893 ms); interceptor passes the original context -> stop 8006 ms, fails. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
f0c6cf9640 |
fix(security): running drivers refuse out-of-namespace credential refs at resolve time (#834)
v2-ci / unit-tests (push) Has been cancelled
The #815 warn-only compatibility window is over. A running Galaxy driver (InitializeAsync, every gateway ReopenAsync, the ack fallback password) and a running OpcUaClient driver (session open) now refuse an out-of-namespace credential reference with the same CredentialReferenceException that save, deploy (CredentialReferenceOutOfScope), Test Connect and browse already raise, before any store / environment / file read and before any endpoint is dialled. The one existing scope check (CredentialReferences.Parse/ResolveAsync) is now the only path; nothing new was added. Removed: - LegacyCredentialReferenceTracker (Core.Abstractions), its rate-limited WARN and the credential-compat:<field> diagnostics entry. - The per-driver Legacy* resolve methods and every `legacy` parameter (GalaxySecretRef, GalaxyAckCredentialResolver, OpcUaUserIdentityFactory, OpcUaUserCertificatePaths, OpcUaClientSecretResolution). - IDriverDiagnostics on GalaxyDriver / OpcUaClientDriver (it only carried the tracker's entries). - The tracker also kept a pre-#836 absolute UserCertificatePath loading on a running OpcUaClient driver; that is refused now too. Preflight / banner decision: KEPT, as a hard error. The Running source (driver-health credential-compat entries) and CredentialReferenceBannerModel are gone with the tracker, but the ConfigDb scan still tells an operator something no other surface does: a Deployed finding is now a driver that is not connecting, a Draft finding is a deploy the gate will refuse - named before anyone deploys, e.g. after restoring an old ConfigDb backup or importing a pre-#815 config. It also carries the #835 cross-instance and #836/#850 path checks. CredentialReferencePreflightService now logs one Error per finding (was Warning) with no warn-only wording, and the /raw banner is alert-danger. DEV and QA ConfigDbs hold no Galaxy/OpcUaClient driver (checked 2026-11-13); no production install exists. Tests: GalaxyDriverLegacyCredentialTests replaced by GalaxyDriverCredentialScopeTests; new OpcUaClientDriverCredentialScopeTests; tracker tests in OpcUaClientSecretResolutionTests / OpcUaClientUserCertificatePathTests deleted; AdminUI preflight tests drop the Running/banner-model cases and assert Error level. Against the old code the new refusal tests fail (Galaxy 5/6, OpcUaClient 4/5; the two positive controls pass on both). docs/security.md: the migration table now says running drivers refuse; the warn-only section is replaced by "The compatibility window is over". Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
35576485b1 |
fix(adminui): deployments drift chip never serves a stale "In sync" (#903)
v2-ci / unit-tests (push) Has been cancelled
Root cause: DeploymentDriftProbe (a singleton shared by every circuit) cached its verdict until the ConfigEdit audit watermark (MAX(EditedAtUtc), COUNT(*)) moved, assuming every configuration write appends a ConfigEdit row. Only deployments and driver restart/reconnect do; the AdminUI editors (RawTreeService, UnsTreeService, ScriptEdit, cluster/node/ACL pages) write the config tables directly. So after a draft change the page kept showing the cached "In sync" until something unrelated moved the watermark. - DeploymentDriftProbe: the watermark cache is gone; every verdict comes from a composition that started after the caller asked. Compositions stay serialised behind the gate, and callers queued behind one share a verdict only when that composition started after they arrived (start sequence number) for the same sealed hash, so sharing is exact, never stale. - Deployments page: the drift check runs in OnAfterRenderAsync (interactive render only, so a visit costs one composition, not two with prerender); the first render shows a neutral "Checking for changes..." chip, never a verdict; a failed check shows "Drift check failed" and logs a warning instead of crashing the page. A deploy re-runs the check. - Tests: probe tests for an un-audited draft change (real ConfigComposer over InMemory), fresh composition per call, and queued callers not reusing an older composition; bUnit page tests that a second page load after a direct draft change shows "Configuration drift" and that the first render shows "Checking" until the comparison answers. All fail against the previous code. DeploymentDriftProbeSqlServerTests is removed: it pinned the SQL translation of the watermark query, which no longer exists. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
b85e004bdb |
fix(adminui): Monaco dispose on a gone circuit is expected, not an error (#902)
Closing a tab with the script editor open ended the circuit; MonacoEditor.DisposeAsync then called MonacoBlazor.dispose through SafeInvokeAsync, which caught only JSDisconnectedException, so the TaskCanceledException from the torn-down circuit escaped and was logged at [ERR]. DisposeAsync now treats JSDisconnectedException, OperationCanceledException (incl. TaskCanceledException) and ObjectDisposedException as the expected gone-circuit outcome and logs them at Debug; a real JSException from a live page is still logged (Warning); the DotNetObjectReference is released in a finally. MonacoEditor is the only component in the repo that uses JS interop, so there is nothing else to align. bUnit tests: dispose throwing each of the three gone-circuit exceptions neither escapes nor logs above Debug (with a positive control that the failing call ran), and a JSException still warns. They fail against the previous DisposeAsync. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
efaf3435ea |
fix(historian): gateway AGGREGATE_NOT_SUPPORTED_FOR_TAG -> BadAggregateNotSupported, no health failure (#900)
v2-ci / unit-tests (push) Has been cancelled
A node declared numeric whose AVEVA tag is discrete (or string) is not refused up front by #897, so its native TimeAverage / Total still reach AVEVA, which refuses them. A HistorianGateway at or after historiangw#135 (a220ec0) answers that FailedPrecondition with a google.rpc.ErrorInfo (reason AGGREGATE_NOT_SUPPORTED_FOR_TAG, domain historian_gateway.v1) in grpc-status-details-bin. The Gateway backend's native processed read now maps exactly that to HistoryAggregateNotSupportedException (BadAggregateNotSupported for the node) without recording a health failure or treating it as transport. Any other FailedPrecondition and every Internal (an older gateway's answer for the same case) keep today's handling. Detection lives in one place, GatewayErrorReasons. The HistorianGateway.Client pin stays 1.0.0 (1.1.0 is not on the feed): 1.0.0 keeps the gateway's RpcException, trailers included, as the InnerException of the HistorianGatewayException it throws, so the reason is read with Grpc.StatusProto 2.76.0 (added via central package management; nuget.org; same version as Grpc.Core.Api; depends on Google.Api.CommonProtos 2.16.0, which accepts Google.Protobuf 3.34.1). With Client 1.1.0, ReasonOf becomes `(ex as HistorianGatewayException)?.ErrorReason` and the StatusProto reference can go. Tests: - GatewayAggregateNotSupportedWireTests: in-process gRPC gateway sending the real status + ErrorInfo shape (copied from historiangw HistorianRpcFaults.ToRpcException), read through the PUBLISHED Client 1.0.0 via the production adapter: TimeAverage / Total -> HistoryAggregateNotSupportedException, 0 health failures; controls (Internal; FailedPrecondition without ErrorInfo; other reason; other domain; the reason on Internal) are rethrown, not IOException, and counted; positive control served. - DiscreteTagTimeWeightedWireTests: over a real SDK session, a Double / Int32 node on the discrete tag -> BadAggregateNotSupported with 0 failures; the older gateway's Internal answer keeps its status and counts. - AvevaGatewayAggregateRefusalLiveGateTests: opt-in read-only live gate (OTOPCUA_AVEVA_GATE_900=true). Mutations (catch removed; status ignored; domain ignored; reason ignored; inner-exception walk removed) each fail at least one test. Release build 0 warnings; Historian.Gateway.Tests 601/607 (6 skipped), OpcUaServer IntegrationTests 323/361 (38 skipped). Live, read-only: private HistorianGateway container from a220ec0 (API-key auth disabled, live writes off, removed after) against wonder-sql-vd03: Double and Int32 nodes on SysPulse, TimeAverage and Total -> BadAggregateNotSupported, health failures unchanged (0); SysTimeSec TimeAverage Good (2 rows); SysPulse Average served. Gateway traffic was ReadAggregate / ReadRaw / GetTagMetadata only. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
6c4d29902e |
fix(historian): read-only historian bindings + ProvisionTags policy; provision OPC UA type names (#899, #898)
v2-ci / unit-tests (push) Has been cancelled
#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via historianTagname re-typed/re-described it on every add and host start. - TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly): HistoryRead from an existing historian tag that OtOpcUa never provisions and never records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid. AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the runtime parse. - ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly (Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start. - Default for an explicit historianTagname stays OWNED: overrides pin owned history across renames, a read-only default would silently stop provisioning + recording. #898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized Float/Double/Byte/SByte tags were never provisioned. New shared HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16 (lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject). UPGRADE: the first host start after this provisions (upserts) every existing historized Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
ee39c169df |
fix(historian): refuse aggregates not valid for the node's DataType up front (#897)
v2-ci / unit-tests (push) Has been cancelled
TimeAverage / Total on a Boolean node over the Gateway backend (AVEVA discrete tag, live SysPulse) went to the gateway since native TimeAverage/Total became default-on (#694). AVEVA refuses both modes at StartQuery; the gateway reports that only as Internal / HistorianServerOperationException (the same answer as a historian storage fault), so the node answered BadHistoryOperationUnsupported and the read counted as a backend failure. HistoryRead-Processed now checks the node's scalar DataType before calling any backend and answers BadAggregateNotSupported (Part 11 6.5.4: "If the Aggregate is not valid for the Node then the StatusCode shall be Bad_AggregateNotSupported"). Nothing is sent, nothing is counted. - Shared, every backend: HistoryAggregateDataTypes (Core.Abstractions) applies Part 13 5.4.2.3 Table 50: the numeric-only aggregates (Average, TimeAverage(2), Total(2), Min/Max, ActualTime, Range, Delta, DeltaBounds, Interpolative, stddev/variance) on a String or DateTime node are refused. Client-visible: the SQL backends used to answer every interval BadAggregateInvalidInputs. - Per backend: IHistorianDataSource.SupportsAggregateFor(aggregate, TypeCode?) (default true; every production backend declares it, guard-tested). Gateway: false for native TimeAverage/Total on a Boolean node. SqlServer/QuestDb: true, since they serve Boolean as 0/1 (stepped), so Boolean stays served there, as do the Gateway's raw-evaluated aggregates. The residual case (a node declared numeric whose AVEVA tag is discrete) cannot be told apart from a historian fault on the wire and still counts; that needs the gateway to surface AVEVA's native error code as a deterministic status. Tests: HistoryAggregateDataTypesTests, guard test, node-manager String/DateTime and per-type refusals, Gateway capability matrix, DiscreteTagTimeWeightedWireTests (production Gateway backend over an AVEVA-shaped fake: unsent, 0 health failures, positive controls). Each mutation (no check, no shared rule, no backend hint, Gateway hint true, shared rule always true, SQL inheriting the default) fails at least one. Live gate re-run read-only against AVEVA 2023 R2 via a private HistorianGateway fa9cf31 container: SysPulse TimeAverage on the wire = BadAggregateNotSupported, health failures unchanged; #801 gate 244 cases pass. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
53978ca638 |
feat(historian-gateway): native TimeAverage/Total on by default, SendEvents batches, server-side max_events (#694, #746)
#694 item 3 — historiangw#27 is fixed and now live-verified through OtOpcUa: - ServerHistorian:Gateway:EnableNativeTimeWeighted defaults to true (false is the kill switch for a gateway older than 9f711c1). TimeAverage / Total are served and advertised again (12 aggregates). - The gateway sends the interval as max(1, round(ms)) and AVEVA clips its last interval at the request end (live). NativeAggregatePlan therefore sends a multi-interval grid only for whole-ms intervals; a fractional interval is read one interval per request (at most 1,000, else BadTooManyOperations), and every one-interval request goes out rounded UP to whole ms so AVEVA answers one clipped row. Live, a 3.0004 s clipped interval sent at its own width lost its last 0.4 ms (Total 123 vs 123.0172). #746 — HistorianGateway Contracts/Client 1.0.0 surface: - Alarm history: GatewayAlarmHistorianWriter sends SendEvents batches of <=200. A failed per-event result is re-sent alone via SendEvent (classified as before); a refused batch (InvalidArgument etc.) goes event by event so a poison event dead-letters alone; a systemic fault retries the rest without sending more; Unimplemented (older gateway) falls back to SendEvent for good. IHistorianGatewayClient.SendEventsAsync defaults to Unimplemented. - Events reads: a capped page sends cap + 1 as ReadEventsRequest.max_events, so the gateway completes the stream instead of the client cancelling it. An ambiguous capped answer (foreign rows filtered out of the server's cut) or a refused cap is re-read uncapped with the existing EventDrainBudget drain. Tests: GatewayServerCappedEventsTests, GatewayAlarmWriterBatchTests, GatewayNativeWholeMillisecondGridTests (gateway-faithful rounding fake), gate tests flipped to the new default; every new test mutation-checked (each mutation of the production change fails at least one). Live harness AvevaGatewayHistorianFollowupsLiveGateTests (read-only) for #694/#700/#746. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
09b373e4db |
fix(address-space): no UNS value Variable for a reference to an alarm-bearing tag (#896)
v2-ci / unit-tests (push) Has been cancelled
A UnsTagReference to a raw tag that carries an "alarm" object produced a UNS
value Variable that read BadWaitingForInitialData forever (G6 live gate,
TestAlarm002Condition on testmachine-015). BuildUnsReferenceVariables now skips
those references; the equipment gets the alarm only as the raw condition wired
as an event notifier of its folder, which is unchanged (the reference still
feeds RawTagPlan.ReferencingEquipmentPaths).
Why condition-only and not "feed the mirror":
- The raw side has no value node for such a tag: AddressSpaceApplier
materialises an alarm tag as a condition OR a value variable, never both.
There is no raw value for the UNS mirror to mirror.
- DriverHostActor skips alarm tags when it builds the value fan-out and write
routing maps (`continue` before the UNS registration), and the driver leaves
them out of its value subscription ("subscribed to 5 refs" with 6 tags in
G6). Feeding the mirror would mean subscribing a value the design says the
tag does not have.
- Docs already state condition-only: ScriptedAlarms.md ("instead of a value
variable"), AlarmTracking.md ("only the condition, with no sibling value
variable"), the v3 design (native alarm = one condition at the raw tag,
surfaced at equipment via notifiers).
- The composer tests that asserted the mirror were incidental:
|
||
|
|
fa8c3d1a1a |
fix(adminui): bodyless POST /api/deployments answered 404 on fused admin+driver nodes (#761)
Found on the docker-dev rig during the #761 Wave-4 gate run (2026-11-12): the documented bodyless headless deploy (docker-dev/deploy.sh, README "Headless deploy") returned 404 on both centrals, while the same POST with a JSON body was accepted. Cause: the optional body was an inferred `DeployRequest?` parameter, which stamps Accepts(application/json) metadata on the endpoint. For a request with no Content-Type the routing AcceptsMatcherPolicy prefers endpoints that declare no consumes metadata, and on a fused admin+driver host MapOtOpcUaMeshGrpcServices has mapped grpc-dotnet's `{unimplementedService}/{unimplementedMethod}` catch-all, so the request was routed away from the deploy handler and 404'd. The deploy API on its own does not reproduce it. The handler now reads the optional body itself: no body (absent or zero-length) is the headless form, a non-JSON body answers 415 and malformed JSON 400, as the framework's binding did. New DeployApiBodylessPostTests compose the deploy API with the mesh gRPC services and drive real HTTP; with the fix reverted the two bodyless cases fail with 404. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
412da27c5f |
feat(telemetry): Telemetry 0.3.0 - service.version, site.id/node.role, logs over OTLP (scadaproj#55)
v2-ci / unit-tests (push) Has been cancelled
Pins ZB.MOM.WW.Telemetry(.Serilog) 0.1.0 -> 0.3.0. The OTel Resource now names the node: site.id = its application Cluster id (the cluster-<ClusterId> role suffix, omitted on a legacy node), node.role = its plain roles in canonical order (admin / driver / admin,driver), service.version = the entry assembly's informational version (the git SHA). Program passes its resolved EffectiveRoles. The whole OtOpcUa:Telemetry section goes through the shared ApplyConfiguration, so SiteId / NodeRole / ServiceVersion / ResourceAttributes:* are operator overrides, and Exporter=Otlp now also sends the Serilog logs to the same collector. ObservabilityResourceIdentityTests cover the mapping and the built container's Resource. Host.Tests 288/288, Host.IntegrationTests 325/327 (2 skipped). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
0b90379f70 |
fix(telemetry): export the Galaxy driver's spans and galaxy.events.* counters (#892)
ZbTelemetryOptions.Meters/ActivitySources are strict allowlists. The host named only ZB.MOM.WW.OtOpcUa and the LocalDb meter, so the in-process Galaxy driver's galaxy.* spans and its event pump's galaxy.events.* counters (both on "ZB.MOM.WW.OtOpcUa.Driver.Galaxy") were silently dropped from /metrics and OTLP. Found exercising OTLP end to end against a collector (scadaproj#13). - Galaxy driver: public GalaxyDriverTelemetryNames (MeterName / ActivitySourceName); GalaxyTelemetry and EventPump reference it. - Host: ObservabilityExtensions.ObservedMeters / ObservedActivitySources include it. - Host.IntegrationTests: ObservabilityAllowlistTests assert on the listener side (span sampled, instrument enabled) with an unregistered-name negative control; dropping the Galaxy entries fails 3 of 8. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
f75ee7d089 |
fix(scripting): ScriptWorker isolation denies the worker every sensitive sibling, not just app\ (#890)
v2-ci / unit-tests (push) Waiting to run
The installer denied NT SERVICE\OtOpcUaScriptWorker only on -InstallRoot. On the wonder layout the node's keys and secrets sit beside it (E:\ApiInstall\OtOpcUa\pki with the application certificate's private key, data\ with secrets.db and LocalDb), inheriting BUILTIN\Users:RX. Users is a restricting SID of the worker token, so a worker could read them. - Install-ScriptWorkerIsolation.ps1 -DenyPaths <dir>,...: each folder gets the same inherited deny as InstallRoot (new SensitiveDirDeny steps). Refuses drive/share roots, script-worker\ and the PE cache; folders must exist; a comma/semicolon string works for powershell -File. - The deny is read AND write (0xD0157: ReadData, WriteData, AppendData, WriteEA, DeleteChild, WriteAttributes, Delete, WRITE_DAC, WRITE_OWNER). Traverse/Execute, ReadAttributes, ReadControl and Synchronize stay so the loader still reaches script-worker\. The write half stops a worker planting a cert in pki\trusted\certs where a data drive lets Users create files in subfolders. - The list is recorded under the anchor service's key (Parameters\DenyPaths, step DenyPathRecord): a re-run removes the deny from folders dropped from the list, -Uninstall removes every recorded one without repeating -DenyPaths. -WhatIf/-PlanOnly print every step. - After an apply, WARN for each protected-DACL item under a denied folder that the inherited deny cannot reach; without -DenyPaths, WARN for pki/data/logs/backup/config/secrets siblings of InstallRoot. Tests: - Dry-run harness 46 -> 86 assertions (QA layout plan, mask bits, refusals, stale removal, record-driven rollback, host account never denied). Mutations (no sibling deny, deny->allow, mask back to ReadData, no stale removal, rollback ignoring the record, script-worker not refused) each turn it red. - Test-ScriptWorkerIsolation.Windows.ps1 (Windows PowerShell 5.1, windev): 34/34, incl. -DenyPaths apply, 11/11 unchanged re-apply, shorter-list re-apply, uninstall from the record, second uninstall unchanged. - New InstallerLayoutIsolationTests (OTOPCUA_ISOLATION_INSTALLER_E2E=1, elevated Windows): a scratch wonder layout under the real restricted worker token. Before install and after uninstall the token reads pki/data/ logs/backup/appsettings and plants a trust-list file; installed it can do neither, still reads script-worker\, a real worker launched from the layout runs a script, and the host still reads everything. Mutating the -DenyPaths denies to a no-op mask turns it red on pki\own\private\otopcua.pfx (reproduces #890). QA vq03/vq04 are NOT re-applied; the command is in docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
aa60b3e34e |
test(soak): #799 place the soak's UNS folder after #808; Windows x64 port of the lifecycle soak harness
v2-ci / unit-tests (push) Has been cancelled
Acceptance runs for #799 (owner scope 2026-11-12: Windows x64 + Linux) on current master. Fixture: since #808 an UNS folder the authority snapshot does not place is denied under enforcement (fail closed). The canonical soak creates its own "SoakEquipment" folder but never placed it, so on master the very first cycle's UNS monitored item came back bad (retained failed Linux launch on |
||
|
|
b61f3e0560 |
fix(galaxy): AVEVA priority 1 is the most urgent: Galaxy alarm Severity follows AVEVA's priority bands, live and history alike (#887)
v2-ci / unit-tests (push) Has been cancelled
GalaxyAlarmPriority, the one table live Galaxy alarms (MxAccessSeverityMapper) and AVEVA A&E history (EventMapper, #886) map the AVEVA priority through, ranked a higher number as more severe. AVEVA ranks priority 1 as the most urgent, so a priority-1 alarm read 250 and a priority-999 system event 1000. It now uses AVEVA's default priority ranges, most urgent first: AVEVA priority AVEVA severity bucket OPC UA Severity 1-250 1 Critical Critical 1000 251-500 2 High High 750 501-750 3 Medium Medium 500 751-999 4 Low Low 250 (history only) 0 not an alarm - 1 Out-of-range priorities clamp (<= 0 Critical, > 999 Low). Evidence, vd03 Runtime.dbo.Events read-only: priority 1/100 alarms carry AVEVA severity 1, 400/500 severity 2, 600/700 severity 3, and every priority-999 System.* / User.* row severity 0 with IsAlarm 0. - Live and history now use the same number. GalaxyDriver passes the table's exact value as AlarmEventArgs.OpcUaSeverity. Before, it passed only the bucket, which NativeAlarmProjector re-mapped through its generic 200/500/700/900 ladder, so live and history disagreed even before the direction fix. - History: a row whose AVEVA severity is 0 (System.Start/Stop/Deploy, User.Write...) is not an alarm and reads Severity 1, below every alarm band. For alarms AVEVA's 1-4 severity is still never the value: the live feed carries only the priority, so it cannot use it, and history must agree. - Docs: docs/Historian.md gains "Galaxy alarm priority -> Severity (lmxopcua#887)"; docs/AlarmTracking.md updated. - Live re-run of the #801/#886 harness against vd03 HistorianGateway ea9f21c (read-only, network-only): 224 of 233 cases identical to the #886 run, the 9 others differ only in Severity (priority-700 alarm rows 750 -> 500, 19 system/user events 1000 -> 1). Record: aveva-history-801.md "#887 re-run", report aveva-gate-sdk2-887.json.gz. Client-visible: every OPC UA client that ranks or filters Galaxy alarms by Severity sees the order reversed (live and HistoryReadEvents). ScadaBridge has no code that depends on the old values; its own mixed severity directions are ScadaBridge#129. Tests updated because they pinned the inverted table: - Driver.Galaxy.Tests MxAccessSeverityMapperTests (rewritten: edges 1/250/251/ 500/501/750/751/999, 0/-100/int.MinValue/1000/int.MaxValue clamps, monotonic-decreasing sweep, equals GalaxyAlarmPriority for -2..1001) - Driver.Galaxy.Tests GatewayGalaxyAlarmFeedTests (750 and 600 -> Medium) - Driver.Historian.Gateway.Tests EventMapperTests (band table, priority 999 -> 250, 1 -> 1000; system events -> 1) - OpcUaServer.IntegrationTests GalaxyEquipmentEventHistoryWireTests (700 -> 500, 100 -> 1000, System.Stop -> 1; the fake now stores AVEVA's real 0-4 severity) New tests: - Runtime.Tests NativeAlarmRestatementEndToEndTests .Galaxy_live_condition_severity_is_the_AVEVA_priority_band (8 rows: the real feed -> real GalaxyDriver -> host -> projector; the same rows EventMapperTests pins on the history path) - EventMapperTests.Aveva_severity_zero_marks_an_event_that_is_not_an_alarm (5 rows, incl. the priority-999 alarm positive control) - GalaxyDriverAlarmSourceTests: OpcUaSeverity passes through Mutation check, 8 mutants, all killed: the pre-#887 inverted table; the driver dropping OpcUaSeverity; no not-an-alarm rule; each of the three band edges off by one; history ignoring priority; a missing AVEVA severity read as 0. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
fa811feb3d |
fix(opcua): drop the reflective folder-event verdict invalidation; deny the SDK channel under enforcement (#878)
v2-ci / unit-tests (push) Waiting to run
The SDK memoizes INodeManager3.ValidateEventRolePermissions per (item, event type, SourceNode). #826 kept it fresh by reflecting into the private MonitoredNode2.InvalidatePermissionCaches (plus a detach/re-attach fallback). Since #855 condition events never take that channel, and the search for #878 found nothing production-raised that does except a condition's SDK method audits, which reach it only with auditing on, SignAndEncrypt and a SecurityAdmin session (nothing grants that role). Under enforcement the channel verdict is now a flat deny for items on our nodes, so no cached verdict depends on a grant, and InvalidateFolderEventVerdicts, the reflection, the fallback, its counter and the guard test are removed. Method audits still reach Server-object items, decided per event. Tests: FolderEventVerdictInvalidationTests -> FolderEventVerdictFreshnessTests; new revoked-grant test (positive control: a still-granted session) and method-audit test (SecurityAdmin mapped for the test; positive controls: Server-object item, flag off); the plain-event test runs each mode on a fresh server. Mutation-checked. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
3a312ffdd1 |
fix(history): a folder's event history reads the Galaxy objects behind its native conditions (#886)
v2-ci / unit-tests (push) Has been cancelled
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event history reached no production folder. Its lower-case property keys were also missed, so every row read Severity 1 with no ConditionName. Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef, up to the first '.': - The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality). - The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory, forwarded by the deferred sink). A rebuild or the condition's removal forgets it. Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling. Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire contract is unchanged. EventSourceFanOut merges the reads as one source: - order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId, so the boundary skip stays stable; - duplicates by EventId are dropped and the merge is cut to the cap; - a truncated source bounds the page at the time it stopped. Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source is read exactly as before. ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied folder reads nothing and fails closed. Mapping (EventMapper, property names now case-insensitive): - Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a higher priority as more severe, which inverts AVEVA's convention: filed as #887. - An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition variable's attribute. - alarm_id/alarmid map to AlarmId. Tests: - GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page, reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend call plus a granted positive control, and a rebuild forgetting the bindings. - EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and an applier ordering test. - Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise guard survives as belt-and-braces: the rebuild clear covers it. - Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition. diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity 1 -> 750/1000, and #881 capabilities. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
a83bf1fa27 |
fix(opcua): a configured OpcUa:EnabledSecurityProfiles list replaces the default, so None can be dropped (#879)
v2-ci / unit-tests (push) Has been cancelled
ConfigurationBinder appends configured collection items to an existing list, so the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op: every deployment that listed only the secure profiles still advertised an unsigned/unencrypted None endpoint. EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles() applies the unchanged three-profile default only when nothing is configured. A set-but-empty list fails startup validation and BuildSecurityPolicies no longer falls back to a None endpoint on empty input (it throws). Sweep of all options classes found no other non-empty pre-filled collection default. Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a booted-server wire test (no None endpoint, None connection refused with BadSecurityPolicyRejected, secure connection works; default case is the control). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
be28aa4432 |
fix(opcua-client): send UserName passwords in the legacy RSA format so 65-char API keys log in to SDK 1.5 servers (#880)
SDK 2.0.1's UserNameIdentityTokenHandler encrypts a password longer than 64 bytes
(a private RsaEncryptedSecretPasswordThreshold, no setting) as an RsaEncryptedSecret
with EncryptionAlgorithm=null. SDK 1.5.378 (.106 and .182) decrypts only the legacy
format under RSA policies; its SecurityPolicies.Decrypt answers a null algorithm with
BadIdentityTokenInvalid ("Unexpected encryption algorithm") before any key check.
Every production API key is 65 chars, so no SDK 2 OtOpcUa client could use one
against QA (
|
||
|
|
3fff439b11 |
fix(redundancy): advertise ServiceLevel 0 until the node has a redundancy level (#885)
v2-ci / unit-tests (push) Has been cancelled
The SDK starts Server.ServiceLevel at 255 (full service), and OpcUaPublishActor publishes nothing until the node's first redundancy snapshot arrives, so a restarting pair node advertised 255 for the ~10-13 s it took to join, out-ranking the real Primary (250) for any client that picks the highest ServiceLevel. OtOpcUaSdkServer now writes StartupServiceLevel = 0 in OnNodeManagerStartedAsync: after the SDK's CreateServerObjectAsync wrote 255 and before the server goes Running, so no session can ever read 255. 0 is the design's existing Down / Detached tier (ServiceLevelCalculator returns 0 for a member that is not Up/Joining, and a node with no snapshot has no role yet). It is below the SDK 2 session-headroom floor (200), so it stands through session churn before the #884 claim, and the claim's bind replays any level already requested. Single-node deployments are unaffected beyond the window: the first snapshot publishes 250 as before. New Node_with_no_requested_level_advertises_the_startup_level_until_one_is_published: reads 0 before the bind, 0 after a bind with nothing to replay, then 250 once published. Without the startup write it reads 255 on the first session (mutation-checked). docs/Redundancy.md updated for #884 and #885. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
5e3fb4229d |
test(redundancy): exercise SessionClosing in the #884 ServiceLevel claim test
v2-ci / unit-tests (push) Has been cancelled
The claim test disposed its sessions, which may leave them to time out rather than raise SessionClosing, so only the SessionCreated half of the SDK's session-headroom overwrite was proven. Sessions now close explicitly, a later level (230, inside the SDK's >= 200 overwrite band for every case, where the old level-10 left the 200 case at 190 and so unexercised) is read back on a surviving session after another one closes on its own. Probed without the claim: the close-only read fails (255) in all three cases. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
f66b9daec0 |
fix(redundancy): claim Server.ServiceLevel so SDK 2 stops overwriting 250/240 with 255 (#801)
SDK 2.0.1 recomputes Server.ServiceLevel from session headroom on every session create/close and overwrites any value >= 200 unless a provider has claimed it through IServerServiceLevelControl.ClaimServiceLevelControl. SdkServiceLevelPublisher never claimed it, so on the docker-dev pair both nodes read 255 as soon as a client connected while their own metrics showed 250 (Primary) and 240 (Secondary); the Stale tier's 200 was lifted to 255 too. Levels below 200 (100, 0) were untouched, which is why the bootstrap 100 showed briefly. The publisher now claims the value at construction and writes through the SDK's writer; a server without the claim (test doubles) keeps the diagnostics-guarded write. New Published_level_survives_later_sessions (250/240/200) fails without the claim (reads 255) and passes with it. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
da948e5fb5 |
fix(history): a folder's event history includes its object's attribute alarms (#882)
v2-ci / unit-tests (push) Has been cancelled
HistorianGateway answers a per-source ReadEvents (Source_Object = DevWinPlatform) with the object's own events (SourceName DevWinPlatform) AND its attribute alarms, which it reports as <Object>.<Attribute> (Source_ProcessVariable, e.g. DevWinPlatform.CPULoadAvg). The Gateway backend's defensive client-side source filter compared SourceName for equality, so it dropped the attribute alarms: 70 of the 72 DevWinPlatform events on vd03. The filter (and the past-the-cap "more events" signal) now uses EventSourceMatch: the source itself, or <source>.<anything> on the '.' boundary, ordinal. DevWinPlatform never matches DevWinPlatform2 or DevWinPlatform2.X. This needs no wire change and still makes one request. Each event keeps its own SourceName. SourceNode stays the condition its stored alarm id names, which is null for AVEVA rows. The attribute events ride the notifier's own read, so the per-folder HistoryRead verdict (#826) governs them: a denied folder answers BadUserAccessDenied before any backend call. The SqlServer and QuestDB backends still match exactly; they hold only OtOpcUa's own events. Tests: - GatewayAttributeEventSourceTests (16): the boundary theory, attribute-in/sibling-out through the data source, the drain signal, and an exact attribute-named read. - AttributeEventHistoryWireTests (2, over a real SDK session with the production Gateway backend on an AVEVA-shaped deterministic gateway): object + attribute events with their own SourceName, a prefix sibling excluded, paging, and the ACL-on deny with no backend call plus a granted positive control. - Mutation-checked: exact match fails both wire tests; dropping the '.' boundary fails the wire sibling check and 6 unit tests. - Live AVEVA harness re-run against vd03 HistorianGateway ea9f21c (read-only, no WinRM): folder DevWinPlatform 2 -> 72 events (whole, 72 1-event pages, reverse); the new #882 check passes; 221 of 225 shared cases are identical to the #801 SDK2 baseline, and the 4 that differ are exactly the DevWinPlatform event cases. Recorded in aveva-history-801.md. Follow-up #886: production folders name RawPaths/EquipmentIds, not Galaxy objects, plus the AVEVA severity/condition property mapping. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
b27b5b9cc1 |
fix(history): advertise HistoryServerCapabilities from the wired backend (#881)
v2-ci / unit-tests (push) Has been cancelled
Every Boolean under Server/ServerCapabilities/HistoryServerCapabilities read false (the SDK default) even though data and event history were served, so a spec-following client skipped HistoryRead entirely. Each backend now declares what it serves through its own interface: - IHistorianDataSource.ReadCapabilities (Data / Events / ServerTimestamps): Gateway Data|Events; SqlServer + QuestDb add ServerTimestamps (they store ServerTimeUtc, the Gateway stamps read time); Null declares None. A reflection guard requires every production source to declare it itself. - IHistorianDataUpdater.Capabilities (required member): SqlServer and QuestDb declare Insert/Replace/Update/DeleteRaw/DeleteAtTime; the Gateway provider has no updater. The node manager writes the flags on every source/updater assignment (AdvertisedHistoryCapabilities), and the same declarations drive dispatch: an update the updater does not declare is BadHistoryOperationUnsupported without reaching it, the HistoryWrite AccessLevel bit follows a non-empty declaration, and event-history notifier promotion follows the Events flag. Event and annotation update flags stay false (nothing dispatches them). Tests: booted-server wire test per backend + Null + a SqlServer->Gateway rewire pairing on one session; dispatch gating unit test; the #801 live gate now asserts the Gateway flags (not run here). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
f607ee7502 |
test(historian): #801 real-AVEVA history gate over the SDK wire (env-gated)
AvevaGatewayHistoryLiveGateTests boots the server in-process, wires the production Gateway backend (HistorianBackendRegistry -> GatewayHistorianDataSource) to a real HistorianGateway and drives HistoryRead over a real SDK session: raw (all, paged, reverse, bounds), modified, the ten served aggregates and seven refused ones, AtTime, events (all, 1-event pages, reverse), a missing tag, HistoryServerCapabilities and node metadata, and a HistoryUpdate that must be refused (sandbox-bound node only). Every wire answer is compared with the backend's direct answer; the run is dumped as JSON for cross-SDK and SQL comparison. Skips unless HISTGW_GATEWAY_ENDPOINT and HISTGW_GATEWAY_APIKEY are set; read-only by construction. scripts/validation/aveva-history-801: run/compare instructions, the SDK 1.5.378 baseline port patch (API spelling only), and the diff / SQL / aggregate / histgw-cli comparison scripts. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
d8a000a409 |
perf(scripting): #789 profile the Windows misses; add the batched paced row; nothing kept in the runner
v2-ci / unit-tests (push) Has been cancelled
Profiled the remaining lmxopcua#789 misses on windev, a VMware VM, using per-thread CPU and context-switch accounting plus ETW CPU sampling. - One blocking hand-off costs ~26 us of latency and ~18 us of CPU there, about 10x macOS. - An idle evaluation makes four hand-offs in a row: host writer -> worker reader -> evaluation thread -> host reader -> dispatcher. - 48% of host CPU at 10k/s is in the kernel: the per-RunAsync NtWriteFile, thread-pool wakes, reads and waits. Spin-before-block was measured at every hand-off and is not kept. It meets 150 us idle p50 (118 us) only by keeping about two host cores and four worker cores busy for one stream. At 10k/s it reads +250 points. Harness: new "paced 10,000/s, batched" row. It uses the same rate and keys, with each 1 ms tick submitted as one RunBatch call, which is the production seams' shape. It runs after the original four phases, so their rows stay comparable. On windev it halves host CPU, to +36..+45 points against +71..+84 unbatched. That is still above 25. Design doc: - 12.8: the record. - 14 #15: the owner's decision on the Windows budgets. Recommended: measure on bare-metal Windows, then state the two budgets per platform, with the batched row as the gate. The alternative is a shared-memory transport. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
e5a03e3cce |
test(acl): pin the fail-closed rule for non-condition events on folder items (#838 item 3)
v2-ci / unit-tests (push) Waiting to run
A folder event item under enforcement withholds an event that names no live condition; flag off, the SDK's own type + SourceNode check applies. Positive controls: the same event reaches the item flag-off and a condition event reaches it flag-on. Mutation-checked: IsNotifierEventVisibleTo returning true for a non-condition event fails the test. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi |
||
|
|
f127f45428 |
fix(opcua): don't register with a discovery server unless configured (#876)
v2-ci / unit-tests (push) Has been cancelled
OpcUaApplicationHost left ServerConfiguration.MaxRegistrationInterval at the SDK default (30000) with a null RegistrationEndpoint, so SDK 2.0.1's StandardServer armed its registration timer against opc.tcp://localhost:4840, which on an OtOpcUa node is the server itself (no LDS). It answered BadServiceUnsupported and the SDK retried forever, visible since #874. Registration is now opt-in: ApplyDiscoveryRegistration sets MaxRegistrationInterval = 0 (the value that keeps StandardServer from arming the timer) unless OpcUa:DiscoveryServerUrl is set, in which case the server registers against that URL every OpcUa:DiscoveryRegistrationIntervalMs (default 30000) over SignAndEncrypt/Basic256Sha256, like the SDK's own default endpoint. The validator requires an absolute opc.tcp:// URL and a positive interval when the URL is set. Documented in Configuration.md and OpcUaServer.md. Co-Authored-By: Claude <noreply@anthropic.com> |
||
|
|
03ac57d5d6 |
fix(scripting): #789 review — reader faults end the worker, write stalls kill it, Cancel off the worker reader
- Host reader: the frame read and its handling are caught separately. Only the read's I/O exceptions mean the pipe ended; anything HandleFrame's inline completion work throws now kills that worker as ProtocolFault (outstanding runs -> worker restarted / BadResourceUnavailable) instead of escaping a dedicated thread (process crash) or being swallowed as a clean end (an InvalidOperationException used to be, leaving the worker unread until a Crash 2 s later). - Worker reader: the same catch-all around HandleFrame (exit ProtocolFault). - Worker: Cancel() runs on the pool, so a script's blocking ctx.CancellationToken callback cannot stop the worker reading its pipe. - Host write-stall check: ScriptWorkerFrameWriter.WriteInProgressFor; the heartbeat monitor kills a worker whose pipe write has been blocked longer than max(10 s, silence limit) as KilledUnresponsive, which fails the write and releases the producer (seam submit, deploy load, Cancel). Fixes the wrong 'heartbeat monitor ends it' comment on the pipe buffer quota. - StopAsync sends Shutdown off the caller so its 1 s budget holds on a full pipe. - WindowsBlockingPipeStream refuses the async surface (Stream's base versions serialise a write behind a waiting read). - Tests: PipeFaultTests (throwing completion callback -> ProtocolFault and a BadResourceUnavailable sibling, shard serves again; a worker that stops reading but heartbeats is killed within the bound and the blocked producer returns, via the Hello.test_stall_reader_on_diag seam); BlockingPipeStreamTests dispose with a write blocked on a full pipe, and with a read and a write both pending; Category=ScriptWorker on the protocol, handshake and pipe test classes. Mutation checks: no stall kill -> stall test red; catch-all without the fault -> callback test red (Crash). Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
a7fa327e61 |
test(scripting): #789 BlockingPipeStream duplex, large frame, peer-gone and self-dispose cases
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
2e948d3139 |
fix(opcua): #874 route the server's OPC UA SDK log output to the host logger
The host handed the SDK a provider-less telemetry context, so SDK-side refusals — identity-token decryption (the SDK 2 RsaEncryptedSecret clock window, #873), certificate validation, secure-channel faults — left no server log line (found on windev during the #800 Windows validation). The hosted service now creates one ServerSdkTelemetry context from its ILoggerFactory (Warning and above, SDK categories, rate-limited 20/min per category) and shares it between OtOpcUaSdkServer and the ApplicationInstance; released on dispose. Test: an untrusted client's rejection reaches the captured log. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
11ea972cd3 |
test(adminui): #871 reaper not-a-guid database name unique per run
The fixed name lives on the SQL Server shared by every checkout and CI, so a concurrent run's create or finally-drop could collide (failed once in the local pre-push suite, 3/3 green on rerun; cause plausible, not proven). Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
e57e1324c3 |
Merge #870 ring-buffer event monitored-item queue
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
36583ee6f4 |
test(opcua): #870 review — pin the 1000th-newest duplicate-window boundary; serialise the ring-queue switch users
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
bcde93056a |
perf(opcua): #870 ring-buffer event monitored-item queue
Since #855 condition events are queued to folder event items under the node manager Lock. At a full DiscardOldest queue the SDK's EventQueueHandler dequeues the head of EventMonitoredItemQueue, a List.RemoveAt(0) shift of the whole queue (~1 us/item at QueueSize 1000, ~10 us at 10000). OtOpcUaSdkServer.CreateMonitoredItemQueueFactory now wraps the SDK factory in RingEventQueueFactory: event queues are RingEventMonitoredItemQueue (O(1) discard), data-change queues, durable requests and disposal go to the SDK factory unchanged. A factory set through StandardServer.MonitoredItemQueueFactory is used as-is. The SDK queue is storage only (decompiled 2.0.1): DiscardOldest, overflow-event positions and discarded/overflow counters live in EventQueueHandler, which is unchanged. The ring reproduces QueueSize 0 initial + BadInternalError enqueue, drop-oldest at Count == QueueSize, FIFO dequeue, SetQueueSize keep-newest / keep-oldest, the newest-1000 duplicate window via IsEventRaisedFor, no-op Dispose. RingEventMonitoredItemQueueParityTests drive both queues, and the SDK EventQueueHandler over both factories, with the same random sequences. FolderEventDeliveryPerfTests gains Ring_vs_sdk_event_queue (A/B in one process via the internal OtOpcUaSdkServer.RingEventQueues switch). Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
9aaab9ee42 |
test: #871 fix two hosted-CI races that failed master runs 1218-1220
- MeshPskProviderRefreshTests: GatedResolver.Entered was set by the priming refresh and never reset, so Wait() returned before the held refresh reached the store and the mid-refresh call-count snapshot raced its increment (expected 1, was 2). Hold() now resets it. - FailedCredentialLockoutTests all-bad burst: the 15 waiters could queue before a slot holder ran the increment after BeginAsync (authenticated 0 vs 5). The wait now covers both conditions; the security assertions are unchanged. Both looped 20x locally, 0 failures. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
efb7b21508 |
Merge #855 shared per-report event projection: Lock hold back near pre-#855 at N=50
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
733dfe94da |
fix(opcua): #855 perf review 3 — weak filter-group registry; where clause validated; sharing pinned
- The filter-group registry holds groups weakly (the filter->group table is the only strong reference), sweeps dead entries amortised and counts only live shapes against the 4096 cap; filters past the cap are counted on the new meter otopcua.opcua.condition_events.filter_groups_unshared. - A group validates its where clause too (restored, never-validated filters evaluate like validated ones). - Test: value-equal filters cost one projection per locale set per report. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
b77bb2ded1 |
fix(opcua): #855 perf review 2 — where clause in the group bucket key; pin dedupe, modify and keys
- Filter-group buckets include the where clause's (value-based) hash, so per-source filters sharing a select list are not all scanned under Lock. - Locale keys are cached per session array. - Tests: a notifier reached twice in one walk (Line above two wired equipment) gets each event once on both paths (fails without the per-report set); queued events re-projected by a modify are identical on both paths; locale keys unambiguous and where clauses hash by value. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
ca0e73fbca |
fix(opcua): #855 perf review 1 — groups evaluate their own filter clone; lazy fields; tighter equivalence tests
- A filter group owns a deep clone of its first filter, validated once and evaluated under the group's lock: no item-owned filter is mutated or raced (Validate mutates a select clause). - The projection is computed in the SDK's order: where clause first, fields only for an item that will queue. - Unambiguous locale key; counter for filters given a private group past the registry cap; DeliveryCache getter returns its own allocation. - UseProjectedEventDelivery=false also asks the role and grant checks per item, so the equivalence test compares against the pre-optimisation path. - Equivalence test: same select list under three where clauses, a filter modified mid-run, a session with other locales, an ungranted anonymous Server-object item, per-rig counter; plus a small overflowing queue with DiscardOldest true and false. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
98d962b24b |
perf(opcua): #855 — shared per-report projection for condition events under Lock
Condition events delivered to many event items (folder and Server-object) no longer pay the SDK's per-item QueueEvent(IFilterTarget) work under the node-manager Lock: the role-permission verdict is shared per (session, identity), the #826 grant check per identity per hop, and the where clause + select-clause projection per (value-equal EventFilter, locales); each item gets its own EventFieldList over the shared read-only fields. The SDK's QueueEvent body is reproduced under the item's own lock (three private SDK members, guard-tested); anything outside that shape falls back to the SDK. The per-report duplicate check replaces the SDK's O(queue) scan. ProjectedEventDeliveryWireTests compares both paths over the wire. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
c63233b654 |
Merge #868 fourth review fixes: poison only by SQL error allowlist, memory-counted acks survive restart, Stale-recovered revisions unconfirmed
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
ec3b835e4a |
fix(controlplane,runtime): #868 fourth review — SQL poison allowlist, memory-counted acks survive restart, adopted revisions unconfirmed
M1 IsPoison classified every SQL Server transient as poison: Microsoft.Data.SqlClient never sets DbException.IsTransient, so a deadlock (1205), timeout (-2 / Win32 258), dropped connection or AG failover was counted from memory and the node's row left Applying. Poison is now an ALLOWLIST: a DbUpdateException (not concurrency) carrying a SqlException whose number is 8152, 2628, 547, 515, 2627 or 2601. Everything else retries, bounded by the deadline deferral cap. IsPoison is public for tests. Acks counted only from memory (poison branch and the deferral cap) are kept in _memoryOnlyAcks and re-sent by PreRestart alongside _unpersisted, so a restart before the terminal write does not lose them. M2 Revisions adopted without an outcome-reporting apply are unconfirmed, so a same-revision dispatch re-applies instead of short-circuiting a clean Applied over an empty address space: TryRecoverFromStale (restores only the revision marker) and RestoreApplied when the artifact read fails (null blob, LoadArtifactBlob swallows the error) or the restore throws. L1 The re-drive comment documents the CreatedAtUtc recompute after a second restart / failover. L2 _currentRevisionDegraded renamed _currentRevisionUnconfirmed; the stale TryBootFromCache remark corrected. L3 docs/Redundancy.md: a Direct node running from cache that still cannot reach the ConfigDb acks a same-revision re-dispatch Failed (PartiallyFailed, not Sealed); Stale recovery and failed restores are unconfirmed too. Test harness: SqlExceptionFactory builds real SqlExceptions (reflection on SqlError / SqlErrorCollection / SqlException.CreateException); ConstraintEnforcingDbFactory now throws DbUpdateException over SQL 2628 (over-length), 547 (poison) and 1205 (transient), plus FailNextContexts. Tests (all fail on the previous code): poison allowlist theory (1205, 40613, 10054, 4060 not poison) + timeout/concurrency/providerless not poison; a deadlocked ack write is retried and recorded; a memory-counted ack survives a coordinator restart; Stale-recovered and failed-restore revisions re-apply on re-dispatch. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
eba016096c |
Merge branch 'master' into worktree-agent-a720278a57ae41792
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |
||
|
|
b421c7ca91 |
fix(runtime): #869 review round 3 — key a native-ack revert to the ack that failed
A pending local ack can outlive its own delivery (sources that send no Acknowledge back; a Clear now only carries it through), so a later failed re-ack — or a stale Galaxy confirmation timing out after the alarm re-occurred and was acked again — could revert an ack it does not own. MarkAcknowledged now issues a sequence (0 when the ack changed nothing), carried on RouteAlarmAck and echoed on AlarmAckFailed; TryRevertAcknowledge reverts only on a matching sequence. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk |