Some checks failed
CI / Node 22 (push) Successful in 12m30s
CI / Node 24 (push) Successful in 12m23s
CI / Bun (latest) (push) Successful in 12m36s
CI / Integration + conformance (Node 22) (push) Failing after 17m33s
10.4.11 gave shutdown one owner and one path — close() — and wired all three
process listeners to it. That is right for SIGTERM and SIGINT. It is wrong for
'beforeExit', which Node emits whenever the event loop has no REF'd work left:
not when the process is ending, and with no signal involved. A healthy script
reaches that state routinely, because this engine unref's its idle and cadence
timers ("an idle brain costs nothing"), so a script awaiting anything those
timers drive is, for that instant, a process with no ref'd work and an open
brain.
MEASURED on the 11.1 rehearsal lane against a copy of a real store: after the
heal phase the log printed "Shutdown signal received - flushing pending
data..." and "Flushed successfully (1 instance)" with no signal ever sent, and
the script's very next add() threw "Brainy instance is not initialized: it was
closed via close(). Create a new instance." The engine had closed a live brain
out from under a running script.
The beforeExit listener now runs its own pass, which closes nothing,
deregisters nothing, releases no writer lock, and never force-exits: it runs
flush() — the engine's own non-closing durability door — on each live brain and
leaves every one of them open and usable. flush() persists derived state only
(count ledger, projections, generation counter, aggregation, entity-tree
stamp); the clean-shutdown marker is generationStore.close()'s word about
itself, reached only from close(). Running it concurrently with live writes is
the engine's ordinary steady state — noteWriteForPersistence() kicks the same
call off an unref'd timer on every busy brain — and it is single-flight, so
there is no new race. A throw is reported per instance and the pass continues:
canonical data is durable at ack via the fact log, so a failed derived-state
flush costs the next open a rebuild, never the caller their brain.
The listener is no longer self-deregistered. It does not need to be: a flush on
a clean brain schedules no I/O, so the emit after it does no event-loop work
and the process exits on its own. A one-shot listener spent on a spurious
mid-script drain would leave the genuine end-of-script drain with nothing. The
drained-loop notice is printed once per registration cycle, because a
console.log to a pipe is itself event-loop work.
exitIfSoleShutdownOwner() stays on the signal path alone, and its contract now
says so: beforeExit suppresses no default behaviour, so exiting from it would
end a live script at code 0 mid-work.
THE NAMED TRADE: a script that opens a brain and never closes it now exits with
its writer lock still on disk and no clean-shutdown marker, so its next open
overwrites a stale lock and folds the log. That is the honest cost of never
closing, and the narration names the cure. Closing a live brain to avoid it was
the worse half of the trade.
Pins: tests/integration/beforeexit-never-closes.test.ts — a script that drains
the loop with a brain open keeps a working brain (add + find succeed, the lock
is still held, the process still exits 0), the pass flushed and wrote neither
of close()'s markers, and repeated drains are idempotent. Both cases fail on
10.4.11's handler with the exact production shape ("add() after the drain
failed", "pass 1 closed the brain"). Re-run green: shutdown-single-owner,
writer-lock-clean-close, idle-costs-nothing, shutdown-hooks-lifecycle.
docs/concepts/multi-process.md no longer claims beforeExit releases the lock.
160 lines
5.9 KiB
Markdown
160 lines
5.9 KiB
Markdown
---
|
|
title: Multi-Process Model
|
|
slug: concepts/multi-process
|
|
public: true
|
|
category: concepts
|
|
template: concept
|
|
order: 5
|
|
description: How Brainy coordinates a single writer with any number of readers on a filesystem data directory — and how to safely inspect a live store.
|
|
next:
|
|
- guides/inspection
|
|
---
|
|
|
|
# Multi-Process Model
|
|
|
|
Brainy is a **single-writer, many-reader** database when backed by filesystem
|
|
storage. This page explains the model, the guarantees, and the safe ways to
|
|
inspect a live store from a second process.
|
|
|
|
## The rule
|
|
|
|
For one data directory:
|
|
|
|
- **One writer** at a time. The writer acquires an exclusive lock on the
|
|
directory at `init()` and releases it on `close()`.
|
|
- **Any number of readers**, concurrent with each other and with the writer.
|
|
Readers open via `Brainy.openReadOnly()` — they never touch the writer
|
|
lock.
|
|
|
|
Any attempt to open a second writer on the same directory throws:
|
|
|
|
```
|
|
BrainyError: Another writer holds this Brainy directory.
|
|
PID: 1774431 on host app-host-1
|
|
Started: 2026-05-15T14:22:11Z
|
|
Heartbeat: 2026-05-15T14:22:34Z
|
|
Version: 7.21.0
|
|
Directory: /data/brain
|
|
```
|
|
|
|
This is intentional. Two writers sharing a directory would silently corrupt
|
|
in-memory indexes and produce wrong query results — the worst possible default
|
|
for an operations tool.
|
|
|
|
## Why a lock?
|
|
|
|
Brainy keeps its primary indexes (HNSW, metadata, graph adjacency) in memory.
|
|
On disk, those indexes are persisted incrementally as writes flush. A second
|
|
process opening the same directory:
|
|
|
|
- Loads the *persisted* state into a fresh in-memory copy.
|
|
- Has no awareness of writes the first process buffered but hasn't flushed.
|
|
- Will overwrite the persisted state on its own next flush, racing the first
|
|
process and corrupting whichever wins.
|
|
|
|
The fix is the lock: refuse to open a second writer. SQLite has done the same
|
|
since the late 1990s (`SQLITE_BUSY`).
|
|
|
|
## What about Cor?
|
|
|
|
Brainy + Cor compose cleanly under this model:
|
|
|
|
- Cor stores its column-index segments inside the same `rootDir` (under
|
|
`indexes/_column_index/{field}/`).
|
|
- Segments (`*.cidx` files) are **immutable** once written. Cor mmaps them
|
|
read-only.
|
|
- The `MANIFEST.json` per field is updated via atomic rename — readers see
|
|
either the old or new manifest, never a torn file.
|
|
|
|
A reader process can safely mmap Cor segments alongside a live writer
|
|
without coordination. The single Brainy writer lock at
|
|
`<rootDir>/locks/_writer.lock` covers Cor too, because Cor segment
|
|
writes happen on the writer's side.
|
|
|
|
## Stale-lock detection
|
|
|
|
If a writer crashes or is forcibly killed, its lock file is left behind. To
|
|
avoid a permanently-jammed directory, Brainy treats a lock as stale when:
|
|
|
|
1. The recorded `hostname` equals the current host (cross-host PID checks
|
|
are unsafe), AND
|
|
2. The recorded `pid` is no longer alive (`process.kill(pid, 0)` returns
|
|
`ESRCH`), OR the `lastHeartbeat` field is older than 60 seconds.
|
|
|
|
A live writer rewrites `lastHeartbeat` every 10 seconds, so a hung writer
|
|
that's missed several heartbeats is treated as dead. Stale locks are
|
|
overwritten with a warning.
|
|
|
|
If stale detection cannot prove the existing lock is dead — for example, a
|
|
crashed writer on a different host writing to a shared filesystem — pass
|
|
`{ force: true }` to override. A warning is logged either way.
|
|
|
|
## Heartbeat and shutdown
|
|
|
|
The heartbeat interval rewrites the lock file every 10 seconds. The timer
|
|
is unref'd, so it does not keep the event loop alive on its own.
|
|
|
|
On normal shutdown the writer releases the lock in `close()`. The shutdown
|
|
hooks Brainy registers for `SIGTERM` and `SIGINT` close every live brain by
|
|
that same `close()`, so a container restart doesn't strand the directory.
|
|
|
|
`beforeExit` is not one of them. Node emits it whenever the event loop has
|
|
no ref'd work left — a state a healthy script reaches routinely, because
|
|
Brainy's own idle and cadence timers are unref'd — and a drained event loop
|
|
is not a shutdown. That hook only persists derived state with a non-closing
|
|
`flush()`: it closes nothing, releases no lock, and leaves every brain open
|
|
and usable. If you want a shutdown, call `close()` or send `SIGTERM`.
|
|
|
|
## How to inspect a live writer
|
|
|
|
Use `Brainy.openReadOnly()`. It does not acquire the writer lock, so it
|
|
coexists with whatever the writer is doing:
|
|
|
|
```typescript
|
|
const reader = await Brainy.openReadOnly({
|
|
storage: { type: 'filesystem', path: '/data/brain' }
|
|
})
|
|
|
|
const stats = await reader.stats()
|
|
const bookings = await reader.find({ where: { entityType: 'booking' } })
|
|
|
|
await reader.close()
|
|
```
|
|
|
|
What the reader sees reflects the writer's most recent **flush** to disk. If
|
|
you need fresher state, ask the writer to flush before opening:
|
|
|
|
```typescript
|
|
const reader = await Brainy.openReadOnly({
|
|
storage: { type: 'filesystem', path: '/data/brain' }
|
|
})
|
|
|
|
const acked = await reader.requestFlush({ timeoutMs: 5000 })
|
|
if (!acked) {
|
|
console.warn('Writer did not respond; results reflect last natural flush.')
|
|
}
|
|
|
|
const fresh = await reader.find({ where: { entityType: 'booking' } })
|
|
```
|
|
|
|
The CLI `brainy inspect` subcommands all do this for you by default
|
|
(`--no-fresh` to opt out).
|
|
|
|
## What's not enforced (yet)
|
|
|
|
- **Non-filesystem backends** are out of scope in 8.0, which ships only the
|
|
filesystem and memory adapters. A custom `BaseStorage` subclass that is not
|
|
filesystem-backed does not enforce multi-process locking by default: two
|
|
processes can both succeed at `init()` in writer mode and clobber each
|
|
other's writes. A best-effort warning is logged in writer mode against a
|
|
non-filesystem backend.
|
|
- **Long-running readers** do not automatically pick up new Cor segments
|
|
the writer publishes. One-shot inspector calls re-open the store and see
|
|
fresh segments; a reader that stays open for hours sees its column store
|
|
as-of the time it opened.
|
|
|
|
## Reading material
|
|
|
|
- `Brainy.openReadOnly()` — [API reference](../api/brainy.md)
|
|
- `brainy inspect` — [inspection guide](../guides/inspection.md)
|
|
- Cor columnar storage — see `node_modules/@soulcraft/cor/README.md`
|