mpackdb
All repositories: gitoria
15.5 KB
# BUG: `refresh()` clobbers un-persisted in-memory meta during a concurrent write → lost deletes/inserts**Severity:** High — silent data corruption (deleted rows resurrect; inserts can vanish) underconcurrent reads + writes in the *same process*. No error is thrown.**Status: FIXED (2026-07-02).** Reproduced first with `tests/concurrent-delete-during-scan.test.js`(failed on the old code: deleted record resurrected in full scan), then fixed:- `meta.json` now carries a monotonic `version`, bumped by every `persistMeta()` (writtenatomically via tmp+rename). `refresh()` only adopts the on-disk meta when its version isNEWER than the in-memory one — i.e. another process persisted under the file lock.In-memory meta stays authoritative for this process, so the clobber below cannot happen.- Writers refresh right after acquiring the file lock, so cross-process `nextId`/tombstonesare current before mutating.- Bonus fix found during the work: the in-process lock was *temporal*, not causal — anyoperation that merely STARTED while another held the lock walked straight into the criticalsection (`_lockDepth++`). Locking is now based on `AsyncLocalStorage` call-chain ownershipplus an in-process FIFO queue (`tests/causal-lock.test.js`).Verified by `tests/concurrent-delete-during-scan.test.js` (40 rounds, 5x repeat, stable),`tests/causal-lock.test.js`, `tests/multi-process-write.test.js`, and the full suite.The original analysis is kept below for reference.---## SummaryRead scans (`recordGenerator` / `find`) do **not** take the instance lock, and every scan beginswith `await this.refresh()`, which **unconditionally overwrites `this._meta` with the on-diskcopy**. A concurrent `delete()` (or `insert()`) holds the lock, mutates `this._meta` in memory,and only calls `persistMeta()` **after an `await`**. If an unlocked scan's `refresh()` runs duringthat await window, it replaces `this._meta` with the *stale* on-disk version — discarding thein-memory mutation. The write then persists the rolled-back meta, so the change is silently lost.For a delete, the lost mutation is a tombstone offset → **the deleted record is resurrected** onsubsequent scans. For an insert, `nextId`/append bookkeeping can be lost.## Root cause (code)`src/MPackDB.js`:1. **`refresh()`** — unconditional wholesale reload:```jsasync refresh() {try { this._meta = JSON.parse(await readFile(this._metaPath)); } catch (e) {}}```2. **`recordGenerator()`** (backs `find()`) — calls `refresh()` with **no lock held**:```jsasync *recordGenerator(queryFn = null, { ... } = {}) {await this.init();await this.refresh(); // <-- unlocked; clobbers in-memory meta...}```3. **`delete()`** — holds the lock, mutates meta in memory, `await`s, THEN persists:```jsasync delete(mixed, { ... } = {}) {await this._acquireLock('delete', mixed);try {for await (const [record, offset] of this.find(mixed, ...)) {this._meta.deleted.push(offset); // (A) in-memory mutation, NOT persistedif (this._indexManager) await this._indexManager.remove(...); // (B) await → yields event loop}await this.persistMeta(); // (C) writes this._meta to disk} finally { await this._releaseLock(); }}```### The interleaving that corrupts data (single process)```delete(): acquire lockdelete(): this._meta.deleted.push(offset) // (A) tombstone in memory onlydelete(): await indexManager.remove(...) // (B) event loop yields herefind(): recordGenerator -> await refresh() // NO lock; reloads on-disk metafind(): this._meta = <on-disk copy> // ← DROPS the pushed tombstonedelete(): await persistMeta() // (C) persists meta WITHOUT the tombstone=> the "deleted" row is not tombstoned; future scans return it (resurrected).```Note `delete()` itself calls `find()` internally (the `for await ... of this.find(...)`), so thescan and the delete are routinely in flight together, and any other concurrent `find()` on thesame instance (e.g. a background query) triggers the same clobber.## Impact / how it was foundFound in a multi-lane blockchain (adryllium-2026-3) built on mpackdb. The UTXO store gets constantconcurrent access: background scans (`find`), point lookups, and a state-fingerprint reader runconcurrently with `delete()` (spend) and `insert()` (create). Spent UTXOs kept **reappearing** onsome nodes (and occasionally inserts were lost), causing derived-state divergence across nodes thatshould be byte-identical. Every application-level guard failed because the corruption is **below**the application — mpackdb was losing writes. It also caused a downstream "post-write invariantcheck" to see a just-deleted row as live and trigger expensive, unnecessary rebuilds.The bug is independent of any external/second writer — it happens with a **single process** doingconcurrent reads and writes on one `MPackDB` instance.## Reproduction sketch```jsconst db = new MPackDB('/tmp/racetest', { primaryKey: 'id' });await db.insert({ id: 'x', v: 1 });// Concurrently: delete 'x' while hammering find() so a scan's refresh() lands in delete()'s await window.await Promise.all([db.delete('x'),(async () => { for (let i = 0; i < 50; i++) { for await (const _ of db.find(() => true)) {} } })(),]);// BUG: (await db.find('x')).length is sometimes 1 — 'x' resurrected. Should always be 0.```(Timing-dependent; loop it or add a small await inside `indexManager.remove` to widen the window.)## Suggested fix`refresh()` must not reload when nothing changed externally, because **in-memory meta isauthoritative for this process** and may contain un-persisted mutations. Only a genuine externalchange (another process wrote the meta file under the file lock) should trigger a reload.**Do NOT** simply make `refresh()` acquire the lock — `delete()`/`insert()` call `find()` (→`refresh()`) *while already holding the lock*, so locking `refresh()` would deadlock (non-reentrantlock).Recommended: guard the reload on the meta file's identity since our last read/write.- Track `this._metaStat = { mtimeMs, size }` whenever we read the meta (in `init()`) or write it(end of `persistMeta()`, via `await stat(this._metaPath)`).- In `refresh()`: `stat()` the file; if `mtimeMs` and `size` are unchanged from `this._metaStat`,return without reloading; otherwise reload and update `this._metaStat`.This makes `refresh()` a no-op within a single writer (in-memory stays authoritative, so theconcurrent-delete clobber can't happen) while still reloading after a genuine external write.Caveat: `mtimeMs`+`size` can collide on coarse-mtime filesystems or same-size rewrites. If robustmulti-writer support is required, prefer a **monotonic version counter persisted inside the meta**(bump on every `persistMeta`; `refresh()` reloads only if the on-disk version > in-memory version).The stat-based guard is sufficient for single-writer-per-file usage (the common case here).## Suggested testAdd a `tests/concurrent-delete-during-scan.test.js` that runs many `find()` scans concurrently with`delete()`/`insert()` and asserts deleted keys never reappear and inserted keys never vanish (therepro above, looped). The existing `concurrent-find-insert.test.js` / `concurrent-lock.test.js` donot cover the delete-tombstone-loss case.## Files- `src/MPackDB.js`: `refresh()`, `recordGenerator()`, `delete()`, `insert()`, `persistMeta()`,`init()` (meta load).---# BUG: index lookup leaks `ERR_INVALID_ARG_TYPE` when `_indexPaths[field]` is undefined**Severity:** Medium — opaque rejected operation which can terminate a process when uncaught; nodata corruption. Reported against package 1.0.7 before publication.**Status: FIXED IN SOURCE (2026-07-20), consumer confirmation pending.**## Resolution / maintainer findings- All production access to `_indexPaths` now goes through `IndexManager._getIndexPath(field)`.Paths for declared indexes are deterministic, populated synchronously in the constructor andreconstructed if a slot is unexpectedly absent. This prevents `readFile(undefined)` for thereported declared-index condition.- Truly undeclared hints now reject with a clear `INDEX_NOT_FOUND` error carrying `field` and theconfigured `indexes`. Returning `[]` was rejected because that would silently create falsenegatives and could break find-then-insert uniqueness logic.- The report's exact/range asymmetry was incorrect: both `get()` and `entries()` use`_readIndexEntries()`, and both previously threw the same raw TypeError for an undeclared field.- The proposed `resetAfterReplace()` race was not supported by the implementation or runtime test:reset never clears `_indexPaths`. An external compaction/reopen preserved every path and thesubsequent declared exact lookup succeeded. The original observation most likely involved aninstance whose effective index configuration did not contain the requested field; the new errorwill expose that configuration directly if it recurs.- Regression coverage: `tests/index-path-guard.test.js` forces a declared path slot missing, checksexact and range recovery, checks both unknown-hint errors, and performs an external replacement.The targeted test, all 20 test files, and a clean packed-artifact consumer test pass.The original report is retained below for incident context; its range-immunity and reset-racehypotheses are historical and disproven.---## Original report (historical)## SummaryAn exact-value indexed lookup — `db.find(fn, { index: { field, value } })`, which routes through`IndexManager.get()` → `_getOnDisk()` → `_readIndexEntries()` — crashed with:```TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of Buffer or URL. Received undefinedat readFile (node:internal/fs/promises:1279:20)at IndexManager._readIndexEntries (src/IndexManager.js:393:39) // readFile(this._indexPaths[field], 'utf-8')at IndexManager._getOnDisk (src/IndexManager.js:573:34)at IndexManager.get (src/IndexManager.js:545:36)at MPackDB._indexedStream (src/MPackDB.js:703:46) // hint.value !== undefined branchat MPackDB.recordGenerator (src/MPackDB.js:529:16)at async Cursor.toArray (src/Cursor.js:60:20)````_readIndexEntries(field)` does `readFile(this._indexPaths[field], 'utf-8')`. When`this._indexPaths[field]` is `undefined`, `readFile(undefined)` throws `ERR_INVALID_ARG_TYPE`. Thesurrounding `try/catch` only swallows `ENOENT`, so this error propagates and (in our case) became anuncaught exception that killed the process.## Two independent problems1. **The robustness gap (definitely real, trivially confirmable by reading the code):**`_indexPaths[field]` is populated *only* in `IndexManager.init()` (src/IndexManager.js:62,iterating the declared `_indexes`) and is never re-cleared. If `get()` is ever reached with a`field` whose path slot is `undefined` — a field not in the declared index set, or a `get()`that races `init()`/`resetAfterReplace()` before the path map is (re)populated — the code does`readFile(undefined)` and throws an opaque `ERR_INVALID_ARG_TYPE` instead of returning `[]` or aclear "no such index" error.**Asymmetry:** the *range-scan* path (`entries()`, used by `boundingBox()` and `from/to` hints)tolerates a missing/empty index gracefully (returns nothing) — only the *exact-value* path(`get()`/`_getOnDisk()`/`_readIndexEntries()`) faults. In our migration, `boundingBox()` over anempty `units` collection returned `[]` cleanly in the same run where an exact-value `url` lookupcrashed.2. **The trigger (observed, NOT yet isolated):** how `_indexPaths[field]` became `undefined` for a*declared* index. It happened during a find-then-insert (`uniqueUrl` doing`find(r => r.url === url, { index: { field: 'url', value: url } })`) on a collection declared with`indexes: ['name','url','email']`, in a **second process** (a one-off Node script) attached tofiles that a **first process** (a long-running server, `compact:false`) also held open, shortlyafter a `drop()` (our `delete(() => true)` + `compact()`) had rewritten/replaced those files —i.e. the external-compaction `refresh()` → `_reopenAfterExternalReplace()` →`resetAfterReplace()` path was in play, and the server's 30Hz tick was doing concurrent indexops throughout. This points at an init/reset-ordering race around `_indexPaths`, not a plainempty-collection case.## Reproduction attempts (could NOT reproduce standalone)All of the following returned cleanly (`0` rows) and created a 0-byte `<name>.<field>.txt`:- fresh collection, no inserts, then `find(fn, {index:{field:'url', value:'x'}})`;- `insert` → `delete(() => true)` → `compact()` → exact-value lookup;- two `MPackDB` instances on the same files, second does the exact-value lookup;- `drop()` of a never-written collection, then a second instance + `refresh()` + exact-value lookup.So a single-process, quiescent reproduction does **not** trigger it. Reproducing it likely needs theconcurrent multi-process timing above (long-running writer doing continuous index ops + externalcompaction + a second reader issuing an exact-value lookup during the reopen/reset window). Sharingthis now so the maintainer with the internals in hand can pin the exact interleaving.## Suggested fix- **Make the failure safe and clear (fixes the crash regardless of trigger).** In`_readIndexEntries()` (and anywhere `_indexPaths[field]` is read), guard the path:```jsasync _readIndexEntries(field) {const path = this._indexPaths[field];if (!path) return []; // not-yet-initialized / unknown field — behave like the range-scan pathtry {return this._parseIndexLines(await readFile(path, 'utf-8'), field);} catch (e) {if (e.code === 'ENOENT') return [];throw e;}}```(Or throw an explicit `Error(`No index for field "${field}"`)` when the field isn't declared, so agenuine misuse is obvious instead of an opaque fs TypeError.) This aligns the exact-value pathwith the already-tolerant range-scan path.- **Investigate the trigger:** ensure `get()`/`_getOnDisk()` can never run against a half-populated`_indexPaths` — e.g. confirm `init()`/`resetAfterReplace()` fully repopulate `_indexPaths` beforeany query can observe the reset, under the causal lock, during a cross-process external-compactionreopen.## Suggested test- Unit: call the exact-value lookup path for a field that is (a) declared but whose index file doesnot yet exist, and (b) not declared at all — assert `[]` (a) / clear error (b), never a raw`ERR_INVALID_ARG_TYPE`.- Concurrency: a long-lived writer doing continuous indexed `update`/`find` while a second processtriggers `drop()`+`compact()` (external replace), and a third issues exact-value lookups acrossthe reopen window — assert no `ERR_INVALID_ARG_TYPE` escapes.## Consumer workaround (in use)Dropped exact-value `index` hints on lookups against collections that may be fresh/just-dropped(`url`-uniqueness checks, `owner` counts) and used predicate full scans there instead. Range-scan`boundingBox()` hints were kept (that path is immune). Small collections, so scans are cheap.## Files- `src/IndexManager.js`: `_readIndexEntries()` (:393), `_getOnDisk()` (:573), `get()` (:545),`init()` (:62, `_indexPaths` population), `resetAfterReplace()` (:158).- `src/MPackDB.js`: `_indexedStream()` (:703, exact-value branch), `refresh()` /`_reopenAfterExternalReplace()` (:772/:798).
Branches
- mastermain branch
Latest commits
- 87888725release 1.0.7caramboleyo
- c4cdb9b6node: import prefixes (Deno compat) + pre-existing index-state WIPcaramboleyo
- 0afb8f4bupdate now must be a callbackcaramboleyo
- cde73eb4release 1.0.6caramboleyo
- d01dda02add index hints, intersection, boundingBox; remove findByIndexcaramboleyo
- b8ffc1a0release 1.0.5caramboleyo
- d47876a1reimplemented lost features like indexed find and more testscaramboleyo
- 7f08da9afixed insert ignoring model definitioncaramboleyo
- 705774a9added flush before findcaramboleyo
- b4db6391initial commitcaramboleyo