Skip to content

fix: add close() so a file-backed db can be released deterministically - #1

Merged
adityamukho merged 1 commit into
mainfrom
fix-add-close-for-deterministic-release
Aug 10, 2026
Merged

fix: add close() so a file-backed db can be released deterministically#1
adityamukho merged 1 commit into
mainfrom
fix-add-close-for-deterministic-release

Conversation

@adityamukho

Copy link
Copy Markdown
Contributor

Fixes the Node.js CI failure on main after the v1.2.2 cascade — file-backed roundtrip broke on all four platforms.

Why it broke

minigraf 1.2.2 (project-minigraf/minigraf#304) refuses a second handle on a file this process already has open. Two handles each cache their own header.page_count, allocate pages from it, and bounds-check reads against it, so they diverge and corrupt the file — that was the source of the intermittent Page N out of bounds. Refusing is correct.

The problem is that this binding had no way to release a handle. The class exposed only constructor, inMemory, execute and checkpoint. Every other binding already has an escape hatch:

binding release mechanism
Python, Java, Swift (UniFFI) destroy()
C minigraf_close
Node (napi-rs) none

So the existing test was not doing anything wrong:

{
  const db = new MiniGrafDb(dbPath)
  db.execute('(transact [[:bob :name "Bob"]])')
  db.checkpoint()
}                                  // correct in a refcounted host

const db2 = new MiniGrafDb(dbPath) // throws: first handle is still alive

Leaving the block makes db unreachable but frees nothing — V8 collects whenever it likes, which here is well after the reopen. In Python or Rust scope exit releases the handle; in JavaScript it cannot. The consequence was that Node users could not reopen a .graph file in one process at all, with no workaround available.

For contrast, minigraf-java's equivalent test passes precisely because it calls db.destroy() between the two opens.

The fix

close() drops the inner Minigraf, running its Drop impl (best-effort checkpoint, then release of the sidecar .graph.lock). inner becomes an Option so that is representable. It is idempotent, and execute() / checkpoint() throw database is closed afterwards.

Tests

file-backed roundtrip now closes before reopening. Removing that single call reproduces the CI failure exactly — verified locally — so close() is what fixes it rather than something incidental:

✖ file-backed roundtrip
  Error: Database is already open in this process (lock file: /tmp/…graph.lock,
  holder PID: 58474 == our own). …

Three new cases: reopening without close throws, close is idempotent, and use-after-close throws. Full suite 6/6 locally against minigraf 1.2.2 from crates.io; cargo clippy -D warnings and cargo fmt --check clean.

Note on index.js

Left untouched on purpose. Regenerating it locally produces ~200 lines of churn unrelated to this change (node:fsfs, optional chaining stripped, a stale 1.1.1 version string baked into the mismatch check) because my @napi-rs/cli minor differs from CI's. index.d.ts is included since its diff is purely the new close() and doc comments. CI regenerates both during its own build.

Follow-up, not in this PR

minigraf@1.2.2 is already published to npm — the Release workflow succeeded before CI failed — so the version on the registry has this gap. It needs a 1.2.3 once this lands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QRGNkzJc5FdfpbzAh4ZZzK

minigraf 1.2.2 refuses a second handle on a file this process already has open
(project-minigraf/minigraf#304): two handles each cache their own
header.page_count and corrupt each other. Every other binding already has a way
to release a handle on demand -- UniFFI exposes destroy() (Python, Java,
Swift), the C API exposes minigraf_close -- but this one exposed only the
constructor, inMemory, execute and checkpoint.

That left Node with no way to reopen a file at all. JavaScript has no
deterministic destructor: letting the handle go out of scope makes it
unreachable but frees nothing until V8 next collects, which is arbitrarily
later or never. `file-backed roundtrip` scopes its writer in a block and then
reopens, which is correct in a refcounted host and impossible here -- it broke
on all four platforms as soon as 1.2.2 landed.

close() drops the inner Minigraf, running its Drop impl (best-effort
checkpoint, then release of the sidecar lock). It is idempotent; execute() and
checkpoint() throw "database is closed" afterwards. inner becomes an Option to
make that representable.

Tests: the roundtrip now closes before reopening -- removing that one call
reproduces the CI failure exactly, so it is the fix and not incidental. Adds
coverage for reopen-without-close throwing, close being idempotent, and
use-after-close throwing.

index.js is deliberately left alone: regenerating it locally produces ~200
lines of churn unrelated to this change (node:fs -> fs, optional chaining
removed, a stale version string) because the local @napi-rs/cli minor differs
from CI's. CI regenerates it during its own build.

Refs project-minigraf/minigraf#304

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRGNkzJc5FdfpbzAh4ZZzK
@adityamukho
adityamukho merged commit 9a0159f into main Aug 10, 2026
4 checks passed
adityamukho added a commit to project-minigraf/minigraf that referenced this pull request Aug 10, 2026
No code changes in the core crate since v1.2.2. This release exists so the
language bindings have a version to republish on.

v1.2.2 made a second handle on an already-open file an error (#304). The UniFFI
bindings and the C API could already release a handle on demand -- destroy()
and minigraf_close -- but the Node binding could not, and JavaScript has no
deterministic destructor, so reopening a .graph file in one process became
impossible there. minigraf-node added close()
(project-minigraf/minigraf-node#1) and needs a cascade to ship it. Binding
versions track the core version, so that has to be driven from here.

Doc sync precedes the bump as usual. Test counts are unchanged at 998; only the
version stamps moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRGNkzJc5FdfpbzAh4ZZzK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant