17 KiB
DCL Batch Seam Design — WP2.1a
Date: 2026-08-14 · Scope: design contract for WP2.1b (implementation) · Findings: arch-review #3 (High) + MxGateway 2-RPC subscribe / no-backoff retry (Med) + quality-counter and alarm-fanout costs (Med) · Sized for: 37,500 tags/site (500 instances × 75 tags, docs/deployment/topology-guide.md:255).
1. Batch API on IDataConnection
public record TagSubscribeResult(string TagPath, bool Success, string? SubscriptionId, string? ErrorMessage);
Task<IReadOnlyList<TagSubscribeResult>> SubscribeBatchAsync(
IReadOnlyList<string> tagPaths, SubscriptionCallback callback, CancellationToken ct = default);
Task UnsubscribeBatchAsync(IReadOnlyList<string> subscriptionIds, CancellationToken ct = default);
- One shared callback, plain tag list — deliberately simpler than the plan's sketched
IReadOnlyList<TagSubscription>input record. The only caller passes an identical closure for every tag (DataConnectionActor.HandleSubscribe,DataConnectionActor.cs:745-748:(path, value) => self.Tell(new TagValueReceived(path, value, generation))), andSubscriptionCallbackalready delivers the tag path. A per-tag record would carry nothing. - Partial-failure contract: per-tag faults (bad node id, resolution failure) come back as result rows with
Success:falseand never throw — mirroring the gateway'sSubscribeResult/BulkSubscribeReply(mxaccess_gateway.proto:632) and OPC UA per-item create status. A thrown exception means the whole batch failed at connection level; the actor classifies it with the existingIsConnectionLevelFailure(DataConnectionActor.cs:1056) and drives Reconnecting, exactly as the per-tag path does today (SubscribeTagResult.ConnectionLevelFailure, line 758-761). ReadBatchAsync/WriteBatchAsynckeep their existing signatures (IDataConnection.cs:47,58— dictionary-of-results, already right) but the contract is strengthened from "may loop over singles" to true bulk:OpcUaDataConnectioncurrently loops per tag (OpcUaDataConnection.cs:250-274, 289-315); it becomes oneReadValueIdCollection/WriteValueCollectionservice call (the MxGateway adapter is the in-repo reference —MxGatewayDataConnection.cs:267-290already delegates toReadBulkAsync/WriteBulkAsync). Adapters chunk internally at 1,000 items per service call to stay under typical OPC UAMaxNodesPerRead/Writeoperation limits. Preserved semantics: per-tag failure rows,OperationCanceledExceptionaborts the whole batch (OpcUaDataConnection.cs:263-267).- Single-tag methods are KEPT, implemented as batch-of-one delegations. They have real callers outside the hot path: the OPC UA heartbeat monitor (
OpcUaDataConnection.cs:145),WriteBatchAndWaitAsync's poll loop (line 355),HandleWrite/HandleReadTagValues. Removing them churns every test fake for zero gain; the actor's hot paths simply stop using them.
2. OPC UA: sharding + apply serialization
- Budget option:
OpcUaEndpointConfig.MaxMonitoredItemsPerSubscription, default 5000, flowing intoOpcUaConnectionOptionslike the other subscription knobs (OpcUaDataConnection.cs:74-96). Per-endpoint, not global — item-count ceilings are a property of the target server. 37,500 tags → 8 shards. RealOpcUaClientreplaces the single_subscription(RealOpcUaClient.cs:22,150-162) with a shard list. Placement: first shard with free capacity, else create a new shard from the same options; a shard is deleted when it empties. Ahandle → shardmap serves removal and keepsRemoveSubscriptionsAsyncgrouping oneApplyChangesAsyncper touched shard.- Alarm (A&C) monitored items get a dedicated event shard.
TriggerConditionRefreshAsyncpasses a subscription id (RealOpcUaClient.cs:784-786); pinning event items to one shard keeps that bookkeeping trivial and keepsQueueSize:1000event items from occupying data-shard capacity. - Batch subscribe = N×
AddItem+ oneApplyChangesAsyncper touched shard per chunk (today it is one Apply per tag —RealOpcUaClient.cs:494-495, the core of finding #3). NewIOpcUaClientmembers:CreateSubscriptionsAsync,RemoveSubscriptionsAsync,ReadValuesAsync,WriteValuesAsync, all with per-item results. - All
ApplyChangesAsync/CreateAsync/DeleteAsynccalls serialize behind ONESemaphoreSlim(1,1)per client. The SDK Subscription is not safe under concurrent structural mutation, and today nothing preventsHandleSubscribe's background task, the resolution-retry tick, and an alarm subscribe from applying concurrently. Batching makes contention rare; the single lock (rather than per-shard) also serializes shard creation and is the simplest thing that is correct.
3. Reconnect orchestration & re-seed
Two distinct paths, deliberately staggered differently:
- Instance-driven subscribes (site failover): DeploymentManager already staggers instance creation (
SiteRuntimeOptions.StartupBatchSize20 /StartupBatchDelayMs100,SiteRuntimeOptions.cs:13-19), soSubscribeTagsRequests arrive pre-spaced at ~75 tags each. Each request becomes ONESubscribeBatchAsync(a single chunk). The DCL adds no extra delay on this path — double-staggering would only slow failover. - Adapter-level reconnect (
ReSubscribeAll,DataConnectionActor.cs:1600-1684): today it fires 37,500 independentSubscribeAsynctasks in a tight loop (lines 1642-1653). It becomes sequential chunks ofSubscribeBatchSize= 500 withSubscribeBatchDelay= 50 ms between chunks, all inside one background task carrying the captured adapter + generation; each chunk pipes one completion message (per-tag results inside) back to the actor. 75 chunks ≈ 15-25 s at realistic RTTs — versus today's unbounded task storm. Sequential (not parallel) chunks: subscription creation on one session is serialized by the apply lock anyway, and sequencing bounds device load. SeedTagsAsync(DataConnectionActor.cs:800-851) currently reads per tag, serially, each with a 30 s timeout — the 30 s-per-tag worst case the plan calls out. It becomes chunkedReadBatchAsynccalls:SeedReadBatchSize= 250,SeedReadMaxParallelism= 4 concurrent chunks,SeedReadTimeout(existing, 30 s) now applying per chunk, under an overall deadlineSeedOverallTimeout= 120 s enforced by one linked CTS across all chunks and retry rounds. The existing retry semantics survive unchanged in shape: still-empty tags re-read up toSeedReadMaxAttempts(3) withSeedReadRetryDelay(250 ms) between rounds; on deadline expiry the remaining tags are logged and stay Uncertain until a change notification — the exact behavior documented atDataConnectionOptions.cs:17-25. Chunk size stays moderate on purpose: the "some gateways time out on a large batch" caveat that motivated per-tag reads (DataConnectionActor.cs:792-793) is answered by chunking + per-chunk timeout, not by giving up bulk. Sizing: 150 chunks / 4-way ≈ 38 waves; ~20-40 s typical, so 120 s has honest headroom.- All new knobs live on
DataConnectionOptions(validated inDataConnectionOptionsValidator):SubscribeBatchSize,SubscribeBatchDelay,SeedReadBatchSize,SeedReadMaxParallelism,SeedOverallTimeout.
4. Tag-resolution retry backoff
Today: fixed 10 s periodic timer, one SubscribeAsync task per unresolved tag per tick (DataConnectionActor.cs:1536-1574). A dead device with thousands of unresolved tags probes forever at full width.
- Exponential backoff: interval starts at
TagResolutionRetryInterval(existing, 10 s), doubles per fully-failed round, capped atTagResolutionRetryMaxInterval= 5 min (new option). Reset to the floor when any tag resolves or on reconnect (ReSubscribeAllalready clears_unresolvedTags, line 1617). - Batched probes: each tick issues
SubscribeBatchAsyncover the not-in-flight unresolved set, chunked atSubscribeBatchSize— not N single subscribes._resolutionInFlightdedup survives at chunk granularity. - Single-shot timer, rescheduled on probe completion — a periodic timer cannot back off. This inherently preserves the anti-starvation property the current code defends with its
IsTimerActivegate (DataConnectionActor.cs:1015-1028): the next tick is scheduled only when the previous round's completion message arrives, so a fan-out of failures can never reset the clock.
5. MxGateway strategy — no cross-repo change needed for the main path
Checked ~/Desktop/MxAccessGateway: the proto already has the bulk family — SubscribeBulkCommand (AddItem + Advise per tag in one worker STA pass, one gRPC round trip; mxaccess_gateway.proto:325, worker MxAccessSession.SubscribeBulk), UnsubscribeBulkCommand, AddItemBulkCommand, ReadBulkCommand, WriteBulkCommand, all with per-item results. The shipped ZB.MOM.WW.MxGateway.Client 0.2.0 — the exact version this repo pins (Directory.Packages.props:98) — exposes SubscribeBulkAsync/AddItemBulkAsync/UnsubscribeBulkAsync (verified in the packaged DLL, dist/ZB.MOM.WW.MxGateway.Client.0.2.0.nupkg). So:
- Plain-advise mode (a configured write-user):
SubscribeBatchAsync= chunkedSubscribeBulkAsync— the 2-RPC-per-tag subscribe (RealMxGatewayClient.cs:93-103:AddItemAsync+AdviseAsync) collapses to one RPC per 500 tags. Unsubscribe =UnsubscribeBulkAsync. - Supervisory mode (
WriteUserId == 0,RealMxGatewayClient.cs:52): the worker has no bulk supervisory advise (SubscribeBulkissues plainAdvise; only singleAdviseSupervisoryexists). Fallback per plan: oneAddItemBulkAsyncfor the chunk, then client-side pipelined per-itemAdviseSupervisorycommands with bounded parallelismMxSupervisoryAdviseParallelism= 16 (new option) — 37,500 tags ≈ 2,344 in-flight windows instead of 75,000 serial round trips. The existingLazy<Task>advise-once map (RealMxGatewayClient.cs:47) is pre-populated from the bulk path so a concurrent write still awaits the same advise. - Cross-repo follow-up (note, not a blocker): add an additive
bool supervisoryfield toSubscribeBulkCommand(or anAdviseSupervisoryBulkCommand) in mxaccessgw so supervisory sites also get one-RPC subscribe. File against mxaccessgw; ScadaBridge picks it up on the next client bump with a capability/Unimplementedfallback to the pipelined path. - Read/Write are already true bulk in this adapter — no change.
6. Quality-counter flush
HandleTagValueReceived calls _healthCollector.UpdateTagQuality on every value received, even when quality is unchanged (DataConnectionActor.cs:1811). New policy: in-actor bucket math stays per-message (cheap), but the collector push is transition-gated + coalesced — skip entirely when prevQuality == newQuality; on a genuine transition set a dirty flag and flush on a QualityFlushInterval = 1 s single-shot timer. Health reports poll at 30 s, so 1 s coalescing loses nothing. Immediate synchronous flushes are retained where correctness depends on them: PushBadQualityForAllTags on disconnect (line 1595), unsubscribe (line 1135), and the ReSubscribeAll reset (line 1635).
7. Alarm subscriber prefix index + gateway union filter
- Index:
HandleAlarmTransitionReceivedscans every subscribed source and does twoStartsWithper source per transition (DataConnectionActor.cs:1974-1979). Replace with buckets keyed by the source reference's first path segment (split on.): lookup extracts the first segment ofSourceObjectReference/SourceReferenceand tests only that bucket. Sources whose reference contains no separator (a prefix shorter than one full segment) go to a small linear residue list — exact semantics preserved, including the per-source condition-type gate and theSnapshotCompletebroadcast-to-all (lines 1965-1972), which bypasses the index entirely. - Gateway union filter: the MxGateway alarm stream is opened gateway-wide today (
alarmFilterPrefix: null,MxGatewayDataConnection.cs:231-236).StreamAlarmsRequestcarries a singlealarm_filter_prefix(mxaccess_gateway.proto:986), so the pushable union is the longest common prefix of the active source references. The adapter recomputes the LCP on subscribe; the stream restarts (cancel + reopen — the source replays a fresh snapshot, whichNativeAlarmActoralready handles) only when a new source falls outside the current prefix. Unsubscribes never restart — a too-broad prefix is only bandwidth. The prefix stays a bandwidth optimisation, never correctness: the actor's per-source + condition-type gate remains authoritative, mirroring the OPC UA WhereClause stance (RealOpcUaClient.cs:659-662).
8. Behaviors that MUST be preserved (stage-b checklist)
- Become/Stash lifecycle — Connecting/Connected/Reconnecting states, stash rules per state (
DataConnectionActor.cs:249-550) unchanged. - Generation fencing — batch callbacks capture
_adapterGenerationexactly as the per-tag closures do (:697,1637);TagValueReceived/AlarmTransitionReceivedstill drop stale generations (:1771,1949). Chunked reconnect runs against the adapter captured at chunk-dispatch time (S7 discipline,:698-704). - Immediate bad quality on disconnect —
PushBadQualityForAllTagsstays synchronous inBecomeReconnecting(:473), never deferred to the quality-flush timer. - Transparent re-subscribe — derived from
_subscriptionsByInstance(durable truth,:1602-1607),_instancesByTagNOT cleared (:1612-1615), reconnect re-seed included (:1655-1683). - Write failures synchronous to the calling script —
HandleWrite/HandleWriteBatchPipeTowithWriteTimeouttranslation unchanged (:1141-1242). - Seed-after-registration — seeds still ride
SubscribeCompletedand are delivered only after_subscriptionsByInstanceregistration (the static-tag fix,:765-782,999-1012); only resolved tags are seeded. - In-flight orphan guards — unsubscribe-during-subscribe releases adapter handles (
:860-904), duplicate alarm feeds released not overwritten (:1908-1932); batch completions apply per-tag through the same guard logic. - Unresolved-tag semantics — Bad-quality
TagValueUpdatesignal (:991-995),_subscribesInFlight/AlreadySubscribeddedup (:706-729), counter promotion rules (:938-996). - Alarm filter last-writer-wins warning (
:1841-1848) and the client-side gate as sole authority.
9. Stage-b test plan (fake-client)
Existing DCL suite (tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ — actor lifecycle, generation fencing, alarm routing) must stay green. New pins:
- One apply per chunk: fake
IOpcUaClientcountsApplyChangesAsynccalls — N tags throughSubscribeBatchAsync→ceil(N/SubscribeBatchSize)applies, not N. - Sharding: 12,001 tags with budget 5,000 → 3 shards; removal empties → shard deleted; alarm item lands on the event shard and
ConditionRefreshtargets it. - Apply serialization: fake apply with an injected delay + concurrency flag — concurrent batch subscribe / unsubscribe / alarm subscribe never overlap an apply.
- Re-seed deadline: fake
ReadBatchAsyncthat hangs → seed returns atSeedOverallTimeout, remaining tags pending/Uncertain,SubscribeCompletedstill delivered and acked. - Backoff caps: all-fail probe rounds produce intervals 10 s, 20 s, 40 s … capped at
TagResolutionRetryMaxInterval(compressed values in-test); any success resets to the floor; probes are batched (one client call per chunk, not per tag). - MxGateway modes: plain mode → exactly one
SubscribeBulkAsyncper chunk; supervisory mode → oneAddItemBulkAsync+ per-item advises with in-flight count ≤MxSupervisoryAdviseParallelism. - Partial failure surface: a per-tag failure row →
_unresolvedTags+ Bad-quality push, responseSuccess:true; a thrown batch → all-tags connection-level →SubscribeTagsResponse(Success:false)+ Reconnecting. - Quality flush: M updates at unchanged quality → zero collector calls; one transition → one flush within the interval; disconnect flush still immediate.
- Alarm index equivalence: routing decisions identical to the linear scan over a sample set including shared-prefix sources, sub-segment prefixes (residue list), and the empty-source
SnapshotCompletebroadcast. LCP stream restarts only when a new source escapes the current prefix.
10. Defaults summary
| Option | Home | Default |
|---|---|---|
MaxMonitoredItemsPerSubscription |
OpcUaEndpointConfig |
5000 |
SubscribeBatchSize |
DataConnectionOptions |
500 |
SubscribeBatchDelay |
DataConnectionOptions |
50 ms |
SeedReadBatchSize |
DataConnectionOptions |
250 |
SeedReadMaxParallelism |
DataConnectionOptions |
4 |
SeedOverallTimeout |
DataConnectionOptions |
120 s |
TagResolutionRetryMaxInterval |
DataConnectionOptions |
5 min |
QualityFlushInterval |
DataConnectionOptions |
1 s |
MxSupervisoryAdviseParallelism |
DataConnectionOptions |
16 |
Existing options keep their meanings; SeedReadTimeout (30 s) becomes per-chunk rather than per-tag.