brainy/docs/concepts/multi-process.md
David Snelling 35b9d7ef43 refactor(8.0)!: remove orphaned zero-config subsystem + dead cloud/progressive-init storage vestige
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.
2026-06-15 11:11:21 -07:00

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`