Files
lmxopcua/archreview/plans/R2-08-ldap-historyread-async-plan.md
T
Joseph Doherty 1891f5d6a7 docs(archreview): 2026-07-12 re-review at f6eaa267 + 12 round-2 remediation plans
Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.

Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
2026-07-12 23:52:23 -04:00

48 KiB
Raw Blame History

Design + Implementation Plan — R2-08: Async LDAP authentication + HistoryRead bridging

  • Source report: archreview/03-server-runtime.md (2026-07-12 re-review, working-tree version)
  • Review commit: f6eaa267 · Plan verified against tree at: f6eaa267 (master, clean)
  • Scope: OVERALL prioritized action item #8 — "Async LDAP + configurable timeout; channel-ize HistoryRead bridging" — covering findings 03/S2 (High — LDAP authentication block-bridges OPC UA session activation with an unconfigurable ~10 s timeout, no pooling, no fail-fast) and 03/S3 (High — HistoryRead block-bridges the gateway per node, sequentially, on SDK request threads, bounded only by the 30 s gateway CallTimeout). Both were carried unfixed from round 1 (archreview/plans/03-server-runtime-plan.md §S2/§S3 — designs verified + refreshed below).
  • Touches: Security/Ldap/LdapOptions.cs, Security/Ldap/OtOpcUaLdapAuthService.cs, Security/Ldap/LdapAuthResult.cs, Host/OpcUa/LdapOpcUaUserAuthenticator.cs, Host/Configuration/LdapOptionsValidator.cs, OpcUaServer/OpcUaApplicationHost.cs (doc only), OpcUaServer/OtOpcUaNodeManager.cs, Runtime/Historian/ServerHistorianOptions.cs, Host/OpcUa/OtOpcUaServerHostedService.cs, plus tests in Host.IntegrationTests, Security.Tests, Runtime.Tests, OpcUaServer.Tests. No shared-lib (ZB.MOM.WW.Auth) change required — see Verification.

Verification summary

