4769044f88
Closes the two follow-ups left open by the MxGateway 0.2.0 bump. Reading the
gateway's own contract first changed what each of them is, and one of my earlier
claims was simply wrong -- both corrections are recorded below.
Replay gap
----------
A sentinel MxEvent carries family = UNSPECIFIED, so it fell through EventPump's
family switch into the default drop: we silently ignored a documented MUST ("you
missed events -- discard local state and re-snapshot"). It is now intercepted and
answered by re-issuing SubscribeBulk for every tracked subscription, since
MXAccess delivers an initial value per item on subscribe -- re-subscribing IS the
re-snapshot. It does not restart the event pump (the stream carrying the sentinel
is healthy, and the handler runs on that stream's own read loop, so disposing the
pump from there would deadlock on itself), it does not block the read loop, and
re-entry is gated so a burst collapses to one pass instead of a subscribe storm
per sentinel.
Handled on the READ loop, not in the dispatch loop, because everything past that
point is subject to newest-dropped channel pressure -- and "you missed events" is
the one signal whose loss is unrecoverable. Pinned by a test that fills the
channel and still expects the sentinel.
⚠️ This cannot fire in the shipped configuration, and I did not pretend otherwise
or paper over it by enabling resume. The gateway emits the sentinel only for a
stream opened with after_worker_sequence > 0; GatewayGalaxySubscriber passes 0,
which the gateway treats as "fresh stream, not a resume -- plain attach, no
replay, no sentinel". Enabling resume is a larger and genuinely delicate change:
the sequence is only meaningful within ONE gateway session, our reconnect
recreates the session, and replaying a remembered sequence against a fresh
session sets the server's live watermark ABOVE every event it is about to send --
silencing the stream instead of recovering it. That deserves its own change with
a live gate, which I cannot run from here.
Sparse arrays -- correcting the last commit message
---------------------------------------------------
I wrote that the bump "makes handling it possible" on the decode path. That is
wrong: MxSparseArray is documented write-only, the gateway expands it before
forwarding, and "the worker never receives or produces it". So the decode path
is unreachable by contract and the write path has no use for it either -- it is a
wire compaction for a WHOLE-array write, not a partial update (unmentioned
indices are RESET, deliberately, because the gateway cannot read-modify-write
without racing the provider). It is therefore the wrong primitive for an OPC UA
IndexRange write, which expects untouched elements preserved -- and WriteRequest
carries no IndexRange in the first place.
It is decoded anyway, as contract defence: the alternative if a future gateway
ever emits one is a silent null published under Good quality. Fill semantics
mirror the gateway's own SparseArrayExpander exactly (epoch for Time, empty
string, Integer widening to long[] when any element is int64) so a value that
round-trips through a write reads back as the same CLR array.
Also: the decoder is now total by contract, since it runs inside the dispatch
loop where an escaping exception kills dispatch for the life of the pump. A
malformed payload degrades instead of throwing.
A real defect found in the same code
------------------------------------
A stream the GATEWAY completes -- a closed or evicted session -- ends the RPC
without an error, so nothing threw, the supervisor was never notified, and the
pump exited silently. GetHealth() kept reporting Healthy, which also means the
#527 comms-loss watchdog could never trip, because it requires a non-Healthy
state. The driver went permanently deaf with no signal anywhere. This one is
live, unlike the two above. Now reported as a fault; the falsification control is
the existing CleanShutdown_DoesNotInvokeOnStreamFault, since our own disposal
also ends the stream and must NOT be reported.
Value kinds we cannot represent are now counted and warned once rather than
published as an indistinguishable null. The status is deliberately NOT downgraded
to Bad: nothing can reach that branch today, and changing what an OPC UA client
sees for a live shape is not a change to make without a gate. The counter exists
so the next person has evidence instead of a guess.
Verified: 330 Galaxy tests green (12 new), plus Galaxy.Browser 21, Runtime 558,
Core 351; full solution builds 0 errors with the warning census unchanged. Every
new test was falsified first -- disabling the gap interception reds 3 of 4 (the
throwing-callback test passes vacuously when the callback never runs, by
construction), disabling sparse decode plus the representability signal reds all
7 decoder tests, and both were restored and re-verified green.
Docs: the Galaxy.Performance invariant was stated as received = dispatched +
dropped + in-flight, which this breaks -- a sentinel is received but never enters
the channel -- so it now reads + replay_gaps. Galaxy.md gains the three
operational notes; DriverLifecycle.md records that "reaches a non-Healthy state"
is an obligation a driver can silently fail to meet, which is the general shape
of the clean-end bug.
183 lines
10 KiB
Markdown
183 lines
10 KiB
Markdown
# Galaxy backend performance
|
||
|
||
This document covers the performance surface of the in-process
|
||
`GalaxyDriver` (the v2 mxgw backend) — the ActivitySource it emits, the
|
||
metrics on its EventPump, the soak scenario that validates it, and the
|
||
tuning knobs you can reach for when the dev parity rig surfaces a hot
|
||
spot.
|
||
|
||
## Tracing surface (PR 6.1)
|
||
|
||
The driver emits spans on the `ZB.MOM.WW.OtOpcUa.Driver.Galaxy`
|
||
ActivitySource. No package dependency on OpenTelemetry — the host
|
||
process picks the listener (OTLP exporter, dotnet-trace, Application
|
||
Insights). Wire it via `OpenTelemetry.Trace.AddSource(...)` in the
|
||
host's tracing pipeline.
|
||
|
||
| Span | Source | Tags |
|
||
|------|--------|------|
|
||
| `galaxy.subscribe_bulk` | `TracedGalaxySubscriber` | `galaxy.client`, `galaxy.tag_count`, `galaxy.buffered_interval_ms`, `galaxy.success_count` |
|
||
| `galaxy.unsubscribe_bulk` | `TracedGalaxySubscriber` | `galaxy.client`, `galaxy.tag_count` |
|
||
| `galaxy.stream_events` | `TracedGalaxySubscriber` | `galaxy.client`, `galaxy.event_count` (set on stream end) |
|
||
| `galaxy.write` | `TracedGalaxyDataWriter` | `galaxy.client`, `galaxy.tag_count`, `galaxy.secured_write_count`, `galaxy.success_count` |
|
||
| `galaxy.get_hierarchy` | `TracedGalaxyHierarchySource` | `galaxy.client`, `galaxy.object_count` |
|
||
|
||
The stream-events span deliberately covers the *entire* stream lifetime
|
||
rather than per-event spans — at 50k tags / 1Hz the per-event volume
|
||
would dominate the trace pipeline. Per-event visibility flows through
|
||
the metrics surface instead.
|
||
|
||
## Metrics surface (PR 6.2)
|
||
|
||
`EventPump` publishes three counters on the
|
||
`ZB.MOM.WW.OtOpcUa.Driver.Galaxy` meter, each tagged with
|
||
`galaxy.client` so multi-driver hosts can split by source:
|
||
|
||
| Counter | Unit | Meaning |
|
||
|---------|------|---------|
|
||
| `galaxy.events.received` | `{event}` | MxEvents read from the gateway StreamEvents stream |
|
||
| `galaxy.events.dispatched` | `{event}` | MxEvents that made it through the bounded channel into `OnDataChange` |
|
||
| `galaxy.events.dropped` | `{event}` | MxEvents discarded because the bounded channel was full (newest-dropped) |
|
||
| `galaxy.events.replay_gaps` | `{gap}` | Replay-gap sentinels received — the gateway evicted events we can never see |
|
||
| `galaxy.events.undecodable` | `{event}` | Data-change events carrying a value kind this driver cannot represent |
|
||
|
||
The invariant is `received = dispatched + dropped + replay_gaps +
|
||
(in-flight in the channel)`. A replay-gap sentinel is counted as
|
||
received but never enters the channel — it is a control signal with no
|
||
item payload, handled on the read loop precisely so channel pressure
|
||
can never drop it.
|
||
|
||
Watch the dropped counter — it is the leading indicator of
|
||
listener back-pressure. A non-zero dropped rate means a downstream
|
||
consumer (DriverNodeManager → UA notification queue → client) is
|
||
slower than the gw event stream; investigate that consumer before
|
||
raising `EventPump` channel capacity.
|
||
|
||
`galaxy.events.replay_gaps` should be flat at zero, for a structural
|
||
reason rather than a healthy one: the gateway only emits the sentinel on
|
||
a stream opened with `after_worker_sequence > 0`, and the driver opens
|
||
with `0` ("fresh stream, not a resume"). A non-zero value therefore means
|
||
the resume path was enabled — each gap costs a full re-subscribe of every
|
||
tag on the driver, so a climbing counter is a sizing problem, not just an
|
||
observation.
|
||
|
||
`galaxy.events.undecodable` should likewise be flat at zero: every
|
||
`MxValue` kind the contract declares decodes today. Non-zero means the
|
||
gateway shipped a new wire type and those readings are being published as
|
||
**nulls under the event's own quality** — the status is deliberately not
|
||
downgraded, so this counter is the only signal that it is happening.
|
||
|
||
### Bounded channel design
|
||
|
||
The pump runs two background tasks:
|
||
|
||
1. **Producer** — reads from `IGalaxySubscriber.StreamEventsAsync`,
|
||
increments `events.received`, and `TryWrite`s into a bounded
|
||
`Channel<MxEvent>`. When the channel is full, the producer counts
|
||
the drop and continues reading the gw stream so back-pressure does
|
||
not propagate upstream (which would stall the gw worker and cascade
|
||
to *all* driver instances sharing that worker).
|
||
2. **Consumer** — reads from the channel, fans out via
|
||
`SubscriptionRegistry`, increments `events.dispatched`.
|
||
|
||
Default channel capacity is 50_000 (one second of headroom at 50k
|
||
tags / 1Hz). Override via the `EventPump` constructor's
|
||
`channelCapacity` parameter; the public-facing wiring path in
|
||
`GalaxyDriver.EnsureEventPumpStarted` does not yet expose this through
|
||
`GalaxyDriverOptions` because no parity scenario has needed it. Add it
|
||
when soak data does.
|
||
|
||
## Buffered update interval (PR 6.3)
|
||
|
||
`MxAccess.PublishingIntervalMs` (default 1000) flows through both
|
||
subscribe paths:
|
||
|
||
- `GalaxyDriver.SubscribeAsync` — the caller's `publishingInterval`
|
||
wins when non-zero (the server's UA subscription publishingInterval
|
||
drives this in production). When the caller passes
|
||
`TimeSpan.Zero`, the configured option is the fallback.
|
||
- `PerPlatformProbeWatcher` — the watcher passes the configured value
|
||
through `SubscribeBulkAsync` so probe `ScanState` changes publish at
|
||
the deployment's chosen cadence.
|
||
|
||
A session-level `SetBufferedUpdateInterval` RPC exists in the gw
|
||
protocol but the .NET client doesn't expose a typed helper yet —
|
||
adjusting an existing subscription's interval mid-flight is a
|
||
follow-up. Today's path subscribes once at the right interval, which
|
||
covers the common case.
|
||
|
||
## Soak scenario (PR 6.4)
|
||
|
||
`SoakScenarioTests.Soak_HoldsSubscription_AndKeepsEventStreamFlowing`
|
||
in `Driver.Galaxy.ParityTests` is the long-running validation. It
|
||
subscribes a configurable tag count (default 50_000), holds the
|
||
subscription for a configurable duration (default 24h), polls the
|
||
three counters every minute, and asserts:
|
||
|
||
- `events.received` continues to grow (gw stream isn't stuck)
|
||
- `events.dropped / events.received` stays under the configured
|
||
ceiling (default 0.5%)
|
||
- process working-set doesn't grow more than 1 GB above baseline
|
||
(leak guard)
|
||
|
||
Always skipped unless the operator opts in:
|
||
|
||
```bash
|
||
# Full 24h × 50k soak (production validation)
|
||
OTOPCUA_SOAK_RUN=1 dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/
|
||
|
||
# Compressed CI-friendly run (10min × 1k tags, 1% drop ceiling)
|
||
OTOPCUA_SOAK_RUN=1 OTOPCUA_SOAK_MINUTES=10 OTOPCUA_SOAK_TAGS=1000 \
|
||
OTOPCUA_SOAK_DROP_PCT=1.0 \
|
||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/
|
||
```
|
||
|
||
The scenario writes a per-minute CSV-style row to stdout
|
||
(`soak,<minutes>,received=…,dispatched=…,dropped=…,ws_mb=…`) so an
|
||
operator can grep the test runner output mid-run.
|
||
|
||
## Tuned defaults (PR 6.5)
|
||
|
||
| Option | Default | Source | Notes |
|
||
|--------|---------|--------|-------|
|
||
| `Gateway.ConnectTimeoutSeconds` | 10 | unchanged | Cold-start network paths fit comfortably; soak never observed >2s |
|
||
| `Gateway.DefaultCallTimeoutSeconds` | 30 | **bumped from 5** in PR 6.5 | A 50k-tag `SubscribeBulk` can exceed 5s under MxAccess COM apartment lock contention; 30s leaves headroom while still failing fast on a wedged worker |
|
||
| `Gateway.StreamTimeoutSeconds` | 0 (unlimited) | unchanged | The stream must run for the lifetime of the driver |
|
||
| `MxAccess.PublishingIntervalMs` | 1000 | unchanged | Matches the legacy `LMXProxyServer` cadence; deployments needing tighter health visibility can dial down |
|
||
| `Reconnect.InitialBackoffMs` | 500 | unchanged | First retry shouldn't dogpile a recovering gw |
|
||
| `Reconnect.MaxBackoffMs` | 30_000 | unchanged | 30s ceiling so a long-down gw doesn't sit in 5+ min backoff |
|
||
| `Repository.DiscoverPageSize` | 5000 | unchanged | One Galaxy page round-trip per ~5k objects; soak hadn't surfaced pressure |
|
||
| `EventPump` channel capacity | 50_000 | unchanged | One second of headroom at 50k tags / 1Hz |
|
||
|
||
The unchanged rows are not "definitely correct" — they are "no live
|
||
data argues for changing them." Re-run the soak scenario after every
|
||
substantive driver change, and revise this table when the data does.
|
||
|
||
## Where to look first when something's slow
|
||
|
||
1. **Slow `Discover`?** Inspect `galaxy.get_hierarchy` span duration
|
||
and `galaxy.object_count`. The gw walks the Galaxy DB serially;
|
||
slow Discovers usually mean a slow ZB SQL.
|
||
2. **Subscribe pile-up?** `galaxy.subscribe_bulk` span duration
|
||
correlates with `galaxy.tag_count`. If duration ÷ tag_count starts
|
||
climbing, the gw worker is probably under apartment-lock pressure.
|
||
3. **Events stalled?** Watch `galaxy.events.received`. Flat-lined
|
||
means the gw stream is wedged — kick the reconnect supervisor by
|
||
forcing a `ReinitializeAsync`.
|
||
4. **Dropped events?** Non-zero `galaxy.events.dropped` means a slow
|
||
downstream consumer. Profile `OnDataChange` handlers in
|
||
`DriverNodeManager` before bumping the channel capacity.
|
||
5. **Memory growing?** Confirm with the soak scenario's working-set
|
||
leak guard. Likely culprits: lingering subscription handles in
|
||
`SubscriptionRegistry`, or a downstream consumer retaining
|
||
`DataValueSnapshot` references past their useful life.
|
||
|
||
## Scripted-alarm engine — hot-path allocation reuse
|
||
|
||
`ScriptedAlarmEngine` keeps a per-alarm reusable evaluation scratch in `_scratchByAlarmId` — the read-cache `Dictionary<string, DataValueSnapshot>` and the `AlarmPredicateContext` are allocated once per alarm (on first evaluation) and refilled in place across every subsequent predicate evaluation. The hot path no longer allocates a fresh dictionary + context per upstream tag change. (Core.ScriptedAlarms-009)
|
||
|
||
Safety: reuse is serialised under `_evalGate`, so two threads can never observe the same scratch in a half-refilled state. The context wraps the read-cache by reference, so refilling the dictionary is what the predicate's `ctx.GetTag(path)` calls observe. `LoadAsync` clears `_scratchByAlarmId` alongside `_alarms` so a config-publish drops the prior generation's scratch (a new generation may carry different `Inputs` / `Logger`). Regression tests in `ScriptedAlarmEngineTests` lock the reuse contract:
|
||
- `Reevaluation_reuses_the_same_read_cache_dictionary` — asserts dictionary instance identity across two evaluations.
|
||
- `Reevaluation_reuses_the_same_predicate_context` — same, for the context.
|
||
- `LoadAsync_drops_the_prior_generations_scratch` — asserts a publish resets the scratch.
|