Repository navigation
Record an audit retention floor so a pruned subscription cursor is detectable (infrastructure; the check lands in #2448) #2447
Description
Problem
Nothing in core can answer "is this audit cursor older than retained history?", so a consumer resuming incremental audit-log consumption from a saved cursor cannot tell a complete catch-up from a truncated one.
Table.subscribe replays a startTime resume with
auditStore.getRange({ start: startTime, exclusiveStart: true, snapshot: false })(resources/Table.ts:4436), which simply begins wherever the log now begins. If retention pruned past startTime, the replay starts late and the consumer loses every event in the gap — no error, no warning, no signal of any kind.
MQTT durable sessions are the shipped consumer that hits this. server/DurableSubscriptionsSession.ts persists a per-topic startTime in hdb_durable_session (:576-588) and, on resume, hands it straight back to that replay (:502-513 → SubscriptionsSession.addSubscription → Table.subscribe). A client offline longer than logging.auditRetention silently loses messages, QoS 1/2 subscriptions included — the exact guarantee the durable-session machinery exists to provide. The module's own docblock (:116-119) records that resume depends on querying the audit log; it says nothing about what happens when the needed range is already gone, because there is currently nothing it could say.
The same question is live for any other incremental audit-log consumer (replication catch-up, external gateways, components that persist a cursor).
Why the existing pieces don't answer it
- A consumer-side probe can't decide it. "The first entry after my cursor is much later than my cursor" is the identical observation for "the range was pruned" and "nothing was written in that window". The surviving log does not record what left it.
Table.getHistory(1, now)is the wrong tool even as a probe: it's an async generator that walks the whole retained range with anawait rest()per entry and filterstableIdin JS (resources/Table.ts:5586), i.e. a linear read of the entire audit log to learn one value that a single seek could give.- Core has a private watermark that does not work.
getLastRemoved/updateLastRemoved(resources/auditStore.ts:305-316) have zero consumers, and measuring them explains why:getLastRemovedreadsauditStore.get(Symbol.for('last-removed')), which goes through the audit store's value decoder. On LMDBreadAuditEntrydecodes the raw 8 float bytes as an audit entry, logsReading audit entry error RangeError…, and returns a corrupt-sentinel object;FLOAT_BUFFER.set(object)is then a silent no-op, so the function returns whatever stale value the module global happens to hold. Measured: after storing1234567.5, it returned1— the value left behind by the store-creationupdateLastRemoved(auditStore, 1).- On RocksDB the same call routes to
rootStore.getSyncand throws (Error: Data read, but end of buffer not reached 65). - Four of the five paths that prune audit history never write the watermark at all: the RocksDB steady-state purge (
auditStore.ts:186-191), the RocksDB boot purgepurgeAgedLogs(:331),Table.deleteHistory(Table.ts:5542), and the whole-databasedelete_transaction_logs_before(dataLayer/harperBridge/ResourceBridge.ts:547). Only the LMDB cleanup loop (:305) does.
Proposal
A supported accessor on the table class:
Table.oldestRetainedAuditTime(): number | nullReturns the floor F: every audit entry with time ≥ F is retained. A consumer whose cursor is ≥ F can catch up incrementally; a cursor below F has fallen off the log and must resync. null means nothing has ever been pruned and nothing is retained.
Semantics to pin down and document:
- Time domain is the audit-log key /
localTime— the same domain as subscription events and durable-sessionstartTime, so consumers compare directly. - Database-scoped, deliberately. The audit store is per-database and entries carry
tableId(resources/auditStore.ts:123); an exact per-table floor would need a scan for the first matching-tableIdentry. The database floor is the cheap conservative answer — ifcursor ≥ dbFloorthen no entry of any table was removed below the cursor. - Sync — it is one seek, not a scan.
- The error direction is one-way: every fallback may tell a consumer to resync when it did not strictly have to, and must never tell it that it is safe when entries it needed were pruned.
- Correct on both engines, including RocksDB's whole-file purge granularity and the storage-pressure retention shrink (
auditRetention / (1 + priority²),:186-191), where the accessor reports post-hoc reality.
Making that true requires fixing getLastRemoved/updateLastRemoved and adding the watermark write to the four prune paths above, so the floor cannot be reported below history that is already gone.
Out of scope (follow-ups)
- Making
DurableSubscriptionsSessionconsume the floor and signal the client on truncation. - Per-table exact floors.
Related
- Durable MQTT catch-up delivers phantom notification for a TTL-expired message #1706 — durable MQTT catch-up delivers a phantom notification for a TTL-expired message (same resume path).
- RocksDB subscription catch-up (startTime/previousCount) delivers patch/invalidate entries with no value — resumed consumers silently drop updates (5.2.x; fixed on main by #2409) #2444 — RocksDB subscription catch-up delivers valueless entries, so resumed consumers silently drop updates (same "resume silently loses data" family).
- Audit log pruning only scheduled on last worker thread; watchdog-restart cycles prevent cleanup #1164 — audit pruning only scheduled on the last worker thread (affects when the floor moves).
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
dawsontoth commentedon Sep 1, 2026
Sequencing, so the split is explicit rather than implicit: this issue ships the primitive (a knowable floor). #2448 owns the guarantee (the floor actually being checked, via an opt-in stale-start validation inside
Table.subscribe).The split is deliberate, not deferral.
Table.subscribealso serves WebSocket/SSE reconnects andsourcedFromcaching-table subscriptions, so turning a fallen-off cursor into an error by default would change behavior for every subscription consumer at once — a blast radius that needs its own design pass and milestone rather than riding along on an API addition.One limitation this issue therefore keeps and documents rather than fixes: an out-of-store
oldestRetainedAuditTime()→subscribe()sequence has a check/use race, since a retention pass can prune between the two calls. The window is milliseconds against retention horizons measured in days, and losing the race degrades to exactly today's behavior (silent truncation), never worse — but it is only closed by validating insidesubscribe, which is #2448.dawsontoth commentedon Sep 2, 2026
PR up: #2458 (draft). Docs companion: HarperFast/documentation#660.
Document Table.oldestRetainedAuditTime() and the subscription catch-u…
[-]Add a supported audit staleness-floor accessor: nothing distinguishes a pruned cursor from a complete one, so MQTT durable resume truncates silently[/-][+]Record an audit retention floor so a pruned subscription cursor is detectable (infrastructure; the check lands in #2448)[/+]dawsontoth commentedon Sep 14, 2026
Re-scoped 2026-09-10; retitled to match. This issue originally asked for a public accessor,
Table.oldestRetainedAuditTime(), and #2458 shipped one. Per @kriszyp's review it is withdrawn: it had no caller except #2448, a public single-number floor binds a contract a per-origin resume vector could not honor, and it created a second resume-validity path where replication already checks inside the operation. Decision: the floor lands as internal infrastructure only — recorded write-ahead by every prune path, verified on commit — and the check lands insideTable.subscribein #2448, where the floor cannot move between being read and being acted on.Status: #2458 is Ready at
167c67f9, awaiting re-review. HarperFast/documentation#660 documented the withdrawn accessor and is being reverted by HarperFast/documentation#669. Follow-ups: #2451 (restore/generation), #2486 (floor write vs boot purge on a full disk), #2566 (RocksDBdeleteHistoryfalseentriesDeleted, pre-existing). The body above stands as the original problem statement; the "supported accessor" framing in it is superseded by this comment.dawsontoth commentedon Sep 14, 2026
Completed by #2458, merged to
mainas63823b2c5on 2026-09-14. Every database now records a write-ahead audit retention floor, raised by all five prune paths before anything is removed and verified on commit; a first-open bootstrap epoch is recorded as provenance; operator-supplied prune bounds are clamped;delete_transaction_logs_beforevalidates its timestamp with a 400.As re-scoped, there is no public accessor — the floor is internal (
getAuditFloorinresources/auditStore.ts). The user-visible fix for the silent MQTT durable-resume truncation described above is #2448, which reads the floor insideTable.subscribe; until it lands, astartTimebelow the floor is still silently truncated. Docs for the withdrawn accessor were reverted in HarperFast/documentation#669. Follow-ups: #2451, #2486, #2566.