Every anchor in both findings was opened against the working tree at f6eaa267; the round-1 plan's S2/S3 designs were re-verified against the code and the actual SDK + shared-library sources. Both findings CONFIRMED — and S2 is materially worse than the report states. Details:

  • S2 anchors — confirmed, one path drift. The report cites src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OpcUaApplicationHost.cs:271-274; the file actually lives at src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs (the authenticator is under Host/OpcUa/). The block-bridge is exact: HandleImpersonation calls authenticator.AuthenticateUserNameAsync(…, CancellationToken.None).GetAwaiter().GetResult() (OpcUaApplicationHost.cs:270-273). LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync (Host/OpcUa/LdapOpcUaUserAuthenticator.cs:30-48) adds no timeout, no concurrency bound, no fail-fast. LdapOptions.ToLibraryOptions() (Security/Ldap/LdapOptions.cs:94-107) projects no timeout field and LdapOptions has none.
  • NEW (verified from SDK source, worse than reported): the impersonation callback runs inside a global event lock. Verified against the pinned OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378.106 sources (Libraries/Opc.Ua.Server/Session/SessionManager.cs): ActivateSessionAsync raises the handler as lock (m_eventLock) { … m_ImpersonateUser(session, args); … } (lines 352-360; m_eventLock is a single SessionManager-wide Lock, line 698). The handler signature is a synchronous void delegate; there is no async variant of the impersonation callback (the surrounding ActivateSessionAsync is ValueTask-returning, but the callback itself must complete inline). Consequence: one stalled LDAP bind does not just hold one SDK thread — it serializes every session activation server-wide (Anonymous and X509 included, since even the early-return path must acquire m_eventLock to invoke the handler). The SDK contract is therefore irreducibly sync → the fix is a hard wall-clock bound + bounded concurrency + outage fail-fast at the authenticator boundary, exactly the shape the report recommends. No "fake async" is attempted.
  • NEW (resolves a round-1 open choice): the shared library already exposes the timeout. ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.ConnectionTimeoutMs exists (default 10_000, LdapContracts.cs:20 in ~/Desktop/scadaproj/ZB.MOM.WW.Auth), and the Novell adapter applies it to both the socket connect (_conn.ConnectionTimeout) and the per-operation search constraint (constraints.TimeLimit) (NovellLdapConnection.ApplyTimeout, lines 131-146). The round-1 plan's step 1 said "do not project into ToLibraryOptions() … unless the library later exposes a matching field" — the field exists; this plan projects it. Also verified: the library's AuthenticateAsync is Task.FromResult(Authenticate(...)) — synchronous-in-Task, token observed at entry only (LdapAuthService.cs:59-66), and each call is a fresh connect + service-bind + search + user-bind (no pooling; pooling stays a shared-lib follow-up, out of scope here). The library is fully fail-closed (never throws; system-side faults map to LdapAuthFailure.ServiceAccountBindFailed).
  • S3 anchors — all four bridge sites line-exact at f6eaa267: OtOpcUaNodeManager.cs:1911 (Events), :2059 (ServeNode — serves Processed + AtTime), :2171 (Raw page read), :2203 (Raw tie-cluster over-fetch) — each .GetAwaiter().GetResult() with CancellationToken.None. The four arms iterate nodesToProcess in a sequential foreach (:1784 RawModified, :1811 Processed, :1851 AtTime, :1873 Events). SDK thread budget confirmed: MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200 (OpcUaServer/OpcUaApplicationHost.cs:333-335). The overrides run outside the node-manager Lock (documented at :1777-1780) — this is a request-pool-exhaustion risk, not a lock-freeze risk.
  • S3 thread-safety audit (round-1 plan asked for it — done): parallel per-node serving is safe. results[handle.Index] / errors[handle.Index] writes are index-disjoint into pre-sized lists (the base dispatcher seeds every slot). All shared node-manager maps the read path touches are ConcurrentDictionary (_variables:41, _historizedTagnames:55, _eventNotifierSources:69). IHistoryContinuationStore is explicitly thread-safe (SDK per-session store — "the SDK session locks internally", HistoryContinuationStore.cs; the test store is a lock-protected dictionary). ServeRawPaged state is all per-handle locals. IHistorianDataSource (the gateway client) is a gRPC channel wrapper — safe for concurrent calls; each call already carries the 30 s CallTimeout deadline (ServerHistorianOptions.CallTimeout, ServerHistorianOptions.cs:58).
  • Round-1 plan §S3 design re-verified: bounded per-node parallelism + per-request deadline + server-side limiter all still fit the tree; refined below (OCE→BadTimeout per node, limiter wait budget, lazy gate creation, options names) and the "fully async HistoryRead" alternative re-confirmed impossible — CustomNodeManager2's HistoryRead* overrides are synchronous void fillers.
  • Existing test harnesses located (reused, not invented): Host.IntegrationTests/ LdapOpcUaUserAuthenticatorTests.cs (offline FakeLdap : ILdapAuthService + FakeMapper — the exact seam the prompt's stall-repro needs), LdapOptionsBindingTests.cs, LdapOptionsValidatorTests.cs, Security.Tests/OtOpcUaLdapAuthServiceTests.cs, OpcUaServer.Tests/NodeManagerHistoryReadTests.cs (boots the real SDK server in-process, invokes the public HistoryRead → real base dispatch → our arms, with a RecordingHistorianDataSource fake) and NodeManagerHistoryReadPagingTests.cs, Runtime.Tests/Historian/AddServerHistorianTests.cs. Dev/test LDAP facts confirmed: shared GLAuth 10.100.0.35:3893 (bind cn=serviceaccount,dc=zb,dc=local) for manual verification; the Host.IntegrationTests harness uses ephemeral bitnami/openldap on :3894 for automated runs — the outage sim below needs neither (an unroutable IP reproduces the outage shape offline).

S2 — HIGH — LDAP authentication block-bridges session activation with a non-cancellable, non-configurable timeout

Restatement: The SDK invokes the impersonation callback synchronously (inside a SessionManager-wide event lock — see Verification); HandleImpersonation block-bridges the authenticator with CancellationToken.None; no layer enforces a timeout; the shared library's 10 s connect timeout is a non-projected default; every authentication is a fresh connect + service-bind + search + user-bind. Under a directory outage, session activations stall serially, server-wide, ~10-20 s each.

Verification: Confirmed (all anchors above). The event-lock serialization makes this strictly worse than the report's "stalls SDK threads serially": Anonymous/X509 activations queue behind a stalled UserName bind too.

Root cause: HandleImpersonation's doc comment delegates timeout responsibility to the authenticator, but LdapOpcUaUserAuthenticator never took it up; and OtOpcUa's LdapOptions never projected the library's existing ConnectionTimeoutMs, so the 10 s library default is both invisible and unconfigurable. Nothing bounds concurrent in-flight binds or fails fast during a sustained outage.

Proposed design — hard wall-clock bound + bounded concurrency + outage circuit, all inside LdapOpcUaUserAuthenticator; project the library timeout. The SDK callback is irreducibly sync (verified), so the fix is entirely at the OtOpcUa authenticator boundary:

  1. Project the library timeout — add ConnectionTimeoutMs (default 10000, matching the library default so behavior is unchanged unless configured) to OtOpcUa's LdapOptions and project it in ToLibraryOptions(). This bounds the socket connect and each search op inside the library.
  2. Hard whole-flow timeout at the boundaryAuthenticateUserNameAsync wraps its existing body (bind and role-map — one budget for everything between SDK-callback entry and result) in core.WaitAsync(TimeSpan.FromMilliseconds(AuthTimeoutMs), ct); TimeoutExceptionDeny("Authentication timed out") (fail-closed) + Warning log. Default AuthTimeoutMs = 15000 — deliberately > ConnectionTimeoutMs so a healthy-but-slow full flow (connect + 2 binds + search, each individually under 10 s) isn't cut off by the boundary, while an outage (connect stall) is bounded by the library timeout first and the boundary is the backstop. Note the abandoned inner task runs to completion in the background — the library is documented never-throw, so no unobserved exceptions; the point is releasing the SDK thread (and m_eventLock).
  3. Bounded in-flight concurrency — a SemaphoreSlim(MaxConcurrentAuthentications) acquired before starting the core task and released by a continuation when the core task completes (not in the caller's finally) — so abandoned/orphaned binds count against the cap and an outage can never accumulate unbounded orphan tasks. A caller that cannot acquire a slot within its own budget denies with "Authentication backend busy" (does not count toward the circuit — local backpressure is not directory evidence). With the SDK's event-lock serialization only one SDK thread is ever in the callback at a time, but the cap still bounds orphan accumulation across successive timed-out calls.
  4. Directory-outage circuit (fail-fast) — after OutageFailureThreshold (default 3) consecutive system-side failures (boundary timeout, IsSystemFailure result, or unexpected throw), the authenticator denies instantly ("Authentication backend unavailable") for OutageCooldownSeconds (default 15) without touching LDAP; after cooldown the next attempt probes (half-open — the counter stays at threshold so one more system failure re-opens immediately). Any success or user-side deny resets the counter. Distinguishing system-side from user-side needs a structured signal: add bool IsSystemFailure { get; init; } to the app LdapAuthResult (Security/Ldap/LdapAuthResult.cs — additive init property, no call-site churn), set by OtOpcUaLdapAuthService.Adapt for LdapAuthFailure.ServiceAccountBindFailed (the library's directory-unreachable bucket — its enum has no dedicated DirectoryUnavailable; documented limitation, LdapAuthService.cs:94-96).
  5. Negative cache — REJECTED (resolves the round-1 "consider a short negative cache" and the security caution): a per-credential deny cache requires holding password digests in memory, creates a window where a just-corrected password is still rejected, and adds a cache-timing side channel — for near-zero benefit, because a bad-credential bind is fast when the directory is up; the expensive stall only exists when the directory is down, which the credential-blind outage circuit covers strictly better.

Alternatives considered:

  • Make the shared library cooperatively cancellable / pooled — cross-repo change to ZB.MOM.WW.Auth (Novell calls are sync-blocking; pooling changes the security posture of bind reuse). Track as a shared-lib follow-up; not blocking — the boundary bound is sufficient here.
  • A ResilientOpcUaUserAuthenticator decorator over IOpcUaUserAuthenticator — cleaner layering on paper, but it would need the system-vs-user failure signal widened onto OpcUaUserAuthResult (an OpcUaServer-project contract) and a second options plumbing path; the inline fix keeps the resilience next to the only LDAP-backed implementation and the signal on the app-owned LdapAuthResult. Rejected for scope.
  • Pass a real timeout-derived token from HandleImpersonation — the library observes the token at entry only (verified), so it cannot shorten anything the boundary WaitAsync doesn't already bound. Not done; HandleImpersonation keeps CancellationToken.None and gains a doc comment stating the verified SDK contract (sync callback under m_eventLock) and where the bound lives.
  • Fully async activation — impossible; no async impersonation callback exists in 1.5.378 (verified).

Implementation steps (task-level detail in the breakdown):

  1. src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs — add five keys (defaults): ConnectionTimeoutMs = 10000 (projected into ToLibraryOptions()), AuthTimeoutMs = 15000, MaxConcurrentAuthentications = 8, OutageFailureThreshold = 3 (0 disables the circuit), OutageCooldownSeconds = 15.
  2. src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs — add public bool IsSystemFailure { get; init; }.
  3. src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.csAdapt sets IsSystemFailure = result.Failure is LdapAuthFailure.ServiceAccountBindFailed on the failure path.
  4. src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs — take IOptions<LdapOptions>; move the existing body to a private AuthenticateCoreAsync; add the circuit check → semaphore acquire (release-on-core-completion continuation) → WaitAsync timeout → circuit bookkeeping, per the design. DI registration (Host/Program.cs:249) is unchanged.
  5. src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs — when Enabled && !DevStubMode: ConnectionTimeoutMs > 0, AuthTimeoutMs > 0, MaxConcurrentAuthentications > 0, and OutageCooldownSeconds > 0 when OutageFailureThreshold > 0.
  6. src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs — doc-comment only on HandleImpersonation: record the verified SDK contract (sync void handler raised under SessionManager.m_eventLock in 1.5.378 — activations serialize) and that the wall-clock bound is the authenticator's responsibility (now actually enforced).
  7. docs/security.md — document the five new Security:Ldap keys + fail-fast semantics.

Tests.

  • Stall-repro (RED first, no real LDAP): Host.IntegrationTests — new LdapAuthResilienceTests beside the existing suite, reusing its FakeLdap : ILdapAuthService pattern with a delaying handler. Authenticate_slow_backend_denies_within_AuthTimeout (fake delays 5 s, AuthTimeoutMs = 200 → assert Deny + elapsed < ~1.5 s) — fails on current code (returns Allow after the full 5 s).
  • Unit: fast success unaffected; timeout denial is fail-closed + logged; circuit opens after N consecutive system failures and denies without invoking the fake (call-count pinned); user-side deny does not trip the circuit; half-open probe after cooldown; success resets; semaphore denies "busy" when saturated; OtOpcUaLdapAuthServiceTestsAdapt maps ServiceAccountBindFailedIsSystemFailure = true, BadCredentialsfalse; LdapOptionsBindingTests / LdapOptionsValidatorTests extended for the new keys. All six existing LdapOpcUaUserAuthenticatorTests stay green (ctor gains options — update construction).
  • Outage-sim integration (offline-safe): real OtOpcUaLdapAuthService + real library pointed at an unroutable host (10.255.255.1:3893 — TCP SYN blackhole, the exact shape of "stop the GLAuth container"), small ConnectionTimeoutMs/AuthTimeoutMs, driven through the real HandleImpersonation (it is internal static — invokable without booting the SDK, precedent OpcUaApplicationHostImpersonationTests): assert bounded fail + BadIdentityTokenRejected.
  • Live/manual (post-merge, not gating): docker-dev rig — point Security:Ldap:Server at an unreachable host, drive Client.CLI connect -u opc.tcp://localhost:4840 with a UserName token (e.g. the multi-role GLAuth user), confirm fast bounded denial and that a burst of activations doesn't serialize for 10 s each; then restore 10.100.0.35:3893 and confirm normal login.

Effort: M. Risk/blast-radius: Low-Medium. All defaults chosen so behavior is unchanged on a healthy directory (ConnectionTimeoutMs 10 s = current library default; AuthTimeoutMs 15 s only cuts flows that today stall past 15 s; the circuit requires 3 consecutive system failures). Fail-closed posture preserved end-to-end (every new path denies). The AdminUI login path (OtOpcUaLdapAuthService direct) is untouched except the additive IsSystemFailure flag.


S3 — HIGH — HistoryRead block-bridges the gateway per node, sequentially, on SDK request threads

Restatement: All four HistoryRead arms serve nodes sequentially, each node a .GetAwaiter().GetResult() gateway call with CancellationToken.None, bounded only by the 30 s per-call gateway CallTimeout. One request naming N historized nodes against a slow historian holds one SDK request thread up to N × 30 s against MaxRequestThreadCount = 100 / MaxQueuedRequestCount = 200; a handful of misbehaving history clients can exhaust the pool and degrade every OPC UA service.

Verification: Confirmed — bridge sites OtOpcUaNodeManager.cs:1911, 2059, 2171, 2203; sequential foreach in all four arms; thread-safety audit for parallelization passed (see Verification summary). GatewayHistorianDataSource is already fully async underneath with a per-call deadline — the bridging pattern is the problem, not the data source.

Root cause: Per-node reads are serialized with no per-request deadline and no server-side cap on concurrent HistoryRead work; the block-bridge is structural (the CustomNodeManager2.HistoryRead* override surface is synchronous void — verified), but nothing says it must be per-node and unbounded.

Proposed design — bounded per-node fan-out + per-request deadline + process-wide batch limiter, one block-bridge per arm. Three composable bounds, each configurable under ServerHistorian:

  1. Bounded per-node parallelism within a batch. Async-ify the per-node serving internals (ServeNodeServeNodeAsync, ServeRawPagedServeRawPagedAsync, the Events body → ServeEventsAsync) — each stays fully self-contained try/catch (per-node error isolation unchanged). Each arm collects one Func<Task> per handle and runs them through a shared RunBounded(work, degree) helper: a SemaphoreSlim(degree)-gated Task.WhenAll, block-bridged once at the arm boundary (Task.WhenAll(...).GetAwaiter().GetResult() — safe: bodies never throw). Worst case drops from N × 30 s to ~⌈N/P⌉ × 30 s. Degree: HistoryReadBatchParallelism, default 4 (bounds the gateway-load multiplication a single client can command — see Risk).
  2. Per-request deadline. Each arm creates using var cts = new CancellationTokenSource(HistoryReadDeadline) (default 60 s) and threads cts.Token into every data-source call (replacing CancellationToken.None at all four sites). Per-node handlers gain a catch (OperationCanceledException) arm mapping to BadTimeout for that node (before the generic catch, which keeps mapping other failures to BadHistoryOperationUnsupported as today). A single request can no longer hold a thread longer than the deadline regardless of node count.
  3. Process-wide concurrent-batch limiter. A node-manager SemaphoreSlim(MaxConcurrentHistoryReadBatches) (default 16, created lazily on first HistoryRead so the post-boot property set is respected — same benign wiring race as the existing MaxTieClusterOverfetch property) gates each arm's body. Entry waits up to min(HistoryReadDeadline, 5 s); on expiry every handle in the batch gets BadTooManyOperations (fail-fast under flood, brief waits under mild contention — resolves the round-1 "wait or fail" open choice with both, bounded). 16 batches × degree 4 also caps total in-flight gateway calls at 64.

Alternatives considered:

  • Fully async HistoryRead — the SDK override surface is synchronous void (verified); structural. Rejected; parallelize within the sync boundary.
  • Parallel.ForEachAsync for the fan-out — throws OperationCanceledException at the scheduling level on token cancellation, which would bypass per-node error isolation and throw out of the arm. The SemaphoreSlim + Task.WhenAll shape keeps "bodies never throw" airtight. Rejected.
  • Rejecting multi-node history requests — breaks legitimate clients. Rejected (round-1 concurrence).
  • A channel/queue + dedicated reader pool — heavier machinery for the same bound; the semaphore composition is sufficient and keeps the diff reviewable. Rejected.
  • Per-node continuation-point-aware sub-paging changes — none needed: paging state (ServeRawPaged/continuation store) is per-handle and thread-safe (audit above); TimestampsToReturn handling is untouched (the arms already receive it and the projection helpers are pure).

Implementation steps (task-level detail in the breakdown):

  1. src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs — add HistoryReadBatchParallelism = 4, MaxConcurrentHistoryReadBatches = 16, HistoryReadDeadline = TimeSpan.FromSeconds(60); Validate() warns on non-positive values (Enabled-gated, same pattern as MaxTieClusterOverfetch).
  2. src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs — three settable properties mirroring the MaxTieClusterOverfetch precedent (HistoryReadBatchParallelism, MaxConcurrentHistoryReadBatches, HistoryReadDeadline); async-ified per-node internals with ct parameters; RunBounded helper; lazily-created batch-limiter gate; the four arms rewritten to limiter → deadline CTS → work-item collection → bounded fan-out → single bridge.
  3. src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs — set the three node-manager properties beside the existing MaxTieClusterOverfetch assignment (StartAsync).
  4. docs/Historian.md + the CLAUDE.md ServerHistorian key table — document the three new keys.

Tests.

  • Stall-repro (RED first, no real gateway): OpcUaServer.Tests — new NodeManagerHistoryReadConcurrencyTests reusing the NodeManagerHistoryReadTests boot harness with a delaying fake IHistorianDataSource that tracks max observed concurrent reads (interlocked). Raw_batch_is_served_with_bounded_parallelism: 8 historized nodes, 150 ms delay per read → assert maxConcurrent >= 2 (deterministic — fails on current sequential code where it is pinned at 1)
    • a loose wall-clock secondary bound (< ~⌈8/4⌉ × 150 ms + slack).
  • Unit: per-node isolation regression under parallelism (one node's fake throws → that node Bad, others Good — the existing guarantee, re-proven concurrent); parallelism also bounded above (maxConcurrent <= degree); deadline — a fake whose task only completes on token cancellation → every node BadTimeout within deadline + slack, call returns (fails-by-hanging is prevented by wrapping the invoke in Task.Run(...).WaitAsync(10 s)); limiter — MaxConcurrentHistoryReadBatches=1, first batch parked in the fake, second concurrent HistoryRead (small HistoryReadDeadline so the wait budget is short) → all its handles BadTooManyOperations; existing NodeManagerHistoryReadTests + NodeManagerHistoryReadPagingTests (incl. tie-cluster paths) stay green — the paging suite is the continuation-point-correctness regression net; AddServerHistorianTests / options tests extended for the new keys.
  • Integration/live (post-merge, not gating): the env-gated Category=LiveIntegration gateway suite (VPN + real HistorianGateway) re-run; optionally a manual soak — one client issuing a 50-node Raw HistoryRead against a slow historian while a second client browses/subscribes, confirming the second client stays responsive (the pool-exhaustion scenario).

Effort: M. Risk/blast-radius: Medium. Parallelizing multiplies peak gateway load by up to HistoryReadBatchParallelism per request (default 4, total in-flight capped at 64 by the limiter — the knobs exist precisely to tune this against a weak historian). Result/error index writes verified disjoint; all touched shared state verified concurrent-safe. Client-visible behavior changes only in failure shapes (BadTimeout on deadline, BadTooManyOperations under flood — both standard, spec-conformant codes) and latency (better). Continuation-point semantics untouched.


Task breakdown

Branch: fix/archreview-r2-08-ldap-historyread-async off master@f6eaa267. Tasks are ≤5 min each; TDD-ordered (each RED repro precedes its implementation). Test commands run from the repo root; all automated tests are offline-safe (no Docker, no VPN, no real LDAP/gateway).


R2-08-T1 — S2 stall-repro test (RED — must fail on current code)

Classification: test-first repro (deterministic unit, fake ILdapAuthService with a delay) Estimated implement time: 5 min Parallelizable with: T7, T8 (different files/subsystem). Files: tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs (new)

  1. New test class beside LdapOpcUaUserAuthenticatorTests, cloning its FakeLdap/FakeMapper pattern but with an async handler seam: DelayingFakeLdap(TimeSpan delay, LdapAuthResult then) (await Task.Delay(delay, ct) before returning) and an Interlocked call counter.
  2. Add Authenticate_slow_backend_denies_within_AuthTimeout: construct LdapOpcUaUserAuthenticator exactly as the existing tests do (this is RED partly because there is no way to pass a timeout yet — write the test against the intended ctor shape new LdapOpcUaUserAuthenticator(ldap, scopeFactory, Options.Create(new LdapOptions { AuthTimeoutMs = 200 }), logger); until T4 lands the file won't compile — acceptable for a same-branch RED, or temporarily pin with the current ctor + assert elapsed, which fails by timing). Fake delays 5 s; assert result.Success == false, result.Error contains "timed out", and a Stopwatch elapsed < 1.5 s. Wrap the await in .WaitAsync(TimeSpan.FromSeconds(10)) so a regression fails rather than hangs.
  3. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests.Authenticate_slow_backend_denies_within_AuthTimeout"expected: FAIL (compile-fail against intended ctor, or Allow-after-5s timing fail).

Commit: test(host): red repro — LDAP authenticator has no wall-clock bound on session activation (03/S2)


R2-08-T2 — LdapOptions keys + library projection + validator

Classification: implementation (options + validation, additive) Estimated implement time: 5 min Parallelizable with: T3, T7, T8. Files: src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs, src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs

  1. RED: extend LdapOptionsBindingTests — a config section carrying the five new keys binds them; defaults hold when absent; ToLibraryOptions().ConnectionTimeoutMs mirrors the app value. Extend LdapOptionsValidatorTests — non-positive ConnectionTimeoutMs/AuthTimeoutMs/ MaxConcurrentAuthentications fail validation when Enabled && !DevStubMode; OutageFailureThreshold = 3 with OutageCooldownSeconds = 0 fails; OutageFailureThreshold = 0 with any cooldown passes; disabled/DevStub configs stay exempt. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapOptionsBindingTests|FullyQualifiedName~LdapOptionsValidatorTests"expected: FAIL (properties don't exist).
  2. GREEN: add ConnectionTimeoutMs = 10000, AuthTimeoutMs = 15000, MaxConcurrentAuthentications = 8, OutageFailureThreshold = 3, OutageCooldownSeconds = 15 (XML-doc each: what it bounds, why the default, 0 disables the circuit); project ConnectionTimeoutMs in ToLibraryOptions(); add the validator rules. Re-run the filter → PASS.

Commit: feat(security): LDAP auth timeout/concurrency/outage-circuit options; project ConnectionTimeoutMs into the shared library (03/S2)


R2-08-T3 — LdapAuthResult.IsSystemFailure seam

Classification: implementation (additive contract field + adapter mapping) Estimated implement time: 5 min Parallelizable with: T2, T7, T8. Files: src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs, src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs

  1. RED: extend OtOpcUaLdapAuthServiceTests — library ServiceAccountBindFailed adapts to IsSystemFailure == true; BadCredentials/UserNotFound/success adapt to false. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~OtOpcUaLdapAuthServiceTests"expected: FAIL.
  2. GREEN: add public bool IsSystemFailure { get; init; } (XML-doc: "system-side directory failure — unreachable/misconfigured — as opposed to a user-credential deny; feeds the OPC UA authenticator's outage circuit"); set it in Adapt's failure branch for ServiceAccountBindFailed. Re-run → PASS (whole class — existing adapt tests must stay green).

Commit: feat(security): LdapAuthResult.IsSystemFailure distinguishes directory outage from credential deny (03/S2)


R2-08-T4 — Authenticator wall-clock bound + concurrency cap (turns T1 green)

Classification: implementation (the core S2 fix) Estimated implement time: 5 min Parallelizable with: T7-T13 (S3 files). Blocked by T1, T2. Files: src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs

  1. Add IOptions<LdapOptions> to the ctor; move the existing method body to private async Task<OpcUaUserAuthResult> AuthenticateCoreAsync(...) unchanged; new outer body: semaphore WaitAsync(AuthTimeoutMs, ct) → deny "Authentication backend busy" on false; start core = AuthenticateCoreAsync(...) with a core.ContinueWith(_ => _gate.Release(), TaskScheduler.Default) release-on-completion; await core.WaitAsync(TimeSpan.FromMilliseconds(AuthTimeoutMs), ct) in try/catch — TimeoutException ⇒ Warning log + deny "Authentication timed out". Update the class XML-doc (why: SDK sync callback under m_eventLock).
  2. Update the six existing LdapOpcUaUserAuthenticatorTests constructions to pass Options.Create(new LdapOptions()).
  3. Add Authenticate_fast_success_unaffected and Authenticate_saturated_concurrency_denies_busy (MaxConcurrentAuthentications = 1, first call parked in the fake, second denies "busy" within bound) to LdapAuthResilienceTests.
  4. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests|FullyQualifiedName~LdapOpcUaUserAuthenticatorTests"expected: PASS (T1's repro now green; all six pre-existing tests green).

Commit: feat(host): hard wall-clock bound + bounded concurrency on OPC UA LDAP authentication (03/S2)


R2-08-T5 — Directory-outage circuit (fail-fast during sustained outage)

Classification: implementation (resilience state machine) Estimated implement time: 5 min Parallelizable with: T7-T13. Blocked by T3, T4. Files: src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs

  1. RED: add circuit tests — three consecutive timeouts (or IsSystemFailure denies) open the circuit: the 4th call denies "Authentication backend unavailable" without invoking the fake (call counter pinned at 3) and returns in ~0 ms; a user-side deny (Invalid username or password, IsSystemFailure=false) between failures resets the streak; after OutageCooldownSeconds (use 1 s in test) the next call probes the fake (half-open) and a success closes the circuit; OutageFailureThreshold = 0 never opens. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests"expected: FAIL (new tests only).
  2. GREEN: small lock-guarded state (_consecutiveSystemFailures, _circuitOpenUntil via Environment.TickCount64); entry check denies while open (Debug log; single Warning when opening, Information when closing); system failure = boundary timeout result.IsSystemFailure unexpected throw from the core; success or user-side deny resets. Re-run → PASS.

Commit: feat(host): LDAP directory-outage circuit — fail-fast denial during sustained outage (03/S2)


R2-08-T6 — Outage-sim integration test + HandleImpersonation contract doc

Classification: integration test (offline-safe) + doc Estimated implement time: 5 min Parallelizable with: T7-T13. Blocked by T4. Files: tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs, src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs, docs/security.md

  1. Add Activation_with_unreachable_directory_fails_within_bound: real OtOpcUaLdapAuthService (real ZB.MOM.WW.Auth library, no fake) with Server = "10.255.255.1", Port = 3893, Transport = None, AllowInsecure = true, ConnectionTimeoutMs = 500, AuthTimeoutMs = 1000; wrap in LdapOpcUaUserAuthenticator; drive the real internal static OpcUaApplicationHost.HandleImpersonation with a UserNameIdentityToken (precedent: OpcUaApplicationHostImpersonationTests); assert args.IdentityValidationError is BadIdentityTokenRejected and total elapsed < ~3 s. (This is the automated stand-in for "stop the GLAuth container" — a TCP blackhole is the same outage shape; the bitnami/openldap :3894 harness is not needed for the outage direction.)
  2. Rewrite HandleImpersonation's doc comment: verified 1.5.378 contract (synchronous void handler raised under SessionManager.m_eventLock — a stalled handler serializes all activations), why the block-bridge is irreducible, and where the wall-clock bound + circuit live.
  3. Document the new Security:Ldap keys + semantics in docs/security.md.
  4. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapAuthResilienceTests"expected: PASS.

Commit: test(host): unreachable-directory activation fails bounded; document the SDK impersonation threading contract (03/S2)


R2-08-T7 — S3 sequential-serving repro test (RED — must fail on current code)

Classification: test-first repro (deterministic — concurrency counter, not raw timing) Estimated implement time: 5 min Parallelizable with: T1-T6, T8. Files: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs (new)

  1. New class reusing the NodeManagerHistoryReadTests boot pattern (BootAsync + EnsureVariable + public HistoryRead invoke). Fake IHistorianDataSource whose ReadRawAsync does Interlocked.Increment a live-counter, records the max, await Task.Delay(150ms, ct), decrements, returns one sample.
  2. Add Raw_batch_is_served_with_bounded_parallelism: materialise 8 historized variables; issue one Raw HistoryRead naming all 8; assert fake.MaxObservedConcurrency >= 2 (primary — deterministic) and elapsed < ~1.0 s (secondary, loose). Wrap the invoke in Task.Run(...).WaitAsync(15 s).
  3. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadConcurrencyTests.Raw_batch_is_served_with_bounded_parallelism"expected: FAIL (MaxObservedConcurrency == 1 on the sequential foreach).

Commit: test(opcuaserver): red repro — HistoryRead serves a batch strictly sequentially (03/S3)


R2-08-T8 — ServerHistorian keys + hosted-service wiring

Classification: implementation (options + wiring, additive) Estimated implement time: 5 min Parallelizable with: T1-T6, T7. Files: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs, src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs (properties only), src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs, tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs (or the options test file beside it)

  1. RED: options tests — defaults (4 / 16 / 60 s); Validate() warns on non-positive parallelism/batches/deadline when Enabled (and stays silent when disabled). dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~ServerHistorianOptions|FullyQualifiedName~AddServerHistorianTests"expected: FAIL.
  2. GREEN: add HistoryReadBatchParallelism = 4, MaxConcurrentHistoryReadBatches = 16, HistoryReadDeadline = TimeSpan.FromSeconds(60) (+ XML-doc each: what it bounds, the ⌈N/P⌉ × CallTimeout worst case, 16 × 4 = 64 in-flight cap) + Validate() warnings; add the three settable node-manager properties (defaults matching — MaxTieClusterOverfetch precedent, unused until T9-T13); assign all three in OtOpcUaServerHostedService.StartAsync beside the existing MaxTieClusterOverfetch line. Re-run → PASS.

Commit: feat(runtime): ServerHistorian HistoryRead parallelism/limiter/deadline knobs + node-manager wiring (03/S3)


R2-08-T9 — Async-ify ServeNode + Processed/AtTime arms (still sequential)

Classification: implementation (mechanical async refactor — behavior-neutral) Estimated implement time: 5 min Parallelizable with: T1-T6. Blocked by T7 (repro must be red first), T8 (ct/property names). Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs

  1. ServeNodeprivate async Task ServeNodeAsync(handle, results, errors, read, CancellationToken ct); the read callback gains the token (Func<IHistorianDataSource, string, CancellationToken, Task<HistorianRead>>); await instead of bridging; add catch (OperationCanceledException)errors/results = BadTimeout ahead of the generic catch. Processed/AtTime arms: pass ct into the source calls (:1841/:1865 today CancellationToken.None), collect the per-handle tasks but for now run them sequentially with a single arm-boundary bridge (foreach … ServeNodeAsync(...).GetAwaiter().GetResult() is acceptable interim — the fan-out lands in T11). Thread a ct parameter down from the arm (pass CancellationToken.None at the arm until T12).
  2. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadTests"expected: PASS (behavior-neutral; T7's repro stays red).

Commit: refactor(opcuaserver): async-ify ServeNode + Processed/AtTime HistoryRead internals, thread cancellation (03/S3)


R2-08-T10 — Async-ify ServeRawPaged + Events body

Classification: implementation (mechanical async refactor — behavior-neutral) Estimated implement time: 5 min Parallelizable with: T1-T6. Blocked by T9 (shared BadTimeout pattern/signature conventions). Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs

  1. ServeRawPagedServeRawPagedAsync(..., CancellationToken ct): await the page read (:2171) and the tie-cluster over-fetch (:2203) with ct; add the OCE → BadTimeout arm; continuation store calls unchanged (thread-safe, audited).
  2. Extract the HistoryReadEvents per-handle body into ServeEventsAsync(handle, sourceName, details, selectClauses, results, errors, ct)await the :1911 read with ct; OCE → BadTimeout; existing catch preserved. Both arms keep the interim sequential arm-boundary bridge.
  3. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadTests|FullyQualifiedName~NodeManagerHistoryReadPagingTests"expected: PASS (paging + tie-cluster suites are the regression net).

Commit: refactor(opcuaserver): async-ify ServeRawPaged + HistoryReadEvents internals (03/S3)


R2-08-T11 — Bounded per-node fan-out in all four arms (turns T7 green)

Classification: implementation (the core S3 parallelism fix) Estimated implement time: 5 min Parallelizable with: T1-T6. Blocked by T9, T10. Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs, tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs

  1. Add private void RunBounded(IReadOnlyList<Func<Task>> work, int degree): degree <= 1 or single item ⇒ run sequentially; else SemaphoreSlim(degree)-gated Task.WhenAll, bridged once (GetAwaiter().GetResult(); safe — every body is self-contained try/catch and never throws; document that invariant on the helper).
  2. Rewrite the four arms: pre-filter the cheap synchronous rejections inline (IsReadModified, non-notifier Events handles), collect one Func<Task> per remaining handle, call RunBounded(work, HistoryReadBatchParallelism).
  3. Extend the concurrency test class: Parallelism_is_bounded_above (12 nodes, property set to 3 → MaxObservedConcurrency <= 3) and One_node_failure_does_not_poison_a_parallel_batch (one handle's fake throws → that node BadHistoryOperationUnsupported, the other 7 Good).
  4. dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryRead"expected: PASS (T7 repro green; paging + base suites green).

Commit: feat(opcuaserver): bounded per-node parallel HistoryRead within a batch (03/S3)


R2-08-T12 — Per-request deadline

Classification: implementation (deadline CTS + BadTimeout surfacing) Estimated implement time: 5 min Parallelizable with: T1-T6. Blocked by T11. Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs, tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs

  1. RED: Deadline_bounds_a_hung_backend — fake whose reads complete only on token cancellation (await Task.Delay(Timeout.Infinite, ct)); HistoryReadDeadline = 500 ms; 4 nodes → every node BadTimeout, call returns within ~2 s (invoke wrapped in .WaitAsync(10 s)). dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadConcurrencyTests.Deadline_bounds_a_hung_backend"expected: FAIL (hangs 30 s+ / WaitAsync trips — nothing cancels today).
  2. GREEN: each arm creates using var cts = HistoryReadDeadline > TimeSpan.Zero ? new CancellationTokenSource(HistoryReadDeadline) : null and passes cts?.Token ?? CancellationToken.None down (defensive: non-positive = unbounded, matching the options warning). Re-run → PASS.

Commit: feat(opcuaserver): per-request HistoryRead deadline — hung backends surface BadTimeout (03/S3)


R2-08-T13 — Process-wide concurrent-batch limiter

Classification: implementation (server-side admission control) Estimated implement time: 5 min Parallelizable with: T1-T6. Blocked by T12. Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs, tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs

  1. RED: Saturated_limiter_rejects_with_BadTooManyOperationsMaxConcurrentHistoryReadBatches = 1, HistoryReadDeadline = 200 ms; first batch parked in a gated fake; a second concurrent HistoryRead → all its handles BadTooManyOperations, returns within ~1 s; release the gate; a third read succeeds (slot freed). dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~NodeManagerHistoryReadConcurrencyTests.Saturated_limiter_rejects_with_BadTooManyOperations"expected: FAIL (no limiter exists).
  2. GREEN: lazily-created SemaphoreSlim (double-checked, sized from MaxConcurrentHistoryReadBatches at first use — respects the post-boot property set, same benign race as MaxTieClusterOverfetch); arm entry Wait(min(HistoryReadDeadline, 5 s)) — on false, set BadTooManyOperations on every handle and return; try/finally release. Re-run → PASS.

Commit: feat(opcuaserver): process-wide HistoryRead batch limiter — flood degrades to BadTooManyOperations, not pool exhaustion (03/S3)


R2-08-T14 — Docs + full regression sweep

Classification: docs + verification Estimated implement time: 5 min Parallelizable with: —. Blocked by T5, T6, T13. Files: docs/Historian.md, docs/security.md (if residue from T6), CLAUDE.md (ServerHistorian key table), archreview/plans/STATUS.md

  1. Add the three ServerHistorian keys to docs/Historian.md + the CLAUDE.md table (defaults + one-line semantics); confirm docs/security.md covers the five Security:Ldap keys.
  2. Full touched-suite sweep: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests (or per-project) → all green; then whole-solution dotnet test ZB.MOM.WW.OtOpcUa.slnx (the CI fail-on-skip gate applies).
  3. Record R2-08 outcome in archreview/plans/STATUS.md (branch, commits, test counts).

Commit: docs: ServerHistorian HistoryRead knobs + Security:Ldap resilience keys; R2-08 status (03/S2, 03/S3)


Post-merge (not gating, operator-run)

  • S2 live check: docker-dev rig — unreachable Security:Ldap:Server → Client.CLI UserName connect denies fast; restore shared GLAuth 10.100.0.35:3893 → normal login. Optionally stop/start the GLAuth container on the docker host for a true outage/recovery cycle (circuit opens, then closes).
  • S3 live check: env-gated Category=LiveIntegration gateway suite on the VPN; a 50-node Raw HistoryRead soak against the real gateway while a second client stays interactive.

Overall effort

M (≈1.5-2 dev-days): 6 tasks S2 + 7 tasks S3 + 1 docs/sweep, each ≤5 min of implementation but with the usual test-run + review cadence. Risk concentrated in T11 (parallel fan-out — mitigated by the thread-safety audit + the paging regression suite) and T4/T5 (auth-path semantics — mitigated by the fail-closed default posture and behavior-neutral defaults).