Skip to content

Rolling-upgrade structure skew makes a local __dbis__ seq cursor row undecodable (root cause behind harper-pro#352 second call site) #1307

Description

@kriszyp

Summary

During an in-place rolling upgrade, a node's own local __dbis__ seq cursor row (key [Symbol.for('seq'), nodeId], value {seqId, nodes}) can become undecodable — RecordEncoder.decode logs Error decoding record: Data read, but end of buffer not reached and returns null. This is the root cause behind the harper-pro#352 replication wedge; the harper-pro side ships a resilience guard (HarperFast/harper-pro#394) that stops the crash, but the row should not be undecodable in the first place.

Evidence (live node, fabric investigation)

Node 8tj-us-west1-a-1 after an in-place upgrade to 5.1.1, leader pq7:

  • The system database __dbis__ decoder had its msgpackr shared-structure table fully intact and consistent — 14 structures, on-disk == in-memory, including id 11 = ["seqId","nodes"] (the correct seq shape).
  • randomAccessStructure = false, typedStructs = 0 — structon random-access is not involved.
  • Yet the single seq entry present, [Symbol(seq), 1] (the leader's cursor), decoded to null with "end of buffer not reached 0".
  • Net effect: sendSubscriptionRequestUpdate crashed on it, inbound replication wedged (connected:true / subscriptions:null / lastReceivedVersion:0), still wedged ~50 min post-boot.

So this is not missing structures, not structon typedStructs, and not v4→v5 migration damage (this node was a fresh v5 provision — no LMDB remnants; #1163 / #362 / harper#1275 / copyDb #1260 are all present in 5.1.0/5.1.1 and working). It is a structure-shape skew: the seq bytes don't cleanly decode against the present, consistent structure table (the decode reads more/fewer fields than the structure it resolves to).

Why it doesn't self-heal

hdb_nodes rows self-heal via base-copy resync (independent of the cursor). The seq cursor only rewrites when replication advances — but the handshake crashes on the undecodable cursor, so replication never advances and the row never rewrites. A self-sustaining wedge (broken on the harper-pro side by harper-pro#394, which lets the node resume and rewrite the row against current structures).

Open question / where to look

How does a locally-written seq row end up encoded against a structure shape this node later decodes differently across a rolling upgrade? Candidates worth investigating:

  • The seq write path (core/resources/nodeIdMapping.ts, core/resources/Table.ts seqKey writes) vs the __dbis__ RecordEncoder structure save/load ordering across the version bump.
  • Whether the __dbis__ shared-structure table can be appended/reordered such that a row written pre-upgrade references a structure id whose definition differs post-upgrade.
  • The full failing bytes were truncated in the field log; an at-the-failing-read capture (or a deterministic rolling-upgrade repro) would pin the exact skew.

Related: harper-pro#352 (the wedge + guard), #1163, #362 (harper#1275), copyDb #1260.

🤖 Filed by Claude Opus 4.8 (1M context), on Kris's behalf.

Activity

  1. kriszyp commented on Jun 15, 2026

    @kriszyp
    MemberAuthor

    Root cause confirmed (live CDP forensics on 8tj)

    It is not a shared-structure skew — the __dbis__ structure table is intact (id 11 = {seqId, nodes}). It's a metadata-prefix misparse driven by isRocksDB=false on the __dbis__ RecordEncoder.

    The failing [seq, 1] value is 27 bytes (end=27):

    01 01 01 01 94 62 00 00 0e 00 00 40 00 00 00 01 | 4b cb<float64> 90
    └──────────────── 16-byte metadata prefix ─────┘   └ struct-11 {seqId, nodes:[]} ┘
    

    The 11 bytes from offset 16 are a perfectly valid struct-11 record — decoding buffer.subarray(16) yields {seqId: 1781538711832, nodes: []}. The record was written with a RocksDB metadata prefix: 8-byte local-timestamp, then buffer[8]=0x0e=ACTION_32_BIT → 32-bit flags 0e000040 (has HAS_NODE_ID) → nodeId=1 → real record at offset 16.

    But the __dbis__ decoder has isRocksDB=false (per #1260's note, handleLocalTimeForGets is deliberately never called on attributesDbi). So decode() takes the LMDB heuristic branch: metadataFlags = buffer[0] | (buffer[1]<<5) = 0x01 | (0x01<<5) = 0x21 → reads only HAS_RESIDENCY_ID (4 bytes) → lands at offset 6, not 16. super.decode(subarray(6,27)) reads 0x00 (fixint 0) and leaves 20 bytes → "Data read, but end of buffer not reached" → null.

    Proof: decoding the exact 27-byte record on the live decoder returns null as-is, and {localTime, version, nodeId:1, size:27, value:{seqId,nodes}} when isRocksDB is temporarily set to true.

    So: write/read asymmetry

    seq rows are written through the RocksDB metadata path (8-byte ts + 32-bit flags incl. nodeId), but read back through a decoder whose isRocksDB=false can't strip that prefix. The fix must reconcile the two without breaking the non-RocksDB structure-load path (superGetStructures) that #1260 relies on for attributesDbi.

    Open sub-question: why only some nodes/records hit this (the heavy audit/timestamp prefix appears on the seq write but not, e.g., the table-attribute catalog rows in the same store) — i.e., what makes a seq row get the versioned RocksDB prefix. Tracking toward a fix.

    🤖 Claude Opus 4.8 (1M context), on Kris's behalf.

  2. kriszyp commented on Jun 15, 2026

    @kriszyp
    MemberAuthor

    C verification: it's a module-global encode-state leak, not an isRocksDB read asymmetry

    The 16-byte prefix on the seq record is spurious — it should not be there at all:

    • The __dbis__ store is opened useVersions=false (OpenDBIObject: useVersions = isPrimary). It should never emit a version/timestamp prefix. The table-attribute catalog rows in the same store confirm this — their first byte is the struct-ref 0x40+id (≥64), no prefix, so they never trip the nextByte<32 decode heuristic and decode fine.
    • recordUpdater is wired only to primaryStore (Table.ts:200), never to dbisDb. The seq write (Table.ts:490 dbisDb.put) is a raw store put that does not set the *NextEncoding module globals (RecordEncoder.ts:104-109) — it encodes with whatever those globals currently hold.
    • recordUpdater sets timestampNextEncoding (658) + metadataInNextEncoding (675) + nodeId (700) before the encode that consumes+resets them. For a delete (record === undefined), the consuming store.putSync at line 742-744 is skipped, so the globals stay set. The next raw dbisDb.put (the seq cursor) then encodes with the leaked metadata → spurious 8-byte-ts + 32-bit-flags(+nodeId) prefix.

    The leaked nodeId=1 (= the leader) and timestamp in the captured prefix match a just-applied replicated record, not the cursor — consistent with the leak. Timing/workload-dependent (needs a delete-then-seq-write interleave during apply), which matches the observed intermittency.

    Fix direction (revised: not A)

    A (make the __dbis__ decoder isRocksDB-aware) would read the spurious prefix but store bogus localTime/nodeId, and flips a flag #1260's structure-load path depends on. The root issue is the leak. Preferred fix:

    • Gate metadata-prefix emission on the encoder's actual versioning config so a useVersions=false store (__dbis__) never emits a prefix regardless of leaked globals (robust against all leak windows; makes seq rows prefix-free like the attribute rows → decode cleanly with isRocksDB=false).
    • Optionally also reset the *NextEncoding globals on recordUpdater's skip path (defense-in-depth for the delete window).

    harper-pro#394 (skip the undecodable row) remains the resilience layer; this stops the row from being mis-encoded in the first place.

    🤖 Claude Opus 4.8 (1M context), on Kris's behalf.

  3. kriszyp commented on Jun 16, 2026

    @kriszyp
    MemberAuthor

    Fix PR: #1308 (draft). Implements option 3 from the analysis — encode hook ignores+preserves staged metadata for non-versioned stores, unconditional global reset in recordUpdater's finally, and explicit useVersions=false marking of the __dbis__ encoder (lmdb/rocksdb don't forward the option). Cross-model reviewed (Codex+Gemini, two rounds). — Claude (Opus 4.8)

  4. kriszyp commented on Jun 17, 2026

    @kriszyp
    MemberAuthor

    Closed by #1308 (merged 2026-06-16) — the encoding bug confirmed on a live node and fixed. The leaking module-global state that corrupted cursor rows is patched.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions