The old config-generation subsystem (src/config/ + autoConfiguration.ts) was
superseded during the 8.0 rework and never wired into init(): it emitted settings
for a partitioning subsystem that no longer exists and probed deleted cloud env
vars. The live zero-config path is inline — recall preset → HNSW knobs, storage
auto-detect, auto persistMode, container-memory-aware cache sizing.
The storage progressive-init / cloud-detection cluster was equally dead after the
cloud adapters were dropped: isCloudStorage() is permanently false (no overriders),
scheduleBackgroundInit/runBackgroundInit were never called (the latter an empty
body), initMode was never assigned, and Brainy.isFullyInitialized()/
awaitBackgroundInit() were always-trivial with zero callers. scheduleCountPersist()
collapses to its only-ever-taken immediate write-through path.
Removed:
- src/config/{index,zeroConfig,storageAutoConfig,modelAutoConfig,sharedConfigManager}.ts
- src/utils/autoConfiguration.ts + the inert BrainyZeroConfig export
- Brainy.isFullyInitialized()/awaitBackgroundInit() (+ BrainyInterface decls)
- InitMode type, isCloudStorage/detectCloudEnvironment/resolveInitMode,
scheduleBackgroundInit/runBackgroundInit/ensureValidatedForWrite and their state
- Dead cloud env-var probes (K_SERVICE/K_REVISION/AWS_LAMBDA_FUNCTION_NAME/
FUNCTIONS_TARGET/AZURE_FUNCTIONS_ENVIRONMENT)
Kept (verified live): production-detection logging (environment.ts), container-
memory cache sizing (memoryDetection/paramValidation), on-disk hash bucketing
(sharding.ts).
Docs: scrubbed deleted-subsystem references (JS quantization knobs, cloud/OPFS
adapters, partitioning, old zero-config API) across 14 files; deleted two wholly-
obsolete feature docs (complete-feature-list, v3-features); rewrote
architecture/zero-config for 8.0.
~3,700 LOC removed. Build clean; 1392 unit + 24 db-mvcc green.
153 lines
5.5 KiB
Markdown
153 lines
5.5 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 Cortex?
|
|
|
|
Brainy + Cortex compose cleanly under this model:
|
|
|
|
- Cortex stores its column-index segments inside the same `rootDir` (under
|
|
`indexes/_column_index/{field}/`).
|
|
- Segments (`*.cidx` files) are **immutable** once written. Cortex 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 Cortex segments alongside a live writer
|
|
without coordination. The single Brainy writer lock at
|
|
`<rootDir>/locks/_writer.lock` covers Cortex too, because Cortex 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`, `SIGINT`, and `beforeExit` also
|
|
release the lock so a container restart doesn't strand the directory.
|
|
|
|
## 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', rootDirectory: '/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', rootDirectory: '/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 Cortex 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)
|
|
- Cortex columnar storage — see `node_modules/@soulcraft/cortex/README.md`
|