Files
ScadaBridge/docs/plans/2026-08-15-target-scale-load-test-design.md
T
Joseph Doherty 8abebdae33 docs(plans): target-scale load test harness design memo (WP-4 / register #25)
Records harness architecture, the real-vs-faked table with a justification per fake,
metric definitions, falsifiable pass/fail thresholds derived from the WP-4 acceptance
criteria, and the eight deviations from the WP-4 protocol with reasons — including the
1-hour to 20-minute sustained-window shortening (memory reported as a slope so a
shorter window still answers the leak question) and the four [xc-*] criteria this
harness does not cover.
2026-08-15 02:27:47 -04:00

16 KiB

Target-Scale Load Test — Harness Design (Phase-8 WP-4, deferred-work register #25 + row 50)

Date: 2026-08-15 Closes: deferred-work register #25 (target-scale load test) and row 50 (S&F drain rate + per-subscriber stream backpressure), and residual 7 of docs/plans/2026-08-15-arch-review-remediation-execution-log.md. Spec: docs/plans/phase-8-production-readiness.md WP-4 (:152-170) + test protocol (:314-320). Results: docs/plans/2026-08-15-target-scale-load-test-results.md. Harness: tests/ZB.MOM.WW.ScadaBridge.LoadHarness/ (+ CI smoke in tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/TargetScale/TargetScaleHarnessSmokeTests.cs).


1. Why this exists

Register row 25 records that WP-4 was claimed complete on the strength of a 107-byte checklist stub ("Status: Complete / Tests: All passing / Build: 0 errors, 0 warnings") with no per-work-package results and no linked run. The nearest real coverage was arithmetic:

  • PerformanceTests/StaggeredStartupTests.csTagCapacity_75TagsPer500Machines_37500Total and the 10-site distribution test compute products of integers. No actor is created.
  • PerformanceTests/HealthAggregationTests.cs — feeds 10 hand-built SiteHealthReport records to a real aggregator. Real, but not under load.
  • PerformanceTests/Streaming/SiteStreamThroughputTests.cs — a genuine 100k-event throughput test, but one subscriber and no instances behind it.

So the job here is not to re-assert the claim. It is to produce the evidence the claim never had, and to report what that evidence actually says — including where it is uncomfortable.

2. Feasibility constraints that shaped the design

The host is one macOS box (Apple M4 Pro, 14 cores, 48 GB) running OrbStack with the 8-node docker/ rig already up. The docker/ topology has 3 sites and cannot host 10 real site pairs at 500 instances each; a full-docker WP-4 topology is out of scope.

There is no simulated protocol adapter in the DCL. Only OpcUa and MxGateway ship. DataConnectionFactory.RegisterAdapter is the documented extension point, and tests/…DataConnectionLayer.Tests/Actors/FakeBatchDataConnection.cs establishes the fake-adapter shape.

375,000 real OPC UA monitored items is not the system under test. That would measure the OPC UA SDK. The system under test is everything above the adapter callback: the actor hierarchy, the site stream, store-and-forward, health, audit, and the streaming relay.

The established repo pattern for scale/failover validation is in-process performance tests (PerformanceTests/Failover/FailoverTimingTests.cs runs a real two-node cluster in-process via TwoNodeClusterFixture with production BuildHocon and production timings). This harness extends that pattern rather than inventing a new one.

3. Harness shape, and why

A standalone console executable (tests/ZB.MOM.WW.ScadaBridge.LoadHarness, an Exe), plus a scaled-down Category=Performance [Fact] in PerformanceTests that references it.

The reason it is not purely an xunit suite is specific and worth recording: in this repo the [Trait("Category", "Performance")] attribute enables a filter, it does not exclude by default. dotnet test ZB.MOM.WW.ScadaBridge.slnx runs the Performance project's tests — FailoverTimingTests included, at 45-90s. A 20-minute full-scale test dropped in there would be a 20-minute tax on every solution test run. Hence:

Full protocol CI smoke
Where LoadHarness executable TargetScaleHarnessSmokeTests
Scale 10 x 500 x 75 = 375,000 2 x 10 x 5 = 100
Duration ~28 min wall ~80 s
Purpose The WP-4 numbers Harness does not bit-rot

The smoke test asserts the shape of a healthy result (traffic flows end to end, all sites tracked centrally, S&F drains to empty, a stalled subscriber costs healthy ones nothing) so #25's evidence can be regenerated on demand rather than being a one-off.

3.1 Process topology

One process. Ten SiteRuntimeFixture instances, each owning:

  • its own ActorSystem (non-clustered),
  • its own LocalDb SQLite file (real AddZbLocalDb + real SiteStorageService),
  • a real DataConnectionManagerActor with 5 DataConnectionActor children,
  • 500 real InstanceActors, each configured with 75 data-sourced attributes,
  • a real SiteStreamManager at the production StreamBufferSize (1000),
  • a real StoreAndForwardStorage + StoreAndForwardService,
  • a real SiteHealthCollector.

Plus one shared real CentralHealthAggregator standing in for central.

3.2 What is real and what is faked

Layer Real / faked Why the fake does not invalidate the measurement
OPC UA server + socket Faked (SimulatedDataConnection) Everything replaced is on the far side of the process boundary: socket I/O, the SDK's session/subscription machinery, the device. The system under test begins at the SubscriptionCallback invocation, which is exactly where the real adapter hands off.
DataConnectionFactory / DataConnectionManagerActor / DataConnectionActor Real The per-connection actor's _instancesByTag fan-out and its single-mailbox serialization point are a genuine scale surface — one of the things WP-4 must characterize. The sim adapter is registered through the documented RegisterAdapter seam.
InstanceActor Real The whole point. Real TagValueUpdate ingest, real type coercion, real PublishAndNotifyChildren.
SiteStreamManager Real, production StreamBufferSize Per-subscriber Buffer(DropHead) behaviour is under test.
StreamRelayActor + bounded DropOldest channel Real, production capacity (1000) This is SiteStreamGrpcServer's per-subscription machinery, reused verbatim.
gRPC socket writer Faked (a reader task) Deliberate: it is precisely the hop whose slowness register row 50 asks about, and a controllable reader is the only way to hold it still.
StoreAndForwardService / Storage / SQLite Real Drain throughput is the measurement.
S&F delivery target (central) Faked (counting stub returning true) What is measured is the site-local buffer's capacity, not a remote endpoint's.
SiteHealthCollector / CentralHealthAggregator Real CollectReport at 37,500 subscriptions is the interesting term.
IHealthReportTransport (gRPC hop) Faked (direct call) The transport is a documented interface seam; the cost being measured is collect + ingest.
Akka cluster membership / failover Not exercised Already measured on a real two-node rig by FailoverTimingTests and docker/failover-drill.sh. WP-4 asks about the hierarchy under the singleton.

3.3 The latency measurement is genuinely end to end

The driver stamps DateTimeOffset.UtcNow on the TagValue it hands the adapter callback. That instant then travels verbatim, with no re-stamping:

driver → SubscriptionCallback → DataConnectionActor (self.Tell(TagValueReceived))
       → TagValueUpdate.Timestamp        (DataConnectionActor fan-out)
       → AttributeValueChanged.Timestamp (InstanceActor.HandleTagValueUpdate copies it)
       → SiteStreamManager hub → per-subscriber Buffer → StreamRelayActor
       → proto SiteStreamEvent.AttributeChanged.Timestamp
       → subscriber reader: now - Timestamp

Everything on that path is production code. This is the harness's single most important property: the reported percentiles are not a synthetic stopwatch around a method call, they are the real DCL-boundary-to-subscriber path.

4. Load model

WP-4 fixes the scale but states no tag update rate, so one has to be chosen and justified.

  • Nominal rate: one update per tag per 10 seconds. At 375,000 subscriptions that is 37,500 tag updates/second fleet-wide, 3,750/s per site, 750/s per data connection actor.
  • Rationale: a plant SCADA tag that genuinely changes every 10 s is a busy tag; assuming every one of 375,000 tags does so simultaneously is a deliberately pessimistic steady state, not a typical one. It also sits comfortably above the 10k events/s floor the existing SiteStreamThroughputTests pins, so the two measurements bracket each other.
  • 5 data connections per site. A site with 37,500 tags behind a single OPC UA server is not the realistic shape, and it would make one actor mailbox the entire story. Five connections (7,500 tags each) is realistic; the per-connection rate is reported so the single-connection case is derivable.
  • 10 live stream subscribers per site (100 fleet-wide). Every subscriber's stream graph sees the full site event flow and filters it by instance name, so this is the fan-out multiplier on the hub — the pessimistic direction.

The driver reports EmitLagSeconds (cumulative slice overrun) and SkippedNoCallback. If the harness itself cannot offer the nominal rate, those numbers say so, and the achieved-vs-nominal ratio is published rather than the nominal being quietly reported as if achieved.

5. Metric definitions

Metric Definition
Tag update latency Subscriber receive instant minus the driver's emit stamp, in ms. Logarithmic histogram, 16 buckets/octave (bucket width ≤ 4.4%, so a reported percentile is within ~4.4% of truth). Mean/max are exact, not bucketed. Steady-state window only — the histogram is repointed at window start so ramp outliers cannot contaminate it.
Instance ramp / deployment at scale Wall time to create every InstanceActor, at the production StartupBatchSize (20) / StartupBatchDelayMs (100) staggering. Both the fleet total (sites in parallel) and the slowest single site are reported; the latter is the "deploy 500 instances to a site" figure.
Memory growth Working set and managed heap, sampled every 10 s. Reported as start→end delta, peak, and least-squares slope in MB/min over the steady-state window — because a run that sawtooths around a stable mean and one that climbs monotonically can share the same endpoint delta.
CPU Process.TotalProcessorTime delta / wall delta, expressed as a percentage of one core (so 1400% = the whole 14-core box saturated) and also as a percentage of the box.
Health report delivery timing SiteHealthCollector.CollectReport(siteId) + CentralHealthAggregator.ProcessReport(report), timed together, once per site per 30 s tick under full load.
Debug view latency Ask<DebugViewSnapshot>(DebugSnapshotRequest) round-trip to a randomly chosen live InstanceActor every 5 s under full load — so the measurement includes real queueing behind production traffic.
S&F drain rate Reported as two numbers: time-to-first-delivery (the configured retry latency) and throughput measured from the first delivery to an empty buffer (the engine's actual capacity). A drain-progress series is captured so a steady rate can be told from a stall-then-burst.
Slow-subscriber isolation Several subscribers on the same instance (identical offered event sequence); one reader stalled at 50 ms/event. Delivery ratio per subscriber, plus per-subscriber channel eviction counts.

6. Pass/fail thresholds

Derived from the WP-4 acceptance criteria. Where the criterion is qualitative ("within acceptable time", "does not degrade"), the threshold is stated here so the verdict is falsifiable rather than a judgement call made after seeing the number.

# Criterion (WP-4) Threshold
1 [2.5-1] 10 sites simultaneously operational All 10 sites built, ramped, and tracked by the central aggregator
2 [2.5-2] 500 instances/site with active subscriptions 5,000 InstanceActors alive; SkippedNoCallback = 0 in the steady window
3 [2.5-3] 75 live tags/instance (375,000 total) 375,000 tag paths subscribed through the real DCL
4 Tag update latency P99 < 250 ms, P50 < 50 ms. (A human-facing live value that lands within a quarter second is indistinguishable from instant; the site stream is explicitly best-effort/lossy under pressure, so this bounds the delivered path.)
5 [xc-1] message patterns function under load Achieved offered load ≥ 95% of nominal, and no unexpected event loss at healthy subscribers
6 [xc-2] health reports arrive within expected intervals Collect+ingest P99 < 1,000 ms (must be a small fraction of the 30 s report interval, else reports would queue) and all 10 sites tracked
7 [xc-6] debug view streams without impacting site performance Snapshot round-trip P99 < 2,000 ms, zero ask timeouts, and no measurable tag-latency penalty
8 [xc-7] S&F handles concurrent buffering from multiple instances 20,000 messages buffered concurrently from 25 origin instances with no error; buffer drains to depth 0
9 Row 50 — S&F drain rate Drain throughput > 500 msg/s (a 20,000-message backlog clears in well under a minute once due)
10 Row 50 — slow-subscriber backpressure Healthy subscribers' delivery ratio = 100% while a peer is stalled; the stalled subscriber's loss is confined to its own bounded channel
11 Deployment of 500 instances to a site Slowest single site < 120 s
12 Memory within acceptable bounds Steady-state working-set slope < 20 MB/min, and no monotonic managed-heap climb across the window
13 CPU within acceptable bounds Steady-state mean < 50% of the box at nominal load

7. Deviations from the WP-4 protocol

Each is a deliberate, recorded trade — not an omission.

# Deviation Reason
D1 Sustained window shortened from 1 hour to 20 minutes at full scale Practicality on a single shared workstation. Memory growth is reported as a slope precisely so a shorter window still answers the leak question: a leak shows as a positive slope in 20 min just as in 60. The 1-hour run is a single flag — --sustain-minutes 60 — and the exact command is recorded in the results doc.
D2 In-process, non-clustered sites rather than 10 real two-node Akka clusters A single box cannot host 20 clustered nodes at this scale. Cluster membership/failover is already measured on a real two-node rig (FailoverTimingTests, docker/failover-drill.sh); WP-4's question is about the hierarchy under the singleton.
D3 Simulated data source instead of real OPC UA No simulated adapter exists in the DCL, and 375k real monitored items would measure the OPC UA SDK. Injected at the documented RegisterAdapter seam, at the exact hand-off point the real adapter uses.
D4 gRPC socket replaced by a reader task on the stream path Required by the row-50 measurement itself: a stalled subscriber must be held stalled. All of SiteStreamGrpcServer's per-subscription machinery above the socket is real.
D5 Instance Actors are /user children, not children of a DeploymentManagerActor Constructing them directly is what lets the ramp be timed in isolation and keeps the harness independent of the deploy round-trip. Visible only as InstanceActorInitialized dead letters, which the harness suppresses. No measured path differs.
D6 Akka logging at WARNING At 37,500 updates/s, INFO output would itself become a measured load.
D7 [xc-3] site event logging volume, [xc-4] audit-log degradation, [xc-5] template flattening, [xc-8] UI responsiveness not covered Out of scope for this harness: the first three need central MS SQL and the Template Engine (a central-cluster fixture, not a site one), and [xc-8] needs a browser. Recorded as residual scope in the results doc rather than silently dropped.
D8 The docker/ 8-node rig stays running during the measurement It is the user's live development cluster. Its baseline cost is measured and recorded alongside the result so the CPU figure is interpretable.

8. Honest-reporting rule

If a criterion fails, that failure is the deliverable. Findings are recorded in the results doc with the measured evidence; nothing is tuned to make a number go green, and no risky fix is attempted under cover of this work package. Anything found gets filed as a finding for separate triage.