The 2026-08-01 run closed every leg except read-back, which no reachable historian could
serve that day (the LAN instance accepts writes but serves no reads under a licence tag-count
violation; the licensed one needed the VPN). With the VPN up the run was repeated against
wonder-sql-vd03 and the remaining legs pass:
- EnsureTags provisioning: ensured=2, failed=0 — the path the LAN box could not exercise at
all (it is RemoteGrpc-only and that CAP was not listening).
- Read-back: two Modbus registers carrying DISTINCT NON-ZERO constants were historized, and
each read back as its own value (HR20 -> 8224, HR3 -> 120, quality 192). A first attempt
read back 0, which proves nothing — the same trap #536 recorded, so the proof was rebuilt
on values a crossed or fabricated answer could not fake.
Found during the run and FILED rather than fixed:
- #538 — one permanently-failing tag re-writes every healthy tag on every drain pass, forever.
DrainOnceAsync writes EVERY per-tag group and then acks all-or-nothing, so a tag whose
historian tag does not exist stops the FIFO head advancing while the healthy tags' values
are re-submitted every drain interval; the outbox never truncates (Capacity defaults to 0 =
unbounded, observed at 8.3 MB). The all-or-nothing ack is deliberate — RemoveAsync truncates
THROUGH an id, so partial acking could drop older unwritten entries — so the fix changes the
outbox contract and does not belong in this commit.
Also re-confirmed live: EnsureTags provisioning fires only for newly-ADDED historized tags, so
flipping an existing tag's historianTagname provisions nothing (Issue 5 of
2026-06-27-historian-gateway-integration-issues.md).
No production code changes — the recorder fix shipped in ad744734.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
15 KiB
Continuous-historization live gate (Gitea #491)
Date: 2026-08-01, completed 2026-08-04 · Rig: docker-dev central pair + a real
ZB.MOM.WW.HistorianGateway against a real AVEVA Historian · Outcome: BOTH LEGS CLOSED. The gate
found and fixed a production-fatal defect, and surfaced a second (unfixed, filed) one.
2026-08-04 update — the gate is complete. The 2026-08-01 run closed everything except the read-back hop, which was blocked by the only historian reachable that day (a licence violation) and by the VPN being down. With the VPN up, the run was repeated against the licensed
wonder-sql-vd03and read-back passed, as didEnsureTagsprovisioning. See §"Read-back leg (2026-08-04)". A second defect — unbounded duplicate re-writes behind one failing tag — was found during that run and filed rather than fixed.
What #491 asked for
- End-to-end value capture — deploy → dependency-mux value delta → outbox append →
WriteLiveValues→ value readable back from the historian. - Restart convergence — the recorder re-registers the deployed historized-ref set after a process restart (it spawns with an empty initial set by design; the set arrives at deploy time).
The defect the gate found
ContinuousHistorizationRecorder threw on the very first value it ever appended, and historization
then stopped dead.
OnValueChangedAsync awaited IHistorizationOutbox.AppendAsync(...) with ConfigureAwait(false) and
then touched Self. ConfigureAwait(false) discards Akka's ActorTaskScheduler synchronization
context, so Self — which reads ActorBase.Context — threw:
[ERR] There is no active ActorContext, this is most likely due to use of async operations from within this actor.
System.NotSupportedException: There is no active ActorContext ...
at ZB.MOM.WW.OtOpcUa.Runtime.Historian.ContinuousHistorizationRecorder.OnValueChangedAsync(...)
ContinuousHistorizationRecorder.cs:line 294
The throw escaped the handler into supervision, which restarted the actor — and because
production seeds an empty ref set (the deployed set arrives later via UpdateHistorizedRefs), the
restart silently discarded every historized ref. Net effect: one value captured, then nothing, with
no further error to show for it.
Fix: capture IActorRef self = Self before the first await and use that — the same idiom the
GetStatus handler already applied to Sender, whose author clearly knew the hazard for Sender and
missed it for Self.
Why 13 unit tests missed it
InMemoryOutbox.AppendAsync returned ValueTask.CompletedTask. A completed ValueTask never yields,
so the ConfigureAwait(false) continuation ran inline on the actor thread with the context still
installed. The production FasterLogHistorizationOutbox fsyncs per entry under PerEntry commit
mode, so it genuinely yields — and only then is the context gone.
⚠️ The obvious regression test was itself vacuous, and only falsification caught that. The first
version of the fake used await Task.Yield(). That does not reproduce the bug: Yield() posts back
to the captured context (Akka's scheduler), so AppendAsync completes on the actor thread, and the
caller's ConfigureAwait(false) then resumes inline right there — context intact. Reverting the
production fix left the new test green. The fake has to complete on a pool thread with no
synchronization context (await Task.Delay(1).ConfigureAwait(false)), which is what a real fsync does.
With that, reverting the fix turns exactly one test red.
Pinned by ContinuousHistorizationRecorderTests.A_yielding_append_neither_faults_the_actor_nor_loses_the_value.
Rig changes
docker-dev/docker-compose.yml carried ServerHistorian__* but no ContinuousHistorization__* keys
at all, so the recorder could never spawn on the rig and this gate was unrunnable. Both centrals now
carry them, default-OFF (the recorder needs ContinuousHistorization:Enabled and
ServerHistorian:Enabled, so the default rig stays inert):
| Key | Value |
|---|---|
ContinuousHistorization__Enabled |
${OTOPCUA_CONTHIST_ENABLED:-false} |
ContinuousHistorization__OutboxPath |
/app/data/historization-outbox (per-node named volume — survives recreate) |
ContinuousHistorization__CommitMode |
${OTOPCUA_CONTHIST_COMMITMODE:-PerEntry} |
ContinuousHistorization__DrainIntervalSeconds |
${OTOPCUA_CONTHIST_DRAIN_SECONDS:-2} |
ServerHistorian__UseTls was also parameterized (${OTOPCUA_HISTORIAN_USETLS:-true}) — it was
hard-coded true on both centrals, which cannot talk to a plaintext h2c gateway.
Re-running the gate
Gateway. The corporate historian (wonder-sql-vd03) needs the VPN. A second AVEVA Historian lives
on the local LAN at 10.100.0.48 (DESKTOP-6JL3KKO, passwordless SSH, PowerShell default shell) and
needs no VPN. Run the gateway on that box so it authenticates to the historian and to SQL with the
local Windows identity — no credentials leave the machine, and no Kerberos/GSSAPI container problem:
# Publish on the Mac (the VM has no credentialed NuGet feed) and copy across:
cd ~/Desktop/HistorianGateway
dotnet publish src/ZB.MOM.WW.HistorianGateway.Server -c Release -o /tmp/histgw-pub -p:UseAppHost=false
tar czf /tmp/histgw-pub.tgz -C /tmp histgw-pub
scp /tmp/histgw-pub.tgz dohertj2@10.100.0.48:C:/Temp/histgw-pub.tgz
ssh dohertj2@10.100.0.48 'cd C:\Temp; tar xzf histgw-pub.tgz; Move-Item histgw-pub histgw'
# …then a run.cmd setting Historian__Host=localhost, RuntimeDb__Enabled/EventReadsEnabled=true with an
# Integrated Security connection string, ApiKeys__Mode=Disabled, Dashboard__DisableLogin=true, and the
# Ldap__* block (AddZbLdapAuth registers a ValidateOnStart that requires a non-empty Ldap:Server even
# when login is disabled), launched detached via Win32_Process.Create.
⚠️ The launcher script must have CRLF line endings. A batch file written on macOS with LF endings
runs — @echo off and the final dotnet line execute — but the set lines silently do not take
effect, so the gateway boots on appsettings.json defaults and fails with a misleading error (it
crashed on the LDAP validator while every env var appeared correct in the file). This cost two
debugging cycles.
Rig:
OTOPCUA_HISTORIAN_ENABLED=true OTOPCUA_HISTORIAN_ENDPOINT=http://10.100.0.48:5221 \
OTOPCUA_HISTORIAN_USETLS=false OTOPCUA_CONTHIST_ENABLED=true \
ServerHistorian__ApiKey=histgw_local_vm-gate-placeholder \
docker compose -f docker-dev/docker-compose.yml up -d --force-recreate central-1 central-2
curl -s -X POST http://localhost:9200/api/deployments -H "X-Api-Key: docker-dev-deploy-key" \
-H "Content-Type: application/json" -d '{"createdBy":"issue-491-gate"}'
The gateway's own /metrics is the observation point — OtOpcUa logs a failed drain only as a
back-off, so the honest counter lives on the gateway side:
curl -s http://10.100.0.48:5220/metrics | grep 'requests_total.*WriteLiveValues'
Results
| Leg | Result |
|---|---|
| Recorder spawns + converges on the deployed ref set | ✅ |
| Driver value → dependency-mux fan-out → FasterLog outbox append | ✅ hlog.log.0 + log-commits grow on both nodes |
Outbox drain → WriteLiveValues accepted by the real historian |
✅ gateway outcome="ok" climbs continuously |
| Value readable back from the historian | ✅ (2026-08-04, wonder-sql-vd03 — see below) |
EnsureTags provisioning |
✅ (2026-08-04, ensured=2 failed=0) |
| Restart convergence (leg 2) | ✅ |
No ActorContext faults on the fixed build |
✅ 0 occurrences |
Restart convergence evidence. With central-2 stopped (so the counter attributes to one node),
central-1 was restarted with no new deployment:
before restart: ok=12
[23:51:29 INF] DriverHost central-1:4053: restored served state for applied deployment d8cacd20… on bootstrap
[23:51:29 INF] AddressSpaceApplier: historian provisioning completed (dispatched=1, requested=1, …)
after restart+90s: ok=19
The boot-time apply re-ran AddressSpaceApplier, which re-fed the historized refs through
ActorHistorizedTagSubscriptionSink → UpdateHistorizedRefs, and capture resumed against a recorder
that had started with an empty set. The retained outbox entries also survived the restart and drained
afterwards, exercising the durable crash boundary.
Read-back leg (2026-08-04) — CLOSED
Re-run with the VPN up, against the licensed wonder-sql-vd03. The gateway ran locally in Docker
from ~/Desktop/HistorianGateway/docker/ (republish first — a stale docker/publish silently ships
old behaviour), reporting every readiness check Healthy including historian-connection. The rig's
centrals pointed at http://host.docker.internal:5221.
EnsureTags provisioning also passed here, which the 2026-08-01 run could not exercise:
[15:25:59 INF] AddressSpaceApplier: historian provisioning completed (dispatched=2, requested=2, ensured=2, skipped=0, failed=0)
⚠️ Provisioning fires only for newly-ADDED historized tags. Flipping an existing tag's
historianTagname produced no provisioning line at all — the applier iterates the added set. This is
the already-known limitation in docs/plans/2026-06-27-historian-gateway-integration-issues.md Issue 5,
confirmed live. The gate had to add genuinely new tags to exercise the path.
Read-back, with values chosen so a wrong answer is visible. A first attempt read back 0, which
proves nothing — the same trap #536 recorded ("a sim where every tag reads 0 can't distinguish a
correct decode from a wrong one"). Two registers carrying distinct non-zero constants were
historized instead, so a crossed or fabricated value would show:
| Historian tag | Source | Read back |
|---|---|---|
OtOpcUa.Gate491.RegA |
Modbus HR 20 = 8224 |
value=8224 opcQuality=192 ✅ |
OtOpcUa.Gate491.RegB |
Modbus HR 3 = 120 |
value=120 opcQuality=192 ✅ |
Each value matched its own source register — not crossed, not defaulted. Two samples per tag per instant, one from each pair node (the #537 duplicate-write finding, visible again).
Fixture note: the otopcua-modbus fixture was down; the live source used was
oee-modbus-sim on 10.100.0.35:1502 (another project's stack, but a real Modbus endpoint). Its
registers are static, which is why distinct constants rather than a moving value carry the proof.
Second defect found — one failing tag re-writes every healthy tag, forever (filed)
DrainOnceAsync peeks a batch, writes every per-tag group, and then acks all-or-nothing:
foreach (var group in batch.GroupBy(e => e.Tag, StringComparer.Ordinal)) {
ok = await _writer.WriteLiveValuesAsync(group.Key, values, ct); // every group IS written
if (!ok) allOk = false;
}
if (!allOk) return new DrainResult(false, 0, batch.Count); // ...but nothing is acked
await _outbox.RemoveAsync(batch[^1].Id, ct);
So a single permanently-failing tag (here: one whose historian tag was never provisioned) means the
FIFO head never advances, and every healthy tag in that batch is re-written to the historian on every
drain pass — duplicate samples at the same timestamps, unboundedly, plus an outbox that never
truncates. Observed: the outbox reached 8.3 MB while the failure persisted, with the gateway's ok
and error counters both climbing together.
The all-or-nothing ack is deliberate and the comment explains why (RemoveAsync truncates through
an id, so partial acking could drop older unwritten entries) — but the consequence is undocumented and
is a real operational hazard, since Capacity defaults to 0 (unbounded). Filed, not fixed: the fix
is a per-tag or non-prefix ack, which changes the outbox contract.
Environment notes from the 2026-08-01 run (superseded for read-back, still accurate)
The read-back hop. Neither historian available today can serve it:
10.100.0.48logsLicense violation: Database tag count exceeds license tag counthourly (1780 configured analog tags against a smaller licence). It accepts writes — the SQLINSERTintoRuntime.dbo.Historysucceeds andC:\Historian\original.datis appended within seconds — but retrieval returns nothing, for any tag: even the historian's ownSysTimeSecyields a single boundary row over a 5-minutewwRetrievalMode='Full'window. This is an environment/licensing fault, entirely outside OtOpcUa.wonder-sql-vd03(the licensed corporate historian, where read/write/alarm-send were previously live-validated green) needs the VPN, which is down — its IPs10.220.157.253/.247are unreachable.
EnsureTags provisioning. The hook fires and is correctly non-blocking, logging
dispatched=1, requested=1, ensured=0, skipped=0, failed=1 — but it cannot succeed on this instance:
EnsureTags goes through HistorianClient.OpenSessionAsync, which is RemoteGrpc-only, and the
historian's gRPC Client Access Point is not listening on 10.100.0.48. Port 32565 is held by a
supervised autossh remote-forward from the developer Mac (-R 32565:wonder-sql-vd03…:32565,
deliberate infrastructure — do not kill it; it self-heals anyway), so the CAP process has no listening
sockets at all. The gate worked around this by pointing the historized tag at a pre-existing historian
tag via historianTagname, which exercises the override resolution path as a bonus.
The consequence is directly visible and is itself a correct-behaviour observation: the tag that exists
writes ok, the unprovisioned one returns Unavailable/SqlException, its outbox entries are retained
rather than dropped, and the drain backs off. No data loss on a failing write.
Secondary finding — both pair nodes historize the same value
ContinuousHistorizationRecorder has no Primary gate. With both centrals up, the gateway's ok
counter advances twice per value; stopping central-2 halves it. So a redundant pair sends duplicate
live values to the historian for every historized tag.
This contrasts with the alarm store-and-forward sink, which is Primary-gated (drainGate fed from
IRedundancyRoleView) precisely so a pair does not double-deliver. Whether duplicate analog samples are
acceptable (the historian may fold same-timestamp samples) or whether the recorder should take the same
gate is a design decision that has not been made — recorded here rather than silently "fixed",
because gating it would also mean a failover window with no capture at all.
Follow-ups
- #491 CLOSED 2026-08-04 — both legs proven.
- #537 — the recorder has no Primary gate, so both pair nodes write duplicate live values.
- #538 — one permanently-failing tag re-writes every healthy tag on every drain pass and the outbox grows unbounded (the all-or-nothing ack above).
- Still open from before this gate:
EnsureTagsfires only for newly-added historized tags (2026-06-27-historian-gateway-integration-issues.mdIssue 5) — re-confirmed live here.