Skip to content

read_audit_log crashes (and time-travel point-read fails) for a key that was deleted then re-inserted #1330

Description

@kriszyp

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_log by hash_value (and the timestamp variant) → TypeError: Cannot set properties of null (setting 'id') at resources/crdt.ts getRecordAtTime (dist crdt.js:137). The history walk hits the delete (record = null) and the unknowns-fill loop then does record['id'] = … on null.
  • Full-record-at-timestamp / time-travel point reconstruction for that key fails the same way.
  • Durable: reproduces after a kill+restart (on-disk, not transient).

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 crashing getRecordAtTime. Replication catch-up uses the same forward iteration. So the impact is confined to the read_audit_log admin/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 (handle record === null from 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). Harper 001bf7b9c (v5.1.0, main).


Found via the exploratory QA campaign (qa-explorer), scenarios QA-095/097.

Activity

  1. added
    bugSomething isn't working
    area:storageStorage engine, LMDB/RocksDB, compaction
    on Jun 16, 2026
  2. self-assigned this
    on Jun 17, 2026
  3. kriszyp commented on Jun 19, 2026

    @kriszyp
    MemberAuthor

    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_log via raw fetch on Harper 7aaa5a152, with the crdt.ts null-deref still unguarded on main):

    sequence search_type: hash_value search_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 fixed auditWindow = 100ms windows. The delete entry is a PARTIAL_RECORD, routed to getRecordAtTime (resources/crdt.ts:80-125), where case '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 to null, and the downstream primary-key stamp does record['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 — the timestamp window 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_log hash_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 getRecordAtTime so a null record from a delete entry doesn't get ['id'] assigned (this covers all paths — window-spanning delete and reinsert). A regression test asserting read_audit_log hash_value is 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).

  4. kriszyp commented on Jun 19, 2026

    @kriszyp
    MemberAuthor

    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.

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

Metadata

Metadata

Assignees

Labels

area:storageStorage engine, LMDB/RocksDB, compactionbugSomething isn't working

Type

No type

Fields

Priority

P2

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions