Repository navigation
read_audit_log crashes (and time-travel point-read fails) for a key that was deleted then re-inserted #1330
Description
Activity
- addedbugSomething isn't workingSomething isn't workingarea:storageStorage engine, LMDB/RocksDB, compactionStorage engine, LMDB/RocksDB, compaction
on Jun 16, 2026 Follow-up from focused reconciliation testing — the trigger here is broader than (and different from) "delete-then-reinsert"; the title/repro should be reframed.
A clean per-key matrix (each sequence on its own key,
audit:true,read_audit_logvia raw fetch on Harper7aaa5a152, with thecrdt.tsnull-deref still unguarded onmain):sequence search_type: hash_valuesearch_type: timestamp[insert]faithful faithful [insert, delete]faithful faithful [insert, update, delete]faithful faithful [insert, update, update, delete]faithful faithful [insert, update, update, update, delete]500 — Cannot set properties of null (setting 'id')faithful [insert, delete, reinsert]faithful faithful [insert, update, delete, reinsert]faithful faithful Two surprises: reinsert is a red herring (both reinsert sequences read faithfully), and it's not simply update-count.
The real variable is the audit back-walk window, not the operation shape.
Table.getHistoryOfRecord(resources/Table.ts:3911-3944) walks the audit chain backward in fixedauditWindow = 100mswindows. The delete entry is aPARTIAL_RECORD, routed togetRecordAtTime(resources/crdt.ts:80-125), wherecase 'delete': record = null(crdt.ts:97). When the record's history spans more than one 100ms window, the reconstruction anchors on a window that resolves tonull, and the downstream primary-key stamp doesrecord['id'] = …on that null → the TypeError. So it's a function of history time-span, not count:- 3 updates at ~2ms spacing (span ~10ms, one window) → faithful
- 2 updates at ~120ms spacing (span ~480ms, multiple windows) → crash
- 1 update → never crashes (chain too short)
Also: the crash is
hash_value-only — thetimestampwindow read is always faithful (it doesn't reconstruct partial records via this back-walk).Practical impact is much wider than the current framing implies: this fires on essentially any moderately-aged audited record that has been updated (≥2 changes) and then deleted, when read back via
read_audit_loghash_value— no reinsert required. A delete+reinsert that happens to span windows is just one instance.Fix: null-guard the PK-stamp / unknowns-fill loop in
getRecordAtTimeso anullrecord from a delete entry doesn't get['id']assigned (this covers all paths — window-spanning delete and reinsert). A regression test assertingread_audit_log hash_valueis faithful for an updated-then-deleted key whose history spans >100ms would guard it.
From the QA-explorer exploratory campaign for @kris. Corrects two earlier internal observations that disagreed on the trigger. Posted by Claude (Opus 4.8).
- added a commit that references this issue
on Jun 19, 2026 Further QA-explorer work on this issue has produced a more complete picture — the crash and silent-wrong reconstruction are both in `getRecordAtTime`. #1413 consolidates everything:
- Three independent crash triggers (window-span delete, reinsert-after-delete window span, and prune-induced missing entry in the second fill-in-the-blanks loop at `crdt.ts:107`)
- Four silent-wrong-reconstruction mechanisms in the reverse-walk (field-bleed for plain PATCH fields, `addTo` delta never reversed, delete-gap backward bleed, off-by-one at `crdt.ts:86`)
The "delete-then-reinsert" trigger in this issue's title is a red herring — the real crash variable is history time-span (>100ms audit window), not reinsert. QA-148 confirmed: pure insert+update+delete with a >100ms span crashes; insert+delete+reinsert within a single window does not.
Suggest tracking the fix work in #1413 and closing this as a duplicate once it lands.
From the QA-explorer campaign. Posted by Claude (Sonnet 4.6) for @kris.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Summary
On an audit-enabled table, a record that is DELETED then RE-INSERTED under the same key corrupts the audit READ path:
read_audit_logbyhash_value(and thetimestampvariant) →TypeError: Cannot set properties of null (setting 'id')atresources/crdt.ts getRecordAtTime(distcrdt.js:137). The history walk hits the delete (record = null) and the unknowns-fill loop then doesrecord['id'] = …on null.NOT a data-loss vector (scoped)
Durable MQTT QoS-1 catch-up and SSE
?startTime=replay across a delete+reinsert boundary deliver all messages with no loss/truncation — those paths iterate the audit store FORWARD (auditRecord.getValue()/previousVersion,resources/Table.ts~3041-3069/~3177-3211) and never call the crashinggetRecordAtTime. Replication catch-up uses the same forward iteration. So the impact is confined to theread_audit_logadmin/observability op and time-travel full-record reconstruction — stored live data + durable delivery are unaffected.Severity: medium
A common operation (delete-then-recreate a key: session/idempotency/cache re-creation, upsert-after-delete) breaks the audit-read admin op + time-travel for that table. Not data loss; not delivery loss.
Suggested fix
Null-guard the unknowns-fill loop in
getRecordAtTime(handlerecord === nullfrom a delete in the history walk) so a re-insert-after-delete history reads cleanly.Repro
integrationTests/qa-scratch/audit-replay.test.ts(the crash);integrationTests/qa-scratch/f015-catchup.test.ts(consumers unaffected). Harper001bf7b9c(v5.1.0, main).Found via the exploratory QA campaign (qa-explorer), scenarios QA-095/097.