Skip to content

feat(gateway): keep a plugin entity's freeze frame across a gateway restart - #680

Draft
bburda wants to merge 6 commits into
fix/opcua-rescan-comms-lost-freeze-sourcefrom
fix/persist-entity-freeze-frames
Draft

feat(gateway): keep a plugin entity's freeze frame across a gateway restart#680
bburda wants to merge 6 commits into
fix/opcua-rescan-comms-lost-freeze-sourcefrom
fix/persist-entity-freeze-frames

Conversation

@bburda

@bburda bburda commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

A plugin entity's freeze frame lived in gateway memory only. A restart replaced it with a re-read of current values under a new captured_at, in the same array shape.

The gateway now keeps entity frames in a SQLite file, entity_freeze_frames.db next to the trigger store: one row per fault code and entity, written on every capture, kept across a clear, dropped at eviction and at startup for fault codes the fault manager no longer holds. The reload runs before the startup catch-up. A reloaded frame is served as captured. The catch-up re-reads only faults with no frame.

A reloaded frame older than the fault's first_occurred belongs to an earlier occurrence (the fault cleared and confirmed again while the gateway was down). The catch-up re-reads that entity first, in the slot the frame holds. With values, it replaces the frame in memory and on disk, marked startup. With nothing (plugin unreachable, all values null) it drops the frame and logs one warning naming the fault and the entity. Each entity is asked once per catch-up. first_occurred is the comparator because it resets only on a new occurrence, while last_occurred advances on every failing report.

entity_freeze_frame.storage.path defaults next to triggers.storage.path. With neither set, frames stay in memory and the gateway logs that. An unopenable store is logged and capture continues in memory. The OPC UA plugin's docker config sets the path under /var/lib/ros2_medkit, start.sh mounts a named volume there so state survives stop.sh and start.sh, and the gateway changelog announces the parameter.

Known limits: one frame per fault, the newest. A fault that re-failed but has not re-confirmed keeps its earlier frame until it confirms. A HEALED to FAILED cycle does not reset first_occurred (healing is off by default).

Stacked on #660.

Issue

Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

Store suite: 14 tests over both backends, reopen and a corrupted row included.

Capture suite: 17 new tests. Write-through, replace on re-confirm, a reloaded frame served as captured, the catch-up skipping reloaded codes, the prune of unknown codes, the bound counting reloaded frames, eviction deleting the row, the stale-occurrence re-read and its same-occurrence control, the seconds-to-nanoseconds parse with out-of-range values, the drop with a warning on a failed re-read, the re-read before the row is touched, the bound with a stale frame, and each entity asked exactly once. Each test failed first with its guard removed.

Gateway ctest: 3884 tests, 0 failures. pre-commit passes.

Docker: a fault confirmed, a value flipped, the gateway restarted twice. The original frame is served with its own captured_at. A fault confirmed while the gateway was down gets a startup frame. The same script on the base re-derives the frame. With the demo's start.sh, a frame captured at 95.0 °C is served after stop.sh and start.sh while the plant reads 25.55 °C, and the documented purge leaves an empty store.

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

The frames captured for plugin-backed entities lived in process memory, so a
restart threw them away and the startup catch-up re-read the plant as it is
now. What came back was the current values under the original fault, stamped
with the restart and marked capture_origin: startup. The values at fault time,
which are the whole point of a freeze-frame, were gone, and a consumer had no
way to tell that the numbers under a fault from last night were read this
morning.

EntityFreezeFrameCapture now takes an EntityFreezeFrameStore. It writes a
fault's frames through on every capture, under the same lock as the map, and
loads them back in its constructor, so a reloaded frame is served exactly as it
was captured: its original captured_at, its own capture_origin (absent for a
confirm-edge frame, startup for one the catch-up took), and the connected and
source_timestamp provenance it carried. The startup catch-up then skips any
fault that already has a frame and re-reads only the ones with none, so a fault
that confirmed while the gateway was down still gets its startup frame.

Two backends sit behind the interface, SQLite for the gateway and an in-memory
one for tests. entity_freeze_frame.storage.path names the file. Empty puts it
next to triggers.storage.path, and with neither set the frames stay in memory,
exactly as they were before. A store that cannot be opened or written is
reported and the capture keeps working from memory.

Bounds. The retained-frame bound of 256 faults counts reloaded and freshly
captured frames together, so a reloaded frame no longer spends catch-up budget
it does not need, and an evicted fault loses its rows as well as its map entry
so the bound means something across a restart. At startup, frames whose fault
the fault manager no longer holds in any status are dropped. A fault reported
as cleared keeps its frame, and when the fault manager cannot be asked at all
nothing is dropped.
…e the gateway was down

A persisted frame is reloaded at start and the catch-up skips any fault that
already has one. That is right while the fault stands, and wrong the moment it
cleared and confirmed again in the meantime. The stored frame then holds the
previous incident's values, and the gateway served them under the new
occurrence with no marker at all, so nothing on the wire said the numbers came
from a different event than the fault being read.

The standing-fault list now carries each fault's first_occurred, which the
fault manager resets only when a CLEARED fault reactivates. A reloaded frame
whose captured_at predates it belongs to an occurrence that has ended, so the
row is dropped before the catch-up decides what is already framed. The fault
is then re-read now and the result marked capture_origin: startup, exactly
what an unframed standing fault has always got.

