Skip to content

LMDB structon typed-structure dictionary can corrupt under overlapping/interleaved commits to the same store #1702

Closed

Description

@kriszyp

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
    record put(), 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

Activity

  1. kriszyp commented on Jul 8, 2026

    @kriszyp
    MemberAuthor

    Fix for the regression this exposed: #1703

  2. self-assigned this
    on Jul 8, 2026
  3. kriszyp commented on Jul 8, 2026

    @kriszyp
    MemberAuthor

    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):

    1. The structure dictionaries are fine. Typed structs (structon) are off by default (storage.randomAccessFields), and the msgpackr classic named-structure dictionary showed zero divergence — no mint/decode mismatch, no saveStructures CAS rejects.
    2. The stored value itself was corrupted before encoding. The record reached RecordEncoder.encode as {headers: <web Headers>, body, status} — a raw Headers has no enumerable own keys, so it encodes as {}. That's the "silently emptied nested object", not a misapplied structure id.
    3. The mutator is finalizeResponse (server/REST.ts). It reassigns .headers = mergeHeaders(...) (a Headers instance) and defaults .status on the response object in place — and for a cache fill, that object is the same live record getFromSource queued for the deferred store commit. Setter-trap stack: finalizeResponse (REST.ts:38) → http (REST.ts:203).
    4. Why LMDB-only: RocksDB's cache-fill commit encodes synchronously (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 past finalizeResponse. 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)

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions