gitoriaLog in with ident

mpackdb

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8788872587888725release 1.0.7caramboleyo87888725/BUG_REPORTS.md

15.5 KB

  1. # BUG: `refresh()` clobbers un-persisted in-memory meta during a concurrent write → lost deletes/inserts
  2. **Severity:** High — silent data corruption (deleted rows resurrect; inserts can vanish) under
  3. concurrent reads + writes in the *same process*. No error is thrown.
  4. **Status: FIXED (2026-07-02).** Reproduced first with `tests/concurrent-delete-during-scan.test.js`
  5. (failed on the old code: deleted record resurrected in full scan), then fixed:
  6. - `meta.json` now carries a monotonic `version`, bumped by every `persistMeta()` (written
  7. atomically via tmp+rename). `refresh()` only adopts the on-disk meta when its version is
  8. NEWER than the in-memory one — i.e. another process persisted under the file lock.
  9. In-memory meta stays authoritative for this process, so the clobber below cannot happen.
  10. - Writers refresh right after acquiring the file lock, so cross-process `nextId`/tombstones
  11. are current before mutating.
  12. - Bonus fix found during the work: the in-process lock was *temporal*, not causal — any
  13. operation that merely STARTED while another held the lock walked straight into the critical
  14. section (`_lockDepth++`). Locking is now based on `AsyncLocalStorage` call-chain ownership
  15. plus an in-process FIFO queue (`tests/causal-lock.test.js`).
  16. Verified by `tests/concurrent-delete-during-scan.test.js` (40 rounds, 5x repeat, stable),
  17. `tests/causal-lock.test.js`, `tests/multi-process-write.test.js`, and the full suite.
  18. The original analysis is kept below for reference.
  19. ---
  20. ## Summary
  21. Read scans (`recordGenerator` / `find`) do **not** take the instance lock, and every scan begins
  22. with `await this.refresh()`, which **unconditionally overwrites `this._meta` with the on-disk
  23. copy**. A concurrent `delete()` (or `insert()`) holds the lock, mutates `this._meta` in memory,
  24. and only calls `persistMeta()` **after an `await`**. If an unlocked scan's `refresh()` runs during
  25. that await window, it replaces `this._meta` with the *stale* on-disk version — discarding the
  26. in-memory mutation. The write then persists the rolled-back meta, so the change is silently lost.
  27. For a delete, the lost mutation is a tombstone offset → **the deleted record is resurrected** on
  28. subsequent scans. For an insert, `nextId`/append bookkeeping can be lost.
  29. ## Root cause (code)
  30. `src/MPackDB.js`:
  31. 1. **`refresh()`** — unconditional wholesale reload:
  32. ```js
  33. async refresh() {
  34. try { this._meta = JSON.parse(await readFile(this._metaPath)); } catch (e) {}
  35. }
  36. ```
  37. 2. **`recordGenerator()`** (backs `find()`) — calls `refresh()` with **no lock held**:
  38. ```js
  39. async *recordGenerator(queryFn = null, { ... } = {}) {
  40. await this.init();
  41. await this.refresh(); // <-- unlocked; clobbers in-memory meta
  42. ...
  43. }
  44. ```
  45. 3. **`delete()`** — holds the lock, mutates meta in memory, `await`s, THEN persists:
  46. ```js
  47. async delete(mixed, { ... } = {}) {
  48. await this._acquireLock('delete', mixed);
  49. try {
  50. for await (const [record, offset] of this.find(mixed, ...)) {
  51. this._meta.deleted.push(offset); // (A) in-memory mutation, NOT persisted
  52. if (this._indexManager) await this._indexManager.remove(...); // (B) await → yields event loop
  53. }
  54. await this.persistMeta(); // (C) writes this._meta to disk
  55. } finally { await this._releaseLock(); }
  56. }
  57. ```
  58. ### The interleaving that corrupts data (single process)
  59. ```
  60. delete(): acquire lock
  61. delete(): this._meta.deleted.push(offset) // (A) tombstone in memory only
  62. delete(): await indexManager.remove(...) // (B) event loop yields here
  63. find(): recordGenerator -> await refresh() // NO lock; reloads on-disk meta
  64. find(): this._meta = <on-disk copy> // ← DROPS the pushed tombstone
  65. delete(): await persistMeta() // (C) persists meta WITHOUT the tombstone
  66. => the "deleted" row is not tombstoned; future scans return it (resurrected).
  67. ```
  68. Note `delete()` itself calls `find()` internally (the `for await ... of this.find(...)`), so the
  69. scan and the delete are routinely in flight together, and any other concurrent `find()` on the
  70. same instance (e.g. a background query) triggers the same clobber.
  71. ## Impact / how it was found
  72. Found in a multi-lane blockchain (adryllium-2026-3) built on mpackdb. The UTXO store gets constant
  73. concurrent access: background scans (`find`), point lookups, and a state-fingerprint reader run
  74. concurrently with `delete()` (spend) and `insert()` (create). Spent UTXOs kept **reappearing** on
  75. some nodes (and occasionally inserts were lost), causing derived-state divergence across nodes that
  76. should be byte-identical. Every application-level guard failed because the corruption is **below**
  77. the application — mpackdb was losing writes. It also caused a downstream "post-write invariant
  78. check" to see a just-deleted row as live and trigger expensive, unnecessary rebuilds.
  79. The bug is independent of any external/second writer — it happens with a **single process** doing
  80. concurrent reads and writes on one `MPackDB` instance.
  81. ## Reproduction sketch
  82. ```js
  83. const db = new MPackDB('/tmp/racetest', { primaryKey: 'id' });
  84. await db.insert({ id: 'x', v: 1 });
  85. // Concurrently: delete 'x' while hammering find() so a scan's refresh() lands in delete()'s await window.
  86. await Promise.all([
  87. db.delete('x'),
  88. (async () => { for (let i = 0; i < 50; i++) { for await (const _ of db.find(() => true)) {} } })(),
  89. ]);
  90. // BUG: (await db.find('x')).length is sometimes 1 — 'x' resurrected. Should always be 0.
  91. ```
  92. (Timing-dependent; loop it or add a small await inside `indexManager.remove` to widen the window.)
  93. ## Suggested fix
  94. `refresh()` must not reload when nothing changed externally, because **in-memory meta is
  95. authoritative for this process** and may contain un-persisted mutations. Only a genuine external
  96. change (another process wrote the meta file under the file lock) should trigger a reload.
  97. **Do NOT** simply make `refresh()` acquire the lock — `delete()`/`insert()` call `find()` (→
  98. `refresh()`) *while already holding the lock*, so locking `refresh()` would deadlock (non-reentrant
  99. lock).
  100. Recommended: guard the reload on the meta file's identity since our last read/write.
  101. - Track `this._metaStat = { mtimeMs, size }` whenever we read the meta (in `init()`) or write it
  102. (end of `persistMeta()`, via `await stat(this._metaPath)`).
  103. - In `refresh()`: `stat()` the file; if `mtimeMs` and `size` are unchanged from `this._metaStat`,
  104. return without reloading; otherwise reload and update `this._metaStat`.
  105. This makes `refresh()` a no-op within a single writer (in-memory stays authoritative, so the
  106. concurrent-delete clobber can't happen) while still reloading after a genuine external write.
  107. Caveat: `mtimeMs`+`size` can collide on coarse-mtime filesystems or same-size rewrites. If robust
  108. multi-writer support is required, prefer a **monotonic version counter persisted inside the meta**
  109. (bump on every `persistMeta`; `refresh()` reloads only if the on-disk version > in-memory version).
  110. The stat-based guard is sufficient for single-writer-per-file usage (the common case here).
  111. ## Suggested test
  112. Add a `tests/concurrent-delete-during-scan.test.js` that runs many `find()` scans concurrently with
  113. `delete()`/`insert()` and asserts deleted keys never reappear and inserted keys never vanish (the
  114. repro above, looped). The existing `concurrent-find-insert.test.js` / `concurrent-lock.test.js` do
  115. not cover the delete-tombstone-loss case.
  116. ## Files
  117. - `src/MPackDB.js`: `refresh()`, `recordGenerator()`, `delete()`, `insert()`, `persistMeta()`,
  118. `init()` (meta load).
  119. ---
  120. # BUG: index lookup leaks `ERR_INVALID_ARG_TYPE` when `_indexPaths[field]` is undefined
  121. **Severity:** Medium — opaque rejected operation which can terminate a process when uncaught; no
  122. data corruption. Reported against package 1.0.7 before publication.
  123. **Status: FIXED IN SOURCE (2026-07-20), consumer confirmation pending.**
  124. ## Resolution / maintainer findings
  125. - All production access to `_indexPaths` now goes through `IndexManager._getIndexPath(field)`.
  126. Paths for declared indexes are deterministic, populated synchronously in the constructor and
  127. reconstructed if a slot is unexpectedly absent. This prevents `readFile(undefined)` for the
  128. reported declared-index condition.
  129. - Truly undeclared hints now reject with a clear `INDEX_NOT_FOUND` error carrying `field` and the
  130. configured `indexes`. Returning `[]` was rejected because that would silently create false
  131. negatives and could break find-then-insert uniqueness logic.
  132. - The report's exact/range asymmetry was incorrect: both `get()` and `entries()` use
  133. `_readIndexEntries()`, and both previously threw the same raw TypeError for an undeclared field.
  134. - The proposed `resetAfterReplace()` race was not supported by the implementation or runtime test:
  135. reset never clears `_indexPaths`. An external compaction/reopen preserved every path and the
  136. subsequent declared exact lookup succeeded. The original observation most likely involved an
  137. instance whose effective index configuration did not contain the requested field; the new error
  138. will expose that configuration directly if it recurs.
  139. - Regression coverage: `tests/index-path-guard.test.js` forces a declared path slot missing, checks
  140. exact and range recovery, checks both unknown-hint errors, and performs an external replacement.
  141. The targeted test, all 20 test files, and a clean packed-artifact consumer test pass.
  142. The original report is retained below for incident context; its range-immunity and reset-race
  143. hypotheses are historical and disproven.
  144. ---
  145. ## Original report (historical)
  146. ## Summary
  147. An exact-value indexed lookup — `db.find(fn, { index: { field, value } })`, which routes through
  148. `IndexManager.get()` → `_getOnDisk()` → `_readIndexEntries()` — crashed with:
  149. ```
  150. TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined
  151. at readFile (node:internal/fs/promises:1279:20)
  152. at IndexManager._readIndexEntries (src/IndexManager.js:393:39) // readFile(this._indexPaths[field], 'utf-8')
  153. at IndexManager._getOnDisk (src/IndexManager.js:573:34)
  154. at IndexManager.get (src/IndexManager.js:545:36)
  155. at MPackDB._indexedStream (src/MPackDB.js:703:46) // hint.value !== undefined branch
  156. at MPackDB.recordGenerator (src/MPackDB.js:529:16)
  157. at async Cursor.toArray (src/Cursor.js:60:20)
  158. ```
  159. `_readIndexEntries(field)` does `readFile(this._indexPaths[field], 'utf-8')`. When
  160. `this._indexPaths[field]` is `undefined`, `readFile(undefined)` throws `ERR_INVALID_ARG_TYPE`. The
  161. surrounding `try/catch` only swallows `ENOENT`, so this error propagates and (in our case) became an
  162. uncaught exception that killed the process.
  163. ## Two independent problems
  164. 1. **The robustness gap (definitely real, trivially confirmable by reading the code):**
  165. `_indexPaths[field]` is populated *only* in `IndexManager.init()` (src/IndexManager.js:62,
  166. iterating the declared `_indexes`) and is never re-cleared. If `get()` is ever reached with a
  167. `field` whose path slot is `undefined` — a field not in the declared index set, or a `get()`
  168. that races `init()`/`resetAfterReplace()` before the path map is (re)populated — the code does
  169. `readFile(undefined)` and throws an opaque `ERR_INVALID_ARG_TYPE` instead of returning `[]` or a
  170. clear "no such index" error.
  171. **Asymmetry:** the *range-scan* path (`entries()`, used by `boundingBox()` and `from/to` hints)
  172. tolerates a missing/empty index gracefully (returns nothing) — only the *exact-value* path
  173. (`get()`/`_getOnDisk()`/`_readIndexEntries()`) faults. In our migration, `boundingBox()` over an
  174. empty `units` collection returned `[]` cleanly in the same run where an exact-value `url` lookup
  175. crashed.
  176. 2. **The trigger (observed, NOT yet isolated):** how `_indexPaths[field]` became `undefined` for a
  177. *declared* index. It happened during a find-then-insert (`uniqueUrl` doing
  178. `find(r => r.url === url, { index: { field: 'url', value: url } })`) on a collection declared with
  179. `indexes: ['name','url','email']`, in a **second process** (a one-off Node script) attached to
  180. files that a **first process** (a long-running server, `compact:false`) also held open, shortly
  181. after a `drop()` (our `delete(() => true)` + `compact()`) had rewritten/replaced those files —
  182. i.e. the external-compaction `refresh()` → `_reopenAfterExternalReplace()` →
  183. `resetAfterReplace()` path was in play, and the server's 30Hz tick was doing concurrent index
  184. ops throughout. This points at an init/reset-ordering race around `_indexPaths`, not a plain
  185. empty-collection case.
  186. ## Reproduction attempts (could NOT reproduce standalone)
  187. All of the following returned cleanly (`0` rows) and created a 0-byte `<name>.<field>.txt`:
  188. - fresh collection, no inserts, then `find(fn, {index:{field:'url', value:'x'}})`;
  189. - `insert` → `delete(() => true)` → `compact()` → exact-value lookup;
  190. - two `MPackDB` instances on the same files, second does the exact-value lookup;
  191. - `drop()` of a never-written collection, then a second instance + `refresh()` + exact-value lookup.
  192. So a single-process, quiescent reproduction does **not** trigger it. Reproducing it likely needs the
  193. concurrent multi-process timing above (long-running writer doing continuous index ops + external
  194. compaction + a second reader issuing an exact-value lookup during the reopen/reset window). Sharing
  195. this now so the maintainer with the internals in hand can pin the exact interleaving.
  196. ## Suggested fix
  197. - **Make the failure safe and clear (fixes the crash regardless of trigger).** In
  198. `_readIndexEntries()` (and anywhere `_indexPaths[field]` is read), guard the path:
  199. ```js
  200. async _readIndexEntries(field) {
  201. const path = this._indexPaths[field];
  202. if (!path) return []; // not-yet-initialized / unknown field — behave like the range-scan path
  203. try {
  204. return this._parseIndexLines(await readFile(path, 'utf-8'), field);
  205. } catch (e) {
  206. if (e.code === 'ENOENT') return [];
  207. throw e;
  208. }
  209. }
  210. ```
  211. (Or throw an explicit `Error(`No index for field "${field}"`)` when the field isn't declared, so a
  212. genuine misuse is obvious instead of an opaque fs TypeError.) This aligns the exact-value path
  213. with the already-tolerant range-scan path.
  214. - **Investigate the trigger:** ensure `get()`/`_getOnDisk()` can never run against a half-populated
  215. `_indexPaths` — e.g. confirm `init()`/`resetAfterReplace()` fully repopulate `_indexPaths` before
  216. any query can observe the reset, under the causal lock, during a cross-process external-compaction
  217. reopen.
  218. ## Suggested test
  219. - Unit: call the exact-value lookup path for a field that is (a) declared but whose index file does
  220. not yet exist, and (b) not declared at all — assert `[]` (a) / clear error (b), never a raw
  221. `ERR_INVALID_ARG_TYPE`.
  222. - Concurrency: a long-lived writer doing continuous indexed `update`/`find` while a second process
  223. triggers `drop()`+`compact()` (external replace), and a third issues exact-value lookups across
  224. the reopen window — assert no `ERR_INVALID_ARG_TYPE` escapes.
  225. ## Consumer workaround (in use)
  226. Dropped exact-value `index` hints on lookups against collections that may be fresh/just-dropped
  227. (`url`-uniqueness checks, `owner` counts) and used predicate full scans there instead. Range-scan
  228. `boundingBox()` hints were kept (that path is immune). Small collections, so scans are cheap.
  229. ## Files
  230. - `src/IndexManager.js`: `_readIndexEntries()` (:393), `_getOnDisk()` (:573), `get()` (:545),
  231. `init()` (:62, `_indexPaths` population), `resetAfterReplace()` (:158).
  232. - `src/MPackDB.js`: `_indexedStream()` (:703, exact-value branch), `refresh()` /
  233. `_reopenAfterExternalReplace()` (:772/:798).

Branches

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