20820bbf76
v2-ci / unit-tests (push) Has been cancelled
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
319 lines
27 KiB
Markdown
319 lines
27 KiB
Markdown
# 2026-10-21 review remediation — Wave-4 gate record
|
||
|
||
Tracking issue: lmxopcua#761. This file records each gate run with its date, the commit tested, the procedure,
|
||
the measured numbers and the verdict. Gates not listed here have not been run.
|
||
|
||
## docker-dev gates A, B, C, E — 2026-11-09
|
||
|
||
| | |
|
||
|---|---|
|
||
| Commit tested | `1b9c36f3` (`origin/master`, "docs(plans): OPC UA SDK 2.0 upgrade spike catalogue (lmxopcua#782)") |
|
||
| Rig | local `docker-dev/` (6 host nodes: MAIN `central-1/2`, SITE-A `site-a-1/2`, SITE-B `site-b-1/2`), image `otopcua-host:dev` `sha256:24f9d7287ed2` built from that commit, migrator re-run (no pending migrations) |
|
||
| Clock | Times below are rig UTC (2026-11-10 01:37–02:07 UTC = 2026-11-09 evening US Eastern) |
|
||
| Config | The existing ConfigDb: MAIN carries `MAIN-modbus` (pymodbus `10.100.0.35:5020`), `MAIN-opcua` (opc-plc `10.100.0.35:50000`), `MAIN-calc`, 48 `int3b-mb-*` Modbus drivers and one `int3b-blackhole` driver, 52 children in total. Equipment `EQ-32296cc68964` (`a/l/e1`) has the HR5 reference and the change-triggered virtual tag `okvt` (= HR100 + 1). |
|
||
| Tools | `POST /api/deployments` + `GET /api/deployments/{id}` (`X-Api-Key: docker-dev-deploy-key`), `Client.CLI` built from the same commit, `/proc/1/status` (Threads) and `/proc/1/fd` inside the containers. `dotnet-counters` is not in the runtime image, so it was not used. ConfigDb edits were made with `sqlcmd -I` (QUOTED_IDENTIFIER on). |
|
||
|
||
**Summary: A PASS, B PASS, C PASS, E PASS.** None of the four gates failed. Observations are listed at the end. None of
|
||
them blocks a gate.
|
||
|
||
### Gate A (#731) — a driver config change disposes the old instance; threads and handles stay flat — PASS
|
||
|
||
**Procedure.** Each iteration edits both `MAIN-modbus.DriverConfig` (`TimeoutMs` 2000+i) and `MAIN-opcua.DriverConfig`
|
||
(`SessionName` `gateA-i`), deploys, waits for the deploy to seal plus 8 s, then samples PID 1 in `central-1` and
|
||
`central-2` (both run the MAIN drivers). That is 20 iterations, so 20 respawns of each driver on each node: 80 driver
|
||
disposals in total. Two warm-up respawns ran before the baseline.
|
||
|
||
**Disposal evidence** (log lines in the 01:41:47–01:45:10 window):
|
||
|
||
| node | `MAIN-modbus: disposed Modbus driver on stop` | `MAIN-opcua: disposed OpcUaClient driver on stop` | `spawned … MAIN-modbus` / `MAIN-opcua` | shutdown/dispose timeout or throw |
|
||
|---|---|---|---|---|
|
||
| central-1 | 20 | 20 | 20 / 20 | 0 |
|
||
| central-2 | 20 | 20 | 20 / 20 | 0 |
|
||
|
||
**Counts** (threads / open fds / RSS KiB):
|
||
|
||
| sample | central-1 | central-2 |
|
||
|---|---|---|
|
||
| baseline (01:41:47) | 46 / 714 / 490 748 | 46 / 717 / 516 600 |
|
||
| after respawn 5 | 50 / 725 / 506 576 | 51 / 725 / 529 472 |
|
||
| after respawn 10 | 50 / 725 / 507 084 | 48 / 726 / 532 972 |
|
||
| after respawn 15 | 48 / 726 / 497 648 | 48 / 724 / 525 440 |
|
||
| after respawn 20 (01:44:59) | 52 / 726 / 499 048 | 48 / 723 / 526 184 |
|
||
| settled, 75 s later (01:46:14) | 48 / 712 | 46 / 715 |
|
||
|
||
Across the 20 respawns, threads moved between 47 and 55 with no upward trend; that range is normal thread-pool
|
||
movement. The fd count rose by about 11 on the first respawn, which is the new driver's sockets landing before the old
|
||
ones close. It then stayed flat at 723–726, and after settling it was lower than the baseline (712/715). RSS stayed
|
||
flat (±25 MB).
|
||
|
||
After a respawn, the static Modbus values (HR5 and HR200, which do not change) read Good on both 4840 and 4841. The
|
||
dead child's `BadNoCommunication` fan, which lands after the new child's first push, was overwritten by the re-push.
|
||
|
||
### Gate B (#732) — a subscription survives a surgical add; a re-created item after a rebuild receives data — PASS
|
||
|
||
**Procedure.** Session 1 opens two `Client.CLI subscribe` processes (500 ms) on `opc.tcp://localhost:4840`, which
|
||
are held for 170 s:
|
||
|
||
- the raw node `ns=2;s=pymodbus/plc/HR100` (ticks about 1 Hz)
|
||
- the UNS virtual tag `ns=3;s=EQ-32296cc68964/okvt`
|
||
|
||
1. **Surgical add (01:47:42).** Insert the raw tag `HR101` on `MAIN-modbus-dev`, then deploy. The applier logged
|
||
`kind=PureAdd, added=1, removed=0, changed=0, rebuild=False` on both central nodes.
|
||
2. **Read the new node.** `ns=2;s=pymodbus/plc/HR101` exists and is served. Its status is `BadOutOfRange` because the
|
||
pymodbus fixture refuses address 101 with exception 0x02. The device answered, so this is the fixture's register
|
||
map and not a server fault.
|
||
3. **Rebuild (01:48:19).** Rename equipment `e1` to `e1r`, which is a `ChangedEquipment` delta, then deploy. The
|
||
applier logged `kind=Rebuild, … rebuild=True`.
|
||
4. **Re-create (01:48:35).** With session 1 still open and still holding its stale items, start session 2 with new
|
||
subscriptions on the same two NodeIds for 20 s.
|
||
|
||
**Results.**
|
||
|
||
| subscription | samples | non-Good samples |
|
||
|---|---|---|
|
||
| session 1, HR100 | 59 | `01:47:43.1426 BadNoCommunication` (one sample; Good `31505` 0.9 ms later, then 1 Hz Good through 01:48:19); `01:48:19.97 BadNodeIdUnknown`, then nothing (item dead, as B2a specifies) |
|
||
| session 1, okvt | 59 | same pattern: one `BadNoCommunication` sample at 01:47:43.1426, Good again at .1435; `BadNodeIdUnknown` at 01:48:19.97 |
|
||
| session 2, HR100 (after the rebuild) | 20 | 0. Values ran from 36580 at 01:48:34.99 to 38405 at 01:48:54.00 |
|
||
| session 2, okvt (after the rebuild) | 20 | 0. Values ran from 36581 to 38406 |
|
||
|
||
- **Surgical add:** the subscription survived. The items were never invalidated and Good data continued at 1 Hz. The
|
||
single `BadNoCommunication` sample is not address-space churn. Adding a tag changes the driver's spawn spec, so
|
||
`MAIN-modbus` respawned (`stopped driver child` / `disposed` / `spawned` at 01:47:43). The dead child's
|
||
connectivity-lost fan then reached the item for under 1 ms before the new child's push (see observation 3).
|
||
- **Rebuild:** the old items received `BadNodeIdUnknown` and no further data. New items on the same NodeIds, created
|
||
while the stale items were still open in another session, received live data immediately. That is the B2a positive
|
||
control: before the fix, a stale `MonitoredNode2` captured the new item.
|
||
|
||
The CLI cannot re-create an item inside the same session. The same-session case is covered by the
|
||
`OpcUaServer.IntegrationTests` test, not by this gate.
|
||
|
||
### Gate C (#733) — kill a central mid-dispatch, the next deploy succeeds; port 4840 taken ⇒ `/health/ready` 503 — PASS
|
||
|
||
To hold a deployment in flight long enough to kill a node, `site-b-1` was `docker pause`d before each deploy, so its
|
||
ack could not land. The dispatch is otherwise sub-second on this rig. Both centrals were killed in turn:
|
||
`central-1`, which the issue names, and `central-2`, which was hosting the `config-publish` / `admin-operations`
|
||
singletons at the time. Killing the singleton host is what exercises the C1 PreStart recovery.
|
||
|
||
**C-a: `docker kill central-1` mid-dispatch.** Deployment `197e31b4` was `AwaitingApplyAcks` with 5 of 6 rows
|
||
Applied (site-b-1 Applying) when central-1 was killed at 01:51:42.
|
||
|
||
- A second POST while it was in flight returned **409 AnotherDeploymentInFlight**, which is correct.
|
||
- After `site-b-1` was unpaused, the coordinator closed the deployment at the 2 min deadline (01:53:38) as
|
||
`TimedOut`. It logged `No ack from: site-b-1:4053`, because the gRPC dispatch to the paused node was lost.
|
||
- central-1 was restarted, and the **next deploy, `b27ede2a`, sealed with 6 of 6 Applied.**
|
||
|
||
**C-b: `docker kill central-2` (singleton host) mid-dispatch.** Deployment `970e7770` was `AwaitingApplyAcks`
|
||
(5 of 6) when central-2 was killed at 01:54:03.
|
||
|
||
- 01:54:30: central-1 auto-downed central-2.
|
||
- 01:54:46: the singletons started on central-1 and logged `Coordinator recovered in-flight deployment 970e7770… (5/6 acks landed)`.
|
||
- The AdminUI was unavailable through Traefik from about 01:54:05 to 01:54:35 during the hand-over.
|
||
- The recovered deployment closed `TimedOut` at its original deadline (01:56:00).
|
||
- central-2 was restarted, and the **next deploy, `cb0b8134`, sealed with 6 of 6 Applied.** Neither run left a
|
||
deployment wedged in flight.
|
||
|
||
**C-port: port 4840 taken.**
|
||
|
||
- **Fused admin+driver node.** central-2 was stopped. A replacement container was started from the same compose
|
||
service (`compose run --use-aliases`). A `perl` listener in it bound `0.0.0.0:4840` before
|
||
`dotnet OtOpcUa.Host.dll` started. Results:
|
||
- `GET /health/ready` returned **HTTP 503**, overall status `Unhealthy`, with
|
||
`opcua-endpoint: Unhealthy — "OPC UA SDK failed to start: Failed to establish tcp listener sockets for Ipv4 and
|
||
IPv6." (status Failed)`. `configdb` and `akka` were both Healthy.
|
||
- The node logged `OpcUaEndpointStepDown: … not the driver Primary — nothing to step down from`.
|
||
- **Driver-only node.** The same setup on `site-a-2` (driver-only): the host logged
|
||
`FTL OtOpcUa.Host terminated: Failed to establish tcp listener sockets…` and the **process exited with code 1**
|
||
(C3 "a driver-only node rethrows").
|
||
- Both replacement containers were removed and the original containers restarted. The ServiceLevel half of C3 is
|
||
#762 and was not part of this gate.
|
||
|
||
### Gate E (#734) — a `while(true)` virtual tag leaves the node responsive, the tag shows quarantined, threads stay flat over 2 min — PASS
|
||
|
||
**Procedure.**
|
||
|
||
- **Script:** `gateE-spin` with source `var v = ctx.GetTag("pymodbus/plc/HR100").Value; while (true) { }`.
|
||
- **Virtual tag:** `spinvt` on `EQ-32296cc68964`, Int32, change-triggered, so it fires about 1 Hz on HR100.
|
||
- **Healthy sibling:** `okvt`, on the same equipment and the same trigger.
|
||
- Deployed at 02:01:11. Before the deploy and every 6 s for about 3.5 min after it, the following were sampled:
|
||
- threads and CPU ticks of PID 1 on both centrals
|
||
- `/health/ready` latency
|
||
- `Client.CLI read` of `okvt` and `spinvt` on 4840
|
||
|
||
An earlier attempt used a timer-only trigger (`TimerIntervalMs=1000`, no dependency), and the script never ran; see
|
||
observation 4.
|
||
|
||
**Quarantine.**
|
||
|
||
- The first run started at about 02:01:16. The following triggers were refused, as designed: refusals do not count as
|
||
strikes.
|
||
- At 02:01:36, after 20.2 s, which is past `StuckOrphanQuarantineFactor` 10 × the 2 s budget, both centrals logged
|
||
this Error to `scripts-*.log`: `Script gateE-vt-spin quarantined: its previous evaluation has been running for
|
||
20.2 s, over 10x its 2000 ms budget … not evaluated again until its source changes or it is redeployed`.
|
||
- `/metrics`:
|
||
- `otopcua_script_quarantined_total{reason="stuck",script="gateE-vt-spin"} 1`
|
||
- `otopcua_script_run_refused_total{script="gateE-vt-spin"} 21`
|
||
- Both stayed at 1 and 21 for the rest of the run, so evaluation stopped after the quarantine.
|
||
- OPC UA status of `ns=3;s=EQ-32296cc68964/spinvt`: `BadWaitingForInitialData` until the first run, then
|
||
`BadInternalError` (0x80020000) from 02:01:23 onward.
|
||
|
||
**Responsiveness.**
|
||
|
||
| metric | before the deploy | over the 3.5 min after |
|
||
|---|---|---|
|
||
| `/health/ready` | 200 in 3–20 ms | 200 in 3–11 ms |
|
||
| `Client.CLI read` (connect + read) | 0.34–0.46 s | 0.33–0.49 s |
|
||
|
||
`okvt`, the healthy sibling, kept evaluating Good throughout (30656 → 41368).
|
||
|
||
**Threads** (central-1 / central-2):
|
||
|
||
| sample | central-1 | central-2 |
|
||
|---|---|---|
|
||
| before | 50–53 | 49–50 |
|
||
| 02:01:17 – 02:04:42 (3.5 min, sampled every 6–9 s) | 50–54 | 50–54 |
|
||
|
||
There was no growth, and at most one thread was orphaned per node.
|
||
|
||
**CPU.** The orphaned run cannot be pre-empted in-process. Both nodes went from about 0.1 core to about 1.04 cores
|
||
(about 104 ticks/s), and that continued after the virtual tag was deleted and redeployed (1022 ticks per 10 s at
|
||
02:05). Only a process restart cleared it, so both centrals were rolling-restarted after the gate. This is the
|
||
documented limitation: the out-of-process runner is #750. The gate criteria (responsive, quarantined, threads flat)
|
||
hold.
|
||
|
||
### Observations (none blocks a gate)
|
||
|
||
1. **The status API does not say why a deployment timed out.** In the normal timeout branch (some acks landed),
|
||
`GET /api/deployments/{id}` returns `status: TimedOut` with `failureReason: null`. Only the central log names the
|
||
silent node (`No ack from: site-b-1:4053`). The no-expected-rows branch does set a reason. An automation client
|
||
polling the API cannot see which node to fix.
|
||
**Resolved (lmxopcua#784):** the timeout branch now persists `failureReason` = `Timed out waiting for acks
|
||
(n/m landed). No ack from: <nodes>. …` (at most 20 nodes named, the rest counted).
|
||
2. **Rig: mesh PSK warnings on the centrals.** central logs `Mesh PSK secret ot-mesh-psk-{main,site-a,site-b} … is
|
||
absent (or blank) in the store; the key is REVOKED — commands to that cluster and acks from it are refused`. Yet
|
||
every gRPC dispatch and ack in these runs succeeded. Either the rig's PSK secrets are not seeded and enforcement is
|
||
off, or the warning is stale. Not investigated.
|
||
**Resolved (lmxopcua#784): the warning was false, not a bypass.** The rig's centrals supply all three keys
|
||
through `MeshTransport__ClusterPsks__{MAIN,SITE-A,SITE-B}` (each equal to that cluster's nodes'
|
||
`MeshTransport__GrpcPsk`) and seed nothing in the store. `MeshPskProvider.TryGetPsk` consults that override
|
||
first on every lookup, so enforcement was on and every call authenticated against the override key; only
|
||
the background `RefreshAsync` read the store for those clusters anyway and reported them revoked. The
|
||
refresh now skips override-covered clusters, so `REVOKED` is logged only for a cluster with no key at all.
|
||
3. **A surgical raw-tag add respawns that driver.** The driver's spawn spec carries its tag set, so adding one tag
|
||
reconnects the whole driver (the accepted cost noted in `DriverSpawnPlanner`). On a live subscriber this shows as
|
||
one `BadNoCommunication` sample (< 1 ms) on every value of that driver, including UNS references and virtual tags
|
||
fed by it. ScadaBridge-style clients that alarm on a single Bad sample would see it on every tag add.
|
||
4. **A timer-only virtual tag never evaluates, and nothing flags it.** `VirtualTag.TimerIntervalMs` is not consumed
|
||
by the runtime: `VirtualTagActor` evaluates on dependency changes only, and no timer scheduler exists. A virtual
|
||
tag with `TimerIntervalMs=1000` and no `ctx.GetTag` dependency deployed cleanly and sat at
|
||
`BadWaitingForInitialData` indefinitely. The `CalculationNoEffectiveTrigger` DraftValidator rule (E4) covers
|
||
Calculation tags only, not virtual tags.
|
||
5. **The quarantine line in the main log has no script name.** The guard's Error is mirrored into the main log by
|
||
`ScriptLogCompanionSink` as `WRN [Script] unknown emitted Error: Script "gateE-vt-spin" quarantined: …`. The guard's
|
||
logger carries no `ScriptName`/`ScriptId` property, so the mirror labels it `unknown`, and it is rate-limited under
|
||
that shared key. `scripts-*.log` has the correct `[ERR]` line.
|
||
**Resolved (lmxopcua#784):** the guard binds the script key as `ScriptName`, so the mirror names the script
|
||
and rate-limits each one separately.
|
||
6. **The quarantine shows as `BadInternalError` on OPC UA.** A client sees no quarantine-specific status. The reason
|
||
is only in the logs and metrics.
|
||
**Decided (lmxopcua#784): kept `BadInternalError`**, the documented code for any script-attributable fault
|
||
on all three seams; `BadOutOfService` would read as a deliberately disabled point and would be a
|
||
ScadaBridge-visible contract change. Rationale: `docs/VirtualTags.md` §"Quarantine: what a client sees".
|
||
|
||
### Rig state left behind
|
||
|
||
- All six nodes run `otopcua-host:dev` `sha256:24f9d7287ed2` (commit `1b9c36f3`).
|
||
- Every gate edit was reverted in the ConfigDb: `HR101` deleted; `e1` name restored; `gateE-spin`/`spinvt` deleted;
|
||
both driver configs restored. A clean-up deploy, `cd41a890`, sealed 6 of 6.
|
||
- Both centrals were restarted afterwards to clear the gate-E orphan thread.
|
||
|
||
## Driver fixture gates + H1 (2026-11-09, commit 1b9c36f3, fixture host 10.100.0.35)
|
||
|
||
### Fixture gates, lmxopcua#761 (driver fixtures + H1)
|
||
|
||
Date: 2026-11-09. Commit tested: `1b9c36f3` (origin/master), worktree `scratchpad/wt/otopc-fix` (branch w18/fixtures, nothing committed). Release builds, 0 errors. Nothing posted to Gitea.
|
||
|
||
Fixture host 10.100.0.35: `lmxopcua-fix` does not exist on macOS (CLAUDE.md says to use ssh). Every needed fixture was already running, so nothing was started or stopped there. State before and after was identical (modbus-standard, ab-server-controllogix, python-snap7-s7_1500, opc-plc, mosquitto + sparkplug-sim + mqtt-publisher, sql-pg/oracle/blackhole, otopcua-mssql). Pre-existing: `otopcua-opc-plc` reports "unhealthy" (before and after; the opcuaclient suite still passed). `otopcua-sql-blackhole` showed "unhealthy" briefly right after the blackhole pause/unpause test, then "healthy" again.
|
||
|
||
## Driver fixture gates
|
||
|
||
| Gate | Command (from worktree) | Result |
|
||
|---|---|---|
|
||
| modbus standard | `dotnet test tests/Drivers/*Modbus.IntegrationTests -c Release` (default endpoint 10.100.0.35:5020, profile standard) | PASS: 12 passed, 0 failed, 32 skipped. Skips are the other profiles only (exception_injection 11, mitsubishi 7, dl205 ~11, s7_1500 4). They are not valid on the standard profile. Standard round-trip and AddressingGrammar ran. |
|
||
| s7 s7_1500 | `S7_SIM_ALLOW_WRITES=1 dotnet test tests/Drivers/*S7.IntegrationTests` | PASS: 23 passed, 0 failed, 3 skipped. Without the writes env var: 20 passed and 6 skipped. The 3 remaining skips: TM/CT address limit of python-snap7, and two destructive tests (`S7_RECONNECT_BOUNCE_CMD`, `S7_TIMEOUT_OUTAGE_*_CMD`) not run so the shared sim is not bounced. |
|
||
| abcip controllogix | `AB_SERVER_ALLOW_WRITES=1 AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/*AbCip.IntegrationTests` | PASS with one flake: 33 passed, 1 failed, 22 skipped (about 2 min). Failure: `PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag` raised `DifferentialAbortedException` ("Connection reset by peer" on tag UdtSeg.Ready). Rerun of that class: 2/2 passed. Verdict: flake (ab_server connection reset, aborts on first connection-level failure by design). Skips: 6 need `AB_SERVER_PROFILE=emulate` (Logix Emulate), 1 needs `AB_HW_WRITE_GATEWAY` (hardware), 15 are the remaining differential and write tests. Without writes env: 28 passed, 0 failed, 28 skipped. |
|
||
| opcuaclient opc-plc | `dotnet test tests/Drivers/*OpcUaClient.IntegrationTests` (opc.tcp://10.100.0.35:50000) | PASS: 5 passed, 0 skipped. |
|
||
| mqtt + sparkplug | `MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883 MQTT_FIXTURE_PLAIN_ENDPOINT=10.100.0.35:1883 MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<from publisher container env> MQTT_FIXTURE_CA_CERT=<scp'd ca.crt> dotnet test tests/Drivers/*Mqtt.IntegrationTests` | PASS: 30 passed, 0 skipped (includes Sparkplug, simulator running). |
|
||
| sql | `SQL_TEST_ENDPOINT=10.100.0.35,14330 SQL_TEST_PASSWORD=.. SQL_PG_ENDPOINT=10.100.0.35:15432 SQL_PG_PASSWORD=.. SQL_ORACLE_ENDPOINT=10.100.0.35:11521 SQL_ORACLE_PASSWORD=.. SQL_BLACKHOLE_ENDPOINT=10.100.0.35,14333 SQL_BLACKHOLE_PASSWORD=.. SQL_BLACKHOLE_DOCKER_SSH=dohertj2@10.100.0.35 dotnet test tests/Drivers/*Sql.IntegrationTests` | PASS: 37 passed, 0 failed, 17 skipped. SQL Server, Postgres, Oracle and the blackhole timeout test ran. Skips: 14 MySQL (no MySQL fixture on the host) and 3 ODBC (no `SQL_ODBC_CONNECTION_STRING`). |
|
||
|
||
Side effect: the SQL suite creates (and never drops, by design) database `SqlPollFixture` on `otopcua-mssql`. It was already there before (listed in sys.databases).
|
||
|
||
## H1 (#736) historian year-window gate
|
||
|
||
There was no pre-existing year-window fixture test. The existing fixture suites test the cap at 3 rows, and the 10k fuzz is offline in `OpcUaServer.Tests`. I added an uncommitted probe, `tests/Drivers/Historian.Conformance/YearWindowGateProbe.cs`, with a subclass in each backend's integration project (edits are only in the throwaway worktree). It seeds one tag with 52,560 samples spanning 365 days, with `maxRawValuesPerRead` = 10,000.
|
||
|
||
- It checks that an N=0 read of the whole year is refused with `HistoryReadLimitExceededException` (Limit=10000), not returned unbounded and not truncated.
|
||
- It walks the window in 10k-capped pages, resuming after the last timestamp, and checks every page is at most 10000.
|
||
- It checks the concatenated pages equal the seeded set (no loss, no duplicates, ordered), and that peak process working set stays under 500 MB.
|
||
|
||
| Backend | Where | Result |
|
||
|---|---|---|
|
||
| QuestDb | local throwaway container `h1-gate-questdb` (`questdb/questdb:10.0.1`, ports 28812/29000; removed afterwards) | PASS: N=0 refused, limit 10000; 6 pages, max page 10000, 52,560 rows walked, no loss or duplicates; peak working set 122 MB (managed 27 MB). Full QuestDb integration project: 234 passed, 0 failed, 1 skipped (opt-in `HISTORIAN_QUESTDB_STRESS_SECONDS` stress test). |
|
||
| SqlServer | throwaway DB `OtOpcUaHistorian_T_*` on `otopcua-mssql` (the fixture creates and drops it; no orphan left) | PASS: same numbers. 6 pages, max 10000, 52,560 rows walked; peak working set 114 MB (managed 18 MB). Full SqlServer integration project: 196 passed, 0 failed, 0 skipped. |
|
||
| Server half: continuation point at N=0 | offline: `dotnet test tests/Server/*OpcUaServer.Tests --filter RawPageLimit/HistoryRead/MaxReturnDataValues/HistoryServerCapabilities` | PASS: 431 passed, 0 failed (includes the paged-vs-unpaged raw cap fuzz and the MaxReturnDataValues advertisement). |
|
||
|
||
Scope caveat: the probe drives the backend `IHistorianDataSource` directly, not a live OPC UA HistoryRead through the server node manager. The backend side is proven live: bounded at 10k, N=0 refused, pages complete. The continuation point itself is the server's `RawPageLimit` planning, covered by the offline tests above, not by a live OPC UA session. A true end-to-end gate (OPC UA client reading a year at N=0 against a server on the SqlServer/QuestDb backend) needs the docker-dev rig, which I was told not to use.
|
||
|
||
Mishap: two aborted SQL Server probe attempts (row-by-row seeding was too slow, so I killed them) each left a throwaway `OtOpcUaHistorian_T_*` database on `otopcua-mssql`. Both were dropped manually. The final check shows no `OtOpcUaHistorian*` databases remain.
|
||
|
||
## NOT RUN
|
||
|
||
- Hardware userGates (real PLCs): AbCip / AbLegacy / TwinCAT array writes, FOCAS `MACRO:` > 32767, AbCip probe-typo recovery.
|
||
- docker-dev gates A, B, C, D, E, and the Galaxy gate F: not in scope. The otopcua-dev rig is off-limits, and F needs mxaccessgw.
|
||
- Modbus exception_injection / dl205 / mitsubishi / s7_1500 profiles (only `standard` requested); S7 reconnect-bounce and connect-timeout-outage destructive tests; AbCip Logix Emulate profile; Sql MySQL and ODBC (no fixture).
|
||
|
||
## Cleanup
|
||
|
||
Container `h1-gate-questdb` removed. The fixture host is unchanged. The worktree `scratchpad/wt/otopc-fix` (branch w18/fixtures) is left in place with uncommitted probe edits. Remove it with `git worktree remove --force` if not wanted.
|
||
|
||
## Gate D (#745) — AdminUI live pass, docker-dev (2026-11-09) — FAIL (1 of 5 checks)
|
||
|
||
Overall: four checks PASS; the delete-confirmation check FAILS on four pages (filed as a follow-up issue, see #761). Full record below, as written by the Chrome pass.
|
||
|
||
Date: 2026-11-09 (container clock 2026-11-10 03:xx UTC). Rig: local docker-dev, otopcua-host:dev image built 2026-11-09 20:35 EST from master 1b9c36f3 (per coordinator; rig built from a scratchpad worktree no longer on disk, so commit not re-verifiable from the container). AdminUI http://localhost:9200 (login disabled, session "multi-role-test"). Wonder boxes untouched. No deploy performed.
|
||
|
||
### 1. 100 KB script survives save + reload - PASS
|
||
Procedure: /scripts/new, set Monaco model to 102,492 chars (1,200+ comment lines + `return 1;`), name gateD-100kb-test, Create. Reloaded /scripts/SC-d82b3a36fd85; editor text === regenerated original (identical, length 102492 both). Then deleted it (own modal).
|
||
|
||
### 2. Two-tab /hosts CPU flat with ~50 drivers - PASS
|
||
Rig has 52 drivers on cluster MAIN (48 int3b-mb-*, blackhole, Calc, opcplc, pymodbus); site-a/b have none. `docker stats --no-stream` on central-1/central-2 (the AdminUI hosts), 8 samples ~12s apart (~100s) per condition:
|
||
- no /hosts tab (on /): central-1 11.0%, central-2 10.9% avg
|
||
- 1 /hosts tab (first run): c1 9.7%, c2 11.4%
|
||
- 2 /hosts tabs: c1 10.9%, c2 12.1% avg
|
||
Within noise (range 8-17% in every condition). Flat. Raw: scratchpad/cpu-*.txt.
|
||
|
||
### 3. 5k-tag device renders < 2 s - PASS
|
||
No such device existed. Created disabled-in-effect (never deployed) Modbus driver gateD-bulk/device gdev under docker-dev, imported 5000-row CSV via the Raw tree modal (parse+review 206 ms, commit 717 ms). Expanding gdev: first tag rows in DOM 14-20 ms after click, painted (double rAF) 44 ms. Tree paged: 500 rendered ("Show 500 more (4500 not shown)") and DOM ~611 nodes. Far under 2 s.
|
||
Cleanup: tags cannot be bulk-deleted in UI (device delete: "Delete blocked - contains tags"), so removed gateD-bulk rows (5000 Tag, TagGroup, Device, DriverInstance) by scoped SQL delete in a transaction on the rig OtOpcUa DB (needed `sqlcmd -I`). Verified 0 gateD DriverInstances, Tag total back to 55.
|
||
Observation (not a defect): no bulk "delete all tags in device" in Raw tree.
|
||
|
||
### 4. Every delete asks for confirmation - FAIL (4 defects)
|
||
window.confirm overridden; no native confirm ever fired. Own modal confirmed + Cancel verified (item survives) for: script delete (ScriptEdit), API key delete (/apikeys), all 10 UNS-tree deletes on /uns (enterprise, clusters, area, line, equipment), Raw-tree driver delete and device delete (modals). Equipment-page virtual-tag/alarm/reference deletes: source uses ConfirmDeleteVirtualTag/ConfirmDeleteAlarm/ConfirmRemoveReference; no live rows on the sampled equipment (NOT exercised live). Certificates delete uses RequestAction -> Confirm panel (source).
|
||
DEFECTS - delete executes immediately on click, no confirmation:
|
||
a) /role-grants Delete (RoleGrants.razor:68 -> DeleteAsync(r.Id)). LIVE-VERIFIED: added test grant cn=gated-test..., clicked Delete, row gone instantly, no modal. (Test grant thus already removed.)
|
||
b) ClusterEdit.razor:102 `@onclick="DeleteAsync"` (cluster delete) - source only
|
||
c) NodeEdit.razor:119 `@onclick="DeleteAsync"` (cluster node delete) - source only
|
||
d) AclEdit.razor:151 `@onclick="DeleteAsync"` (node ACL delete) - source only
|
||
(CollectionEditor.razor:31 per-row Delete in driver forms is a working-copy edit committed on Save - not counted.)
|
||
Repro for (a): http://localhost:9200/role-grants, add any group, press its Delete.
|
||
|
||
### 5. A6 diagnostics panel shows real counts - PASS
|
||
/fleet/diagnostics?node=central-1:4053 : DRIVERS 52, FAULTED DEVICES 0, CONFIG SOURCE live, REVISION e221b1792cbd..., per-driver rows (48 HEALTHY int3b-mb-* with 1 connected device each, Calc/opcplc/pymodbus HEALTHY, int3b-blackhole INITIALIZING 0 connected). /hosts shows the same 52 driver instances and blackhole INITIALIZING "1 / 1 DOWN". Disabled int3b-bulk correctly absent. Minor: blackhole's down device is not counted in "FAULTED DEVICES 0" (state INITIALIZING, not faulted) - arguably fine, flag for A6 owner.
|
||
|
||
### Revert state
|
||
Test script, test role grant, gateD-bulk driver/device/5000 tags all removed; no deploys; browser tab closed. Rig config as found.
|
||
|
||
### Still not run after gate D
|
||
|
||
- Gate F (Galaxy) — needs a reachable mxaccessgw.
|
||
- Hardware userGates (real PLCs), as listed under NOT RUN above.
|