Repository navigation
LMDB structon typed-structure dictionary can corrupt under overlapping/interleaved commits to the same store #1702
Description
Summary
Under HARPER_STORAGE_ENGINE=lmdb, two commits to the same store for records with different
top-level shapes can, if their commits overlap/interleave in time, corrupt the per-store structon
typed-structure dictionary used by RecordEncoder (resources/RecordEncoder.ts, RecordEncoder extends StructonEncoder). The symptom observed: a sibling key's structure id gets misapplied on
decode, silently emptying a nested object property on the next read of an otherwise-unrelated record.
How this was found
While fixing #1641's regression (see the PR referenced below), we needed two cache-fill commits for
differently-shaped records on the same table (CacheOfHttp, sourcedFrom) to overlap in time to
reproduce a real, deterministic bug: unitTests/apiTests/cache-test.mjs's "get resolved with
Response" test failed under LMDB with response.headers.get('cache-control') coming back
undefined — the stored record's headers sub-object decoded as {}.
Root cause (confirmed via debug instrumentation + bisection, not just theory): #1641 added an
await to startPreCommitBlobsForRecord's local-write gate that, when it unintentionally applied to
Table.ts's getFromSource() cache-fill path too, widened the window during which two cache-fill
commits (for different keys, same table/store) could interleave. That interleaving — not the blob
gating mechanism itself — is what corrupted the structure dictionary. The PR fixing #1641's
regression (linked below) avoids re-introducing this specific overlap window, but the underlying
concurrency bug in structon's typed-structure dictionary is still latent and could be re-triggered
by any future change that introduces a new await between a commit's preparation and its execution
for a store using struct-mode encoding.
This is likely the same class of bug the team has hit before (per internal notes): issue #1163
(typed-structure divergence, "fix=disable typed structs") and the "structures fork" pattern seen in
v4→v5 migration structure-id forks. Worth checking whether those are actually the same root cause or
a related-but-distinct one.
Reproduction
HARPER_STORAGE_ENGINE=lmdb npx mocha unitTests/apiTests/basicREST-test.mjs unitTests/apiTests/cache-test.mjs
(Only reproduces on the commit that introduced the extra await; see the linked PR for the
avoidance fix. To force the underlying dictionary race again for investigation, reintroduce an
await between two same-store commits of differently-shaped records — e.g. temporarily add
await new Promise(r => setTimeout(r, 50)) before a record's actual store put().)
Suggested follow-up
- Determine the exact mechanism in
structon(vendored/dependency) by which structure-id
registration can diverge between the encoder's in-memory dictionary and what's actually decodable,
under overlapping/concurrent registration of new shapes for the same store. - Consider whether structure-id registration needs to be made atomic/synchronous relative to the
recordput(), or whether the dictionary needs stronger consistency checks (e.g. a checksum or
generation counter) to detect and recover from a divergence rather than silently returning wrong
data. - Audit other
await-containing pre-commit hooks (e.g.preCommitBlobsForRecordBefore's local-write
gate,embedBefore) for the same class of exposure on stores using struct-mode (LMDB primary DBIs).
Related
- PR fixing the immediate regression this bug exposed: fix(blob): don't gate sourcedFrom cache-fill commits on blob durability #1703
- Prior related work: issue Stale shared-structures: nodes silently return empty/partial query results without error #1163 (typed-structure divergence)
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
kriszyp commentedon Jul 8, 2026
Fix for the regression this exposed: #1703
kriszyp commentedon Jul 8, 2026
Root cause identified — and it invalidates this issue's structure-dictionary hypothesis.
Instrumented the full stack against the deterministic repro (probes on structon mints/reads, msgpackr classic-structure mints/saves/reloads,
RecordEncoder.encode, the cache-fill conversion, and the response path):storage.randomAccessFields), and the msgpackr classic named-structure dictionary showed zero divergence — no mint/decode mismatch, nosaveStructuresCAS rejects.RecordEncoder.encodeas{headers: <web Headers>, body, status}— a rawHeadershas no enumerable own keys, so it encodes as{}. That's the "silently emptied nested object", not a misapplied structure id.finalizeResponse(server/REST.ts). It reassigns.headers = mergeHeaders(...)(aHeadersinstance) and defaults.statuson the response object in place — and for a cache fill, that object is the same live recordgetFromSourcequeued for the deferred store commit. Setter-trap stack:finalizeResponse (REST.ts:38) → http (REST.ts:203).putSync) before the response path runs; LMDB's is deferred, and fix(blob): never gate apply commits on wire-carried save flags; gate local writes on blob durability #1641's durability gate widened that window pastfinalizeResponse. The interleaving of two differently-shaped commits was coincidental to the failure, not causal.Fix: #1709 — extend
finalizeResponse's existing frozen-record copy guard to entry-mapped records, so response mutations never reach the stored record regardless of commit timing. That removes the fragility this issue worried about (any future await in the commit path re-exposing it). Includes a regression test that reads the record back from the store.#1703's timing restoration stops being load-bearing for correctness once #1709 lands (its blob orphan-cleanup remains independently useful). No structon/msgpackr follow-up needed from this issue; closing can be decided on #1709.
— Investigated by KrAIs (Claude Opus 4.8)
fix(rest): copy live cache records in finalizeResponse before mutatin…
test(cache): poll for the deferred commit instead of a fixed delay (#…
fix(rest): copy live cache records in finalizeResponse before mutatin…
test(cache): poll for the deferred commit instead of a fixed delay (#…