last_occurred cannot serve this. It moves on every FAILED report, so on a
fault that keeps failing it is always newer than the row and every standing
fault would be re-read at every restart, which is the behaviour persisting the
frame is here to replace. A value the reply does not carry reads as "cannot
tell" and keeps the stored frame.

Also announces the parameter in the changelog, and folds the one-line
"exactly one frame per fault" paragraph into the persistence paragraph it
belongs to.
The docker demo set neither entity_freeze_frame.storage.path nor
triggers.storage.path, so the gateway reported that the frames are not
persisted and kept them in process memory. The start and test scripts already
create /var/lib/ros2_medkit for the fault manager, so the store goes there and
a restart of the demo gateway serves the values frozen when the alarm
confirmed instead of re-reading the PLC as it is now.
…say when it goes

The re-occurrence fix erased the stale row from memory and from the store and
only then let the catch-up try to re-read the entity. When the entity could not
answer, which is a restart while the PLC link is down, the fault ended with no
frame in memory, no row on disk and not one line about it. The route path
returns nothing without a message, the failed capture was neither counted nor
logged, and the drop's own INFO claimed the catch-up re-reads those entities as
a fact it never checked.

The order is now read, then decide. Stale codes are identified without touching
anything, the catch-up re-reads them like any unframed fault, and a successful
read replaces the frame and its row in one write, marked startup. Only a read
that yields nothing drops the row, and then it warns with the fault code, the
entity and why, because the operator is losing evidence and the log is the only
place they can learn it. The tail INFO counts what happened, how many were
re-read and how many went with no replacement.

Two things that follow from the new order. A fault with no reporting sources
was stale to the comparison and invisible to the catch-up, so its row was
dropped by one test and never re-read by the other. It is now a re-read that
cannot be attempted and takes the same path and the same warning. A stale code
also jumps the queued-confirm skip: that skip saves one plugin read per
confirm, and here the alternative is leaving a dead occurrence's frame in place
on the chance the drain loop succeeds.

Also guards the seconds-to-nanoseconds conversion of first_occurred against a
double outside int64's range, where the cast is undefined, and documents the
two windows the comparison deliberately leaves alone: a fault that re-failed
without re-confirming is not in the confirmed list and keeps its frame until it
confirms, and a HEALED to FAILED cycle does not reset first_occurred.
… anything

Re-reading a stale frame in the same pass as the faults that have none put the
retained-frame bound in front of a read that cannot cost anything, and made the
occupancy it works from wrong for everyone else.

A stale code already holds a slot in frames_, and capture_for_event evicts only
when the code is new to the map, so its replacement can neither exceed the bound
nor push another frame out. Counting it as absent therefore did two things at
once at a full bound. A fault with no frame at all was admitted against the
under-count and the FIFO evicted the front of the insertion order, which is the
OLDEST live reloaded frame, from memory and from disk, without naming it. And
the stale code itself then hit the bound check and was discarded with a warning
saying its entity could not be re-read, when the entity had never been asked.

The catch-up now runs in two passes. First every stale code is re-read in place,
with no bound check and no eviction, replaced on success and dropped with the
existing warning on failure, which frees the slot it was holding. Only then is
the occupancy read, from what frames_ really holds, and the faults with no frame
are admitted against that. Eviction can no longer reach a code that is stale or
mid-re-read, and the bound branch for stale codes is gone because it can no
longer be reached.

The truncation warning now names the fault codes it refused (up to ten) instead
of only counting them. A count leaves an operator knowing that some fault
details lack their context but not which.

Also gives the range guard on the seconds-to-nanoseconds conversion a test.
Reverting it left the suite green, so it guarded nothing that was checked: 1e19
seconds and +inf both reach the cast, and both come back as INT64_MIN on this
box, which reads as "older than every frame" and would discard every reloaded
frame the reply mentions. NaN is refused by the sign test either way.
…t twice

Splitting the catch-up into two passes left a code the first pass had dropped
looking, to the second, exactly like a fault that never had a frame. It is
absent from frames_ for that very reason, so with a slot free the second pass
called the plugin again for the same entity: a second blocking read of a link
that had just failed to answer, once per unreachable stale entity at every
start.

When the link came back between the two reads it was worse than wasteful. The
fault ended up holding a fresh frame in memory and on disk while the warning had
already told the operator the stored frame was discarded and this occurrence had
none, the summary still counted it as discarded with no replacement, and the
slot the drop had freed went back to the code that lost it instead of to the
fault still waiting for one.

The first pass now records every code it settles, replaced or dropped alike, and
the second skips them. One read per fault per catch-up, a dropped frame stays
dropped for that catch-up, and its slot goes to the next fault with no frame, so
the counters and the log lines say what actually happened.

The branch's own test hid this behind EXPECT_GE on the read count, and only
passed at all because in that one shape the other fault reached the second pass
first and the bound then turned the stale code away. It now pins the count
exactly, alongside the two shapes that show the defect directly.

Also corrects a comment that had the failure mode backwards. An out-of-range
cast is undefined, and it is the platform's answer that decides what happens:
x86-64 gives INT64_MIN, which the non-positive check rejects, so the frame
survives by luck, while a saturating target gives a large positive value that
passes that check, sits above every captured_at and discards a good frame. The
range guard is what makes the two behave the same.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant