HarperFast / HarperFast/harper
Consume the audit staleness floor in Table.subscribe: an opt-in stale-start check so MQTT durable resume signals truncation instead of replaying short
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
Description
Follow-up to #2447, which adds the primitive (`Table.oldestRetainedAuditTime()` — the database's audit staleness floor) but deliberately stops short of making any consumer call it. This issue owns the end-to-end guarantee.
## What is still broken after #2447
`Table.subscribe` establishes a `startTime` replay with
```js
auditStore.getRange({ start: startTime, exclusiveStart: true, snapshot: false })
```
(`resources/Table.ts:4436`), which begins wherever the log now begins. A consumer whose cursor fell below the retention floor gets a replay that silently starts late. `server/DurableSubscriptionsSession.ts` hands persisted per-topic `startTime` values straight into that path on resume (`:502-513`), so an MQTT client offline longer than `logging.auditRetention` still loses messages with no signal — QoS 1/2 included.
#2447 makes the floor *knowable*. It does not make it *checked*.
## Why an out-of-store check is not sufficient
A consumer that reads the floor and then subscribes has a genuine check/use race: a retention pass can prune between the floor read and replay establishment, so the replay opens above the entries the consumer was told were safe. Two things bound how much this matters, and neither closes it:
- the window is milliseconds against retention horizons measured in days, and
- losing the race degrades to exactly today's behavior — silent truncation — never anything worse.
The real closure is validating the cursor **inside** `Table.subscribe`, atomically with establishing the replay range, so there is no window between the check and the read.
## The design constraint this issue must respect
**It almost certainly wants to be an opt-in subscribe option** (`failOnStaleStart`, or similar), not a new default error.
`Table.subscribe` is not an MQTT-only path. The same code serves MQTT resume, WebSocket and SSE reconnects (`connect`), and `sourcedFrom` caching-table subscriptions. Turning a fallen-off cursor into a thrown error by default changes behavior for every one of those consumers at once — a blast radius that is exactly why it was kept out of #2447 rather than ridden along on an API addition.
So the shape to design:
- an opt-in `SubscriptionRequest` flag that asks for stale-start detection,
- validation performed atomically with opening the replay range (not a separate read),
- a distinguishable error or event so a client can tell "you must resync" from any other failure,
- `DurableSubscriptionsSession` opting in, and deciding what an MQTT client is told (and what happens to the persisted session record) when its cursor is stale,
- a decision on whether QoS 1/2 durable sessions should opt in by default even while the generic path does not.
## Acceptance
- A durable-session reconnect whose persisted `startTime` is below the floor produces an explicit stale-cursor signal rather than a short replay, and an integration test observes it.
- Consumers that do not opt in behave exactly as they do today.
- The check/use race is closed for the opt-in path (validation and range establishment are not separately observable).
## Blocked by #2451 — do not land this first
Enforcement is only as sound as the floor it enforces, and two reviewers of #2458 found the floor
untrustworthy in specific cases. **Both reduce to the database generation #2451 is about**, so this
issue must not land before it:
- **A state copy reinstalls an older floor** (Kris Zyp). `restore_backup` and RocksDB checkpoints
replace a database's state with a copy of an earlier state, floor included, so a cursor from after
the copy point compares as safe against history that is gone.
- **A legacy floorless store's bootstrap epoch can sit below pruned history** (Chris Barber).
`establishAuditFloor` stamps `max(Date.now(), newest surviving key)`, and a legacy `deleteHistory`
can remove one table's entries from above every survivor, so a clock rolled back into that window
stamps an epoch below entries that are gone.
Neither is live today: after #2458 the accessor is diagnostic and no product path acts on the floor.
**This issue is what makes them live.** So the acceptance criteria above are incomplete without a
generation stamped at every state-copy path, validated inside the resume — and the second finding is
also the reason a generation is the only route by which an unmarked store can ever earn a finite
floor back, since `AUDIT_FLOOR_UNKNOWN` is absorbing (`raiseAuditFloor` cannot lift it and
`establishAuditFloor` skips any existing record).
## Related
- #2447 — the primitive this depends on.
- #2451 — the database generation this is blocked by (see above).
- #1706 — durable MQTT catch-up delivers a phantom notification for a TTL-expired message (same resume path).
- #2444 — RocksDB subscription catch-up delivers valueless entries, so resumed consumers silently drop updates (same "resume silently loses data" family).
Contributor guide
Research direction
Start with resources/Table.ts around the subscribe replay range at line 4436, then read server/DurableSubscriptionsSession.ts around lines 502-513 and the prerequisite generation work in #2451. Define the opt-in stale-start behavior and its durable-session signal, then add an integration test showing a stale persisted cursor signals resync while non-opt-in consumers retain current behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js, typescript
- Domain
- backend-api-design, databases
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100