Skip to content

Record an audit retention floor so a pruned subscription cursor is detectable (infrastructure; the check lands in #2448) #2447

Closed

Description

@dawsontoth

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 an await rest() per entry and filters tableId in 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:
    • getLastRemoved reads auditStore.get(Symbol.for('last-removed')), which goes through the audit store's value decoder. On LMDB readAuditEntry decodes the raw 8 float bytes as an audit entry, logs Reading 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 storing 1234567.5, it returned 1 — the value left behind by the store-creation updateLastRemoved(auditStore, 1).
    • On RocksDB the same call routes to rootStore.getSync and 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 purge purgeAgedLogs (:331), Table.deleteHistory (Table.ts:5542), and the whole-database delete_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 | null

Returns 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-session startTime, 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-tableId entry. The database floor is the cheap conservative answer — if cursor ≥ dbFloor then 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 DurableSubscriptionsSession consume the floor and signal the client on truncation.
  • Per-table exact floors.

Related

Activity

  1. self-assigned this
    on Sep 1, 2026
  2. added this to the v5.3 milestone on Sep 1, 2026
  3. dawsontoth commented on Sep 1, 2026

    @dawsontoth
    ContributorAuthor

    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.subscribe also serves WebSocket/SSE reconnects and sourcedFrom caching-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 inside subscribe, which is #2448.

  4. dawsontoth commented on Sep 2, 2026

    @dawsontoth
    ContributorAuthor

    PR up: #2458 (draft). Docs companion: HarperFast/documentation#660.

  5. changed the title [-]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)[/+] on Sep 14, 2026
  6. dawsontoth commented on Sep 14, 2026

    @dawsontoth
    ContributorAuthor

    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 inside Table.subscribe in #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 (RocksDB deleteHistory false entriesDeleted, pre-existing). The body above stands as the original problem statement; the "supported accessor" framing in it is superseded by this comment.

  7. dawsontoth commented on Sep 14, 2026

    @dawsontoth
    ContributorAuthor

    Completed by #2458, merged to main as 63823b2c5 on 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_before validates its timestamp with a 400.

    As re-scoped, there is no public accessor — the floor is internal (getAuditFloor in resources/auditStore.ts). The user-visible fix for the silent MQTT durable-resume truncation described above is #2448, which reads the floor inside Table.subscribe; until it lands, a startTime below the floor is still silently truncated. Docs for the withdrawn accessor were reverted in HarperFast/documentation#669. Follow-ups: #2451, #2486, #2566.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Fields

Priority

P2

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions