Continuous historization: one failing tag re-writes every healthy tag on every drain pass, outbox grows unbounded #538
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?
Split out of #491's read-back run (2026-08-04). Found live, filed rather than fixed — the fix changes the outbox contract.
ContinuousHistorizationRecorder.DrainOnceAsyncpeeks a batch, writes every per-tag group, and then acks all-or-nothing:So a single permanently-failing tag — e.g. one whose historian tag was never provisioned, which is easy to hit given
EnsureTagsonly fires for newly-added tags — means the FIFO head never advances, and every healthy tag in that batch is re-written to the historian on every drain pass, indefinitely.Two consequences:
DrainIntervalSeconds(default 2s), for as long as the bad tag keeps failing.Capacitydefaults to0(unbounded), so nothing ever truncates. Observed at 8.3 MB on a docker-dev node with one unprovisionable tag, with the gateway'sokanderrorcounters climbing together — the signature of this bug.Not a simple "stop writing the good ones"
The all-or-nothing ack is deliberate, and the code comment says why:
IHistorizationOutbox.RemoveAsynctruncates the FIFO head through the acked id, so acking one tag's entries could silently drop older, unwritten entries belonging to another tag. Given a prefix-truncating outbox, all-or-nothing is the only safe ack.Candidate directions, all of which touch the outbox contract:
RemoveAsync(or a newAckAsync(ids)) remove specific ids rather than a prefix. Most correct, biggest change; FasterLog is prefix-truncating by nature, so this likely needs a tombstone/compaction layer.MaxAttempts+ dead-letter +DeadLetterRetentionDays) so the head can advance. Probably the best fit, and consistent with an existing pattern in this repo.At minimum, bound the damage: a non-zero default
Capacity, and an operator-visible warning when a tag has failed for N consecutive passes (today a failing drain is only a silent back-off — the honest counter lives on the gateway's/metrics, not in OtOpcUa's logs).Evidence:
docs/plans/2026-08-01-continuous-historization-live-gate.md§"Second defect found".FIXED + live-gated —
e7fbe977(fix) /4bdce910(gate evidence), master4bdce910.Took the skip-and-quarantine direction this issue listed, and left the all-or-nothing ack and the
outbox contract alone —
RemoveAsyncstill truncates through an id, so the prefix ack stays the only safeone.
DrainOnceAsyncnow tracks consecutive failures per historian tag and abandons that tag's queuedentries once it exceeds the new
ContinuousHistorization:MaxWriteAttempts(default 5), letting the headadvance.
⚠️ The discriminator is "did another tag write this pass?", NOT a failure count — and this is the part
worth remembering. A count-only quarantine looks identical on the poison-tag case and silently discards the
entire backlog during an ordinary gateway restart, where every tag fails, converting a
duplicate-samples bug into a lost-history one. The counter therefore only advances on a pass where some
other tag succeeded; a pass where nothing wrote abandons nothing and retains everything, exactly as
before. The load-bearing test is the falsification control
(
A_backend_that_fails_every_tag_abandons_nothing), which goes red when the evidence check is removedwhile the poison-tag test stays green.
Abandonment is also deferred until every failing tag in the batch is over budget, so nothing is discarded
on a pass that could not have advanced the head anyway.
Capacityalso gained a non-zero default (500,000 entries) as the "bound the damage" this issue askedfor — the disk backstop for the one case the quarantine deliberately cannot resolve, a historian refusing
every write.
0restores unbounded retention. Each abandonment logs a Warning naming the tag and thediscarded sample count; the total rides
RecorderStatus.AbandonedEntries.Live gate (2026-08-04, real gateway + real AVEVA Historian)
The poison tag was the real shape, not a synthetic fault: an existing tag's
historianTagnamewaschanged, which Issue 5 guarantees is never provisioned.
historian tag OtOpcUa.Gate538.PoisonTag has failed 6 consecutive write attempts while other tags wrote successfully; discarding 1 queued sample(s) so the outbox can drain.ok+5,error+5 — one write per tag per change, against a 2s drain interval⚠️ The idle leg is the decisive one and the only one that could not be faked. The driven-rate legs show
a rate that happens to look right; a pre-fix build re-writes its queued healthy entries on every drain pass
whether or not anything new arrives, so its counters climb during a quiet window with no input at all.
Incidental finding, filed against Issue 5 rather than fixed: a node restart cures an unprovisioned
tag. A full address-space rebuild at boot treats every historized tag as added and re-runs
EnsureTags(
dispatched=2, requested=2, ensured=2, failed=0), where the incremental deploy that introduced therenamed tag did not. So an operator who restarts a node while diagnosing "my tag isn't historizing" makes
the symptom disappear without learning the cause.
Evidence:
docs/plans/2026-08-01-continuous-historization-live-gate.md§"Live gate for the two fixes".Design:
docs/Historian.md§"A poison tag no longer pins the outbox".