test(historian): live end-to-end gate for continuous-historization value capture #491
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tracks KNOWN LIMITATION 2 in
CLAUDE.md, filed so it is not carried indefinitely as a doc banner.The
ContinuousHistorizationRecorderis fully wired — actor + FasterLog outbox + gateway value-writer + meters — and the ref-feed gap is closed (2982cc4b:AddressSpaceApplier.FeedHistorizedRefs→ActorHistorizedTagSubscriptionSink→UpdateHistorizedRefs), so the recorder converges on the deployed historized set from the first deploy.What has not been run is the live gate:
WriteLiveValues→ value readable back from the historian.Until both run, treat continuous value capture as unproven in production rather than absent. Reads and alarm-writes are separately live-validated and green.
Prerequisites are the ones already documented: gateway with
RuntimeDb:Enabled=true, an API key carryinghistorian:read+historian:write+historian:tags:write, andContinuousHistorization:Enabled=truewith an absoluteOutboxPath.Note: KNOWN LIMITATION 1 in the same CLAUDE.md section is stale — that live suite ran green (
docs/plans/2026-06-27-otopcua-historian-followups.md: read ✅, write-persist ✅, alarm-send ✅, alarm-readback skipped as a confirmed protocol limitation). Being corrected separately.Gate run 2026-08-01 — it found a production-fatal defect. Fixed in
ad744734.Ran against a real
ZB.MOM.WW.HistorianGatewaytalking to a real AVEVA Historian. Evidence:docs/plans/2026-08-01-continuous-historization-live-gate.md.The defect
ContinuousHistorizationRecorderthrew on the very first value it ever appended, and historization then stopped dead.OnValueChangedAsynctouchedSelfafterawait _outbox.AppendAsync(...).ConfigureAwait(false).ConfigureAwait(false)discards Akka'sActorTaskSchedulersynchronization context, soSelf— readingActorBase.Context— threwNotSupportedException. The throw escaped into supervision, which restarted the actor; and because the recorder is seeded with an empty ref set (the deployed set arrives later viaUpdateHistorizedRefs), the restart silently discarded every historized ref. One value captured, then silence, with no further error to show for it.Fixed by capturing
IActorRef self = Selfbefore the await — the idiom theGetStatushandler already applied toSender.13 unit tests missed it because
InMemoryOutbox.AppendAsyncreturnedValueTask.CompletedTask, which never yields, so the continuation ran inline with the context intact. The production FasterLog outbox fsyncs per entry and does yield. Worth knowing: the obvious fix to the fake —await Task.Yield()— is also vacuous, becauseYield()posts back to the captured context; reverting the production fix left that test green. The fake has to complete on a pool thread.Leg results
WriteLiveValuesaccepted by the real historianRestart convergence, with no new deployment and the partner node stopped so the counter attributes cleanly:
Why this stays open
The read-back hop only. Neither historian available today can serve it:
10.100.0.48) logsLicense violation: Database tag count exceeds license tag count. It accepts writes (the SQL insert succeeds andoriginal.datgrows within seconds) but retrieval returns nothing for any tag — even its ownSysTimeSecyields a single boundary row over a 5-minutewwRetrievalMode='Full'window. Environment/licensing, entirely outside OtOpcUa.wonder-sql-vd03, where read/write/alarm-send were previously validated green, needs the VPN — currently down.EnsureTagsprovisioning is unexercised for the same reason: it goes throughOpenSessionAsync, which isRemoteGrpc-only, and that CAP is not listening on the LAN box. The gate worked around it via ahistorianTagnameoverride onto a pre-existing tag. The consequence was visible and correct: the existing tag writesok, the unprovisioned one returnsUnavailable/SqlException, and its outbox entries are retained rather than dropped.Re-running this needs only the VPN plus the recipe in the evidence doc.
Rig change
docker-devcarriedServerHistorian__*but noContinuousHistorization__*keys at all, so the recorder could never spawn and this gate was unrunnable. Both centrals now carry them (default OFF), with the outbox on the per-node named volume.ServerHistorian__UseTlswas hard-codedtrueand is now parameterized.Secondary finding — filed separately below
ContinuousHistorizationRecorderhas no Primary gate, unlike the alarm store-and-forward sink, so both nodes of a redundant pair send duplicate live values for every historized tag (the gateway'sokcounter advances twice per value; stopping one node halves it). Recorded rather than silently changed — gating it introduces a failover window with no capture.CLOSED — read-back passed against
wonder-sql-vd03with the VPN up (2026-08-04,e77ea4aa).Both legs of this issue are now proven end-to-end, plus
EnsureTagsprovisioning, which the 2026-08-01 attempt could not exercise at all.EnsureTagsprovisioning ✅⚠️ Re-confirmed live: provisioning fires only for newly-ADDED historized tags. Flipping an existing tag's
historianTagnameproduced no provisioning line at all — the applier iterates the added set. That is Issue 5 ofdocs/plans/2026-06-27-historian-gateway-integration-issues.md, still open.Read-back ✅
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"). So the proof was rebuilt on two registers carrying distinct non-zero constants, where a crossed or fabricated value would be visible:OtOpcUa.Gate491.RegA8224value=8224 opcQuality=192OtOpcUa.Gate491.RegB120value=120 opcQuality=192Each matched its own source register. Two samples per tag per instant — one from each pair node, i.e. #537 visible again.
Full chain proven: deploy →
EnsureTags→ dependency-mux value delta → FasterLog outbox append → drain →WriteLiveValues→ value read back out of the historian; plus restart convergence (closed 2026-08-01).Second defect found during this run — filed as #538
DrainOnceAsyncwrites every per-tag group in a batch and then acks all-or-nothing, so one permanently-failing tag stops the FIFO head advancing while the healthy tags' values are re-submitted to the historian on every drain pass, indefinitely — duplicate samples, and an outbox that never truncates (Capacitydefaults to0; observed at 8.3 MB, with the gateway'sokanderrorcounters climbing together). The all-or-nothing ack is deliberate, so the fix changes the outbox contract — filed rather than folded in here.Recap of what this gate was actually worth
It caught a production-fatal defect (
ad744734): the recorder threw on the first value it ever appended —Selftouched afterawait …ConfigureAwait(false), which discards Akka's ActorContext — and the resulting supervision restart wiped the empty-seeded ref set, so historization stopped dead after one value. 13 unit tests missed it because the fake outbox completed synchronously; the first regression test was also vacuous (Task.Yield()posts back to the captured context) and only falsification exposed that.Evidence:
docs/plans/2026-08-01-continuous-historization-live-gate.md.