Repository navigation
Upgrade 5.1.x→5.2: primary-key get() and indexed search miss rows written before the upgrade, while full scans and ops search see them #1864
Description
Symptom
On the first boot(s) of 5.2.0-beta.1 against a root last written by 5.1.10, the Resource read layer splits in two:
Table.get(id)returns null and indexedsearch({conditions:[...]})returns empty for every row written under 5.1.10;- full-scan
Table.search({}), the ops API (search_by_hash/search_by_value),getKeys, and rawgetBinarySync(key)all see every row, fully decoded.
Any component app reading its tables through tables.X.get(...) (REST record GETs, auth lookups) silently serves 404/empty on data that is present and intact on disk. First hit in production-grade form on kris-test (harper-pro:5.2.0-beta.1) with the HarperFast/dispatch app; then reproduced deterministically in isolation.
Deterministic repro
- Fresh headless
harper@5.1.10install (ROOTPATH=... HDB_ADMIN_USERNAME=... HDB_ADMIN_PASSWORD=... harper install; note: move~/.harperdb/hdb_boot_properties.fileaside first or the installer adopts the existing instance). - Deploy any component with a
graphqlSchema@tabletype; insert a few rows (ops API insert is enough). - Verify reads: REST/
Table.get(id)→ HIT under 5.1.10. - Stop cleanly (SIGTERM). Boot 5.2.0-beta.1 on the same root (upgrade directive runs, boot succeeds, no errors/warns logged).
Table.get(id)→ null; indexed search → empty;search({})/ops search → all rows. Reproduced on 3 fresh installs, 4/4 first-boots.
A ready-made harness lives in the dispatch repo (HarperFast/dispatch — docs/harper-table-persistence-bug.md has the full evidence log, and /dispatch/health + /dispatch/debug report the divergence per table; local scripts under ~/dev/tmp/{validate-health,vt-diagnostic}.sh on kzyp's box script the whole cycle).
Localization (what is and is not the cause)
| layer (broken boot) | result |
|---|---|
raw getBinarySync(key) |
HIT (bytes present, well-formed) |
getKeys / getRange / full scan |
HIT (all rows) |
store decoded point read → Table.get(id) |
MISS |
| indexed search | MISS |
Ruled out with evidence:
- Not a table-identity/registry fork: instrumented
table()/initStoresacross ~45 boots — always the existing-table branch, correcttableId, single column family, no__dbis__catalog nulls. (This was the original lead hypothesis; disproven.) - Not the Verification Table / record cache alone: patching the primary store's hardcoded
cache: true(inresources/databases.ts::table()/initStores) tofalse— which also disables the VT inPrimaryRocksDatabase— still misses. The plain decodedgetSyncpath fails. (Also notestorage.caching: falsein config is ineffective for this becausecache: trueis hardcoded at both open sites.) - Not rocksdb-js version skew: both installs resolve
@harperfast/rocksdb-js@2.4.0(5.1.10 declares^2.2.0), so the delta is harper-level open options / record encoding between 5.1.10 and 5.2, not the native library. - Not a decode error: no
decode-missing-structure/ "Error decoding record" warnings; record bytes are format-identical (0x42 timestamp prefix + metadata + classic structure) between 5.1.10- and 5.2-written rows. - Not the classic shared-structure save race (Replication-apply / full-copy re-encode mints shared structures off the saveStructures CAS (root cause behind #1506) #1508/fix(encoder): heal a diverged shared-structures dictionary on decode (durable-wins reload) #1506/Stale shared-structures: nodes silently return empty/partial query results without error #1163 family): ~480 mid-write SIGKILLs with new-shape writes on 5.2 never forked; that write-side path looks solid in 5.2.
Heal semantics (measured)
- A 5.2 write to a row heals that row deterministically (get → HIT immediately after).
- A plain reboot heals probabilistically: one experiment's second 5.2 boot healed all rows with zero user writes; another's second boot stayed broken; the field instance went 0/9 bad boots. SST files were observed rewritten around a healing boot, so the working suspicion is the heal rides on RocksDB flush/compaction rewriting the 5.1.10-era files — i.e. point reads fail against pre-upgrade on-disk state until background rewriting replaces it. (Consistent with the original "coin flip per boot" observation and with all-request-paths agreeing within a boot.)
- Data is never lost; ops reads stay correct throughout.
Suggested next step for the fix
Diff what changed between 5.1.10 and 5.2 in the store open options / point-read path for primary stores (OpenDBIObject/createOpenDBIObject, PrimaryRocksDatabase.getEntry, RecordEncoder read hooks) that could make a decoded point-get miss pre-upgrade entries that iterators still return — bloom/whole-key-filtering or comparator/format options on the CF are the classic way point-Get and iteration diverge on old files, and would explain compaction curing it.
Ties to the held #537/#545/#1453 structure-fork handoff (same investigation thread; different mechanism). Blocks app cutovers onto 5.2 upgraded-in-place instances; workaround for operators is rewrite-all-rows post-upgrade or fresh deploy, plus an app-level pk-get health gate (see HarperFast/dispatch PR #1).
🤖 Generated with Claude Code
— Claude (Opus 4.8), investigation with Kris
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
kriszyp commentedon Jul 18, 2026
Investigation update — layered diagnosis + corrections (2026-07-18)
Ran ~30 more instrumented upgrade boots (fresh 5.1.10 install → seed → 5.2 boot, with an in-component probe that exercises every read layer per key: raw
getBinarySync→ storegetSync→ storegetEntry→Table.get, plus a per-second health hammer from port-open). Key findings, including two corrections to the original report:Corrections
The fresh-install repro is NOT deterministic. The original 4/4 first-boot failures were partly luck: today's fresh-install boots mostly come up healthy, with occasionally a brief (~seconds) broken window right after startup (health 503 immediately post-boot, healed by the time a probe ran seconds later, with zero replay writes and zero user writes —
replayLogslogged nothing). The long-lived broken state (minutes+, the field symptom) reproduced this morning only on a root with real history — months of schema churn (multi-file schemas, type reorderings, ~15 restarts under 5.1.10) — and on the kris-test instance. A pristine seeded root seems insufficient to reliably trigger it; schema/catalog history appears to be an ingredient, echoing the original observation that reordering schema types once orphaned rows.Load sensitivity suspected but not confirmed: the morning's failures occurred while the box ran several concurrent Harper instances; a 2-CPU
tasksetstarvation of the 5.2 boot did not reproduce on a fresh root.Layer analysis (why this narrows the search a lot)
On a healed boot every layer HITs. During the brief window we caught,
Table.get-level reads fail while raw reads see the rows. Studying the read path for how that divergence can exist at all:Table.getreads viaprimaryStore.getEntry(id, { transaction: transaction.getReadTxn() })— i.e. through a rocksdb-js transaction; every path that keeps working (full scan,getKeys, ops API,getBinarySync) reads without one.Transaction::GetSync→txnHandle->getSync(key, value, readOptions, *dbHandle)passes the correct CF override,ensureSnapshot()pins a post-boot snapshot (sequence-based — old data always visible), anddecode()is byte-identical between v5.1.10 and v5.2.0-beta.1. Value bytes of 5.1.10- vs 5.2-written records are format-identical too (only timestamps differ).Table.getthrows during a boot/window state and every consumer (.catch(() => null)in apps, permission wrappers) launders the throw into "not found" — consistent with hdb.log showing no errors on bad boots (the errors would be swallowed client-side), or (b) Table-level entry validation (ensureLoadedFromSource/INVALIDATED|EVICTEDflags / expiration) mis-classifies entries under some boot state. ThemetadataFlagsof the affected records decode via theACTION_32_BITpath to0x0E000040(HAS_NODE_IDonly) on healthy boots — no EVICTED/INVALIDATED bits — so (b) requires state I haven't caught live yet.What would pin it
The probe now distinguishes ERR vs NULL per key (
Table.getwrapped bare, not caught). One captured long-lived broken boot with that probe answers (a)-vs-(b) immediately. The reliable trigger appears to need a schema-churned root: repro loops that also replay the history (multi-file schemas → reorder → restarts → concatenate → upgrade) are the next step, and/or deploying the layered probe to the kris-test instance where the long-lived state occurred in production form.Repro/probe assets:
~/dev/tmp/{layer-probe,hunt-broken,window-probe,hammer-probe}.sh+~/dev/tmp/probe-app(dispatch app with/dispatch/storeprobe+ err-capturing/dispatch/health) on kzyp's box.🤖 Generated with Claude Code
— Claude (Opus 4.8)
kriszyp commentedon Jul 18, 2026
Investigation update — schema-churn repro caught a long-lived break; ERR-vs-NULL answered (2026-07-18, later session)
Built the schema-churn replay repro the previous update called for (
scratch/harper-1864/churn-repro.shon branchagent/harper-1864-upgrade-orphan): fresh 5.1.10 install → multi-file GraphQL schema with type declarations relocated across files over N restarts → concatenate to single file → seed rows throughout → upgrade-boot to 5.2.0-beta.1 → sub-second/dispatch/health+/dispatch/storeprobehammering.Reliability: 15 attempts (
RESTARTS=4andRESTARTS=15, mixed hammer windows). 1/15 produced a long-lived break; the rest were clean or sub-second self-healing blips — consistent with churn history being a necessary but not sufficient ingredient (something else, likely boot-time race-window width, gates whether it's brief or long-lived). Documenting the odds rather than claiming full determinism.What the one caught long-lived break showed
GET /dispatch/healthreturned{"ok":false,"orphaned":[{"table":"User","rawCount":3,"scanCount":3,"getHits":0,"firstErr":"NULL"}]}continuously for ~175s.search({})/getKeyssaw all 3Userrows;Table.get(k)returnednullfor all 3, zero exceptions. The identically-seededWorkertable was unaffected in this run — the divergence is per-table, not global.firstErrwas'NULL'for the entire window, never'ERR:...'. The health probe callsTable.get(k)bare (no app-level.catch) and explicitly distinguishes a caught exception ('ERR:'+message) from a clean miss ('NULL'). A'NULL'result the whole time meansTable.getresolved cleanly tonullthroughout — it did not throw..catch(()=>null)) for this window: there was no throw to launder. It's more consistent with candidate (b)-shaped behavior — something at or above the entry layer legitimately (from the code's perspective) treats the row as absent — though I did not manage to capture the entry's livemetadataFlags/versionduring the break to confirm INVALIDATED/EVICTED/expiration specifically (see gap below).storeGetEntry.flagsfor the previously-orphaned rows decoded to234881088=0x0E000040(HAS_NODE_IDonly) — matches the healthy-boot decode from the prior update, so whatever caused the miss left no trace in the entry's flags once healed.Gap
The mid-window
storeprobecapture (added to the script after this first catch, to snapshotgetEntry's rawversion/metadataFlags/expiresAtwhile still broken) has not yet caught a second long-lived window in 14 follow-up attempts — so I can't yet cite the literal flag/version state of a broken entry, only thatTable.getreturns a cleannull. Given the low, seemingly load/timing-sensitive hit rate, further blind hunting has diminishing returns; I'm consulting on diagnosis + fix direction now rather than continuing to guess.Where I'd point next given NULL-not-ERR
Table.get's local-read path (resources/Table.ts,loadLocalRecord/withLocalEntryaround L4835-4893) returnsentry?.valuestraight through with no throw whenprimaryStore.getEntry(id, { transaction: transaction.getReadTxn() })itself resolves toundefined/no-entry — no INVALIDATED/EVICTED branch needs to fire for a cleannullif the entry itself is missing at the transaction-read layer. The previously-confirmed native-level finding (ensureSnapshot()pins a post-boot sequence snapshot, CF override correct) was established against a steady-state read; it doesn't rule out a boot-window race where a request'sDatabaseTransaction/read-txn is constructed (and its snapshot/CF handle bound) while some table-recreation-driven column-family swap or finalization from the churned schema history is still in flight, so the very first read transactions on that table briefly bind to a pre-finalization state. That's a hypothesis, not a confirmed mechanism — the next concrete step would be instrumentingDatabaseTransaction.getReadTxn/primaryStore.getEntry(or the nativeensureSnapshot) to log the bound sequence number / CF pointer identity on a request that hits during a caught broken window, correlated against when that table's CF was last (re)opened during boot.Repro script + notes:
scratch/harper-1864/churn-repro.shandscratch/harper-1864/README.mdonagent/harper-1864-upgrade-orphan(pushed, no PR yet — consulting on diagnosis/fix direction before writing a fix per the task brief).🤖 Generated with Claude Code
— Claude Sonnet 5