brainy/src/storage/adapters/fileSystemStorage.ts
David Snelling a2f4f6a550 fix: non-destructive, crash-resumable restore
restore() removed the entire live brain directory and then fs.cp'd the
snapshot in, so any copy failure left the store destroyed with only a partial
copy. The sharpest edge: fs.cp materialized the holes of sparse mmap blob
files, so a snapshot that fits on disk could balloon and ENOSPC mid-copy — the
recovery tool destroying the brain it was asked to recover.

Rewrite restoreFromDirectory as stage → verify → atomic swap:
- Copy the snapshot into a _restore_staging area BEFORE touching live data,
  sparse-aware (copyTreeSparse/copyFileSparse skip all-zero 4 MiB chunks, so a
  mostly-hole store restores at its true allocated size, not its apparent size).
- On any copy failure (ENOSPC included) remove only the half-written staging
  area and throw — the live store is left exactly as it was.
- Only after the copy succeeds, fsync a completion marker naming the staged
  entries, then swapStagedRestoreIn(): an idempotent per-entry
  rm-old + rename-staged-in (same-filesystem renames that cannot ENOSPC),
  removing stale live entries the snapshot lacks.
- completeInterruptedRestore(), wired into init() right after the root dir is
  ensured, finishes a crash mid-swap FORWARD (resume a committed swap) or
  discards an uncommitted staging area (live still authoritative).

_restore_staging is excluded from snapshots. persist() (hard-link snapshot)
was already safe and is unchanged; no public API change.

Regression (tests/integration/restore-nondestructive.test.ts): a forced copy
failure leaves live data fully intact and cleans staging; a normal restore
round-trips to the snapshot; an interrupted-but-committed restore completes on
reopen; an uncommitted staging area is discarded on open; the sparse copy is
byte-identical with allocated blocks far below apparent size.
2026-07-12 09:10:35 -07:00

2514 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* File System Storage Adapter
* File system storage adapter for Node.js environments
*/
import {
HNSWNoun,
HNSWNounWithMetadata,
StatisticsData,
NounType
} from '../../coreTypes.js'
import {
BaseStorage,
StorageBatchConfig,
SYSTEM_DIR,
STATISTICS_KEY,
WriterLockInfo
} from '../baseStorage.js'
import { getBrainyVersion } from '../../utils/index.js'
// Node.js modules - dynamically imported to avoid issues in browser environments
let fs: any
let path: any
let zlib: any
let moduleLoadingPromise: Promise<void> | null = null
// Try to load Node.js modules
try {
// Using dynamic imports to avoid issues in browser environments
const fsPromise = import('node:fs')
const pathPromise = import('node:path')
const zlibPromise = import('node:zlib')
moduleLoadingPromise = Promise.all([fsPromise, pathPromise, zlibPromise])
.then(([fsModule, pathModule, zlibModule]) => {
fs = fsModule
path = pathModule.default
zlib = zlibModule
})
.catch((error) => {
console.error('Failed to load Node.js modules:', error)
throw error
})
} catch (error) {
console.error(
'FileSystemStorage: Failed to load Node.js modules. This adapter is not supported in this environment.',
error
)
}
/**
* File system storage adapter for Node.js environments
* Uses the file system to store data in the specified directory structure
*
* Type-aware storage now built into BaseStorage
* - Removed 10 *_internal method overrides (now inherit from BaseStorage's type-first implementation)
* - Removed 2 pagination method overrides (getNounsWithPagination, getVerbsWithPagination)
* - Updated HNSW methods to use BaseStorage's getNoun/saveNoun (type-first paths)
* - All operations now use type-first paths: entities/nouns/{type}/vectors/{shard}/{id}.json
*/
export class FileSystemStorage extends BaseStorage {
// FileSystem-specific count persistence
private countsFilePath?: string // Will be set after init
// Fixed sharding configuration for optimal balance of simplicity and performance
// Single-level sharding (depth=1) provides excellent performance for 1-2.5M entities
// Structure: nouns/ab/uuid.json where 'ab' = first 2 hex chars of UUID
// - 256 shard directories (00-ff)
// - Handles 2.5M+ entities with < 10K files per shard
// - Eliminates dynamic depth changes that cause path mismatch bugs
private readonly SHARDING_DEPTH = 1 as const
protected rootDir: string
private nounsDir!: string
private verbsDir!: string
private metadataDir!: string
private nounMetadataDir!: string
private verbMetadataDir!: string
private indexDir!: string // Legacy - for backward compatibility
private systemDir!: string
private lockDir!: string
// Root for raw binary blobs (`<rootDir>/_blobs`). Blobs are stored verbatim
// (no JSON envelope, no compression) so native code can mmap them directly via
// getBinaryBlobPath(). Set in init() once the path module is loaded.
private blobsDir!: string
private activeLocks: Set<string> = new Set()
private lockTimers: Map<string, NodeJS.Timeout> = new Map() // Track timers for cleanup
private allTimers: Set<NodeJS.Timeout> = new Set() // Track all timers for cleanup
// Writer-lock state. The writer lock at `locks/_writer.lock` is acquired
// at Brainy.init() in writer mode and released at close(). A heartbeat
// timer rewrites the lock every 10s so stale-lock detection can tell a dead
// writer from a slow one. The constant name matches the file path used.
private static readonly WRITER_LOCK_FILE = '_writer.lock'
private static readonly WRITER_HEARTBEAT_MS = 10_000
private static readonly WRITER_STALE_THRESHOLD_MS = 60_000
private writerLockHeartbeat?: NodeJS.Timeout
private writerLockInfo?: WriterLockInfo
// Flush-request RPC state. The writer polls `locks/_flush_requests/` for
// new `.req` files and emits `.ack` files in `locks/_flush_responses/` after
// flushing. Inspectors call `requestFlushOverFilesystem` to drop a request
// and wait for the ack. Polling interval is short enough to feel synchronous
// for operator workflows but doesn't pressure the FS.
private static readonly FLUSH_REQUEST_DIR = '_flush_requests'
private static readonly FLUSH_RESPONSE_DIR = '_flush_responses'
private static readonly FLUSH_WATCH_INTERVAL_MS = 500
private static readonly FLUSH_POLL_INTERVAL_MS = 100
private static readonly FLUSH_REQUEST_TTL_MS = 60_000
private flushWatcherInterval?: NodeJS.Timeout
private flushWatcherInFlight = false
private flushWatcherOnRequest?: () => Promise<void>
// CRITICAL FIX: Mutex locks for HNSW concurrency control
// Prevents read-modify-write races during concurrent neighbor updates at scale (1000+ ops)
// Matches MemoryStorage and OPFSStorage behavior (tested in production)
private hnswLocks = new Map<string, Promise<void>>()
// Compression configuration
private compressionEnabled: boolean = true // Enable gzip compression by default for 60-80% disk savings
private compressionLevel: number = 6 // zlib compression level (1-9, default: 6 = balanced)
// Transaction durability barrier (see GenerationStorage.beginWriteBarrier).
// Non-null ONLY between beginWriteBarrier() and flushWriteBarrier(). Because
// canonical writes are tmp+rename (durable only in the page cache until an
// fsync), the generation store fsyncs everything a transaction wrote before
// it advances the generation counter. `writeBarrierPaths` collects the
// root-relative object paths written; `writeBarrierDeleteDirs` the parent
// dirs of deleted objects (an unlink is durable only once its directory is
// fsync'd). Both are null outside a transaction, so the tracking `add`s below
// are free on the single-op and non-transactional write paths.
private writeBarrierPaths: Set<string> | null = null
private writeBarrierDeleteDirs: Set<string> | null = null
/**
* Initialize the storage adapter
* @param rootDirectory The root directory for storage
* @param options Optional configuration
*/
constructor(
rootDirectory: string,
options?: {
compression?: boolean // Enable gzip compression (default: true)
compressionLevel?: number // Compression level 1-9 (default: 6)
}
) {
super()
this.rootDir = rootDirectory
// Configure compression
if (options?.compression !== undefined) {
this.compressionEnabled = options.compression
}
if (options?.compressionLevel !== undefined) {
this.compressionLevel = Math.min(9, Math.max(1, options.compressionLevel))
}
// Defer path operations until init() when path module is guaranteed to be loaded
}
/**
* Get FileSystem-optimized batch configuration
*
* File system storage is I/O bound but not rate limited:
* - Large batch sizes (500 items)
* - No delays needed (0ms)
* - Moderate concurrency (100 operations) - limited by I/O threads
* - Parallel processing supported
*
* @returns FileSystem-optimized batch configuration
*/
public override getBatchConfig(): StorageBatchConfig {
return {
maxBatchSize: 500,
batchDelayMs: 0,
maxConcurrent: 100,
supportsParallelWrites: true, // Filesystem handles parallel I/O
rateLimit: {
operationsPerSecond: 5000, // Depends on disk speed
burstCapacity: 2000
}
}
}
/**
* Initialize the storage adapter
*/
public override async init(): Promise<void> {
if (this.isInitialized) {
return
}
// Wait for module loading to complete
if (moduleLoadingPromise) {
try {
await moduleLoadingPromise
} catch (error) {
throw new Error(
'FileSystemStorage requires a Node.js environment, but `fs` and `path` modules could not be loaded.'
)
}
}
// Check if Node.js modules are available
if (!fs || !path) {
throw new Error(
'FileSystemStorage requires a Node.js environment, but `fs` and `path` modules could not be loaded.'
)
}
try {
// Initialize directory paths now that path module is loaded
// Clean directory structure
this.nounsDir = path.join(this.rootDir, 'entities/nouns/hnsw')
this.verbsDir = path.join(this.rootDir, 'entities/verbs/hnsw')
this.metadataDir = path.join(this.rootDir, 'entities/nouns/metadata') // Legacy reference
this.nounMetadataDir = path.join(this.rootDir, 'entities/nouns/metadata')
this.verbMetadataDir = path.join(this.rootDir, 'entities/verbs/metadata')
this.indexDir = path.join(this.rootDir, 'indexes')
this.systemDir = path.join(this.rootDir, SYSTEM_DIR)
this.lockDir = path.join(this.rootDir, 'locks')
this.blobsDir = path.join(this.rootDir, '_blobs')
// Create the root directory if it doesn't exist
await this.ensureDirectoryExists(this.rootDir)
// Finish any restore interrupted by a crash (resume the staged swap, or
// discard an uncommitted staging area) BEFORE counts/derived state load,
// so the rest of startup sees the completed store.
await this.completeInterruptedRestore()
// Create the nouns directory if it doesn't exist
await this.ensureDirectoryExists(this.nounsDir)
// Create the verbs directory if it doesn't exist
await this.ensureDirectoryExists(this.verbsDir)
// Create the metadata directory if it doesn't exist
await this.ensureDirectoryExists(this.metadataDir)
// Create the noun metadata directory if it doesn't exist
await this.ensureDirectoryExists(this.nounMetadataDir)
// Create the verb metadata directory if it doesn't exist
await this.ensureDirectoryExists(this.verbMetadataDir)
// Create both directories for backward compatibility
await this.ensureDirectoryExists(this.systemDir)
// Only create legacy directory if it exists (don't create new legacy dirs)
if (await this.directoryExists(this.indexDir)) {
await this.ensureDirectoryExists(this.indexDir)
}
// Create the locks directory if it doesn't exist
await this.ensureDirectoryExists(this.lockDir)
// Create the binary blobs directory if it doesn't exist
await this.ensureDirectoryExists(this.blobsDir)
// Initialize count management
this.countsFilePath = path.join(this.systemDir, 'counts.json')
await this.initializeCounts()
// Boot log: new-vs-established, decided from the canonical layout the
// database actually reads and writes (`entities/nouns/<shard>/<id>/`)
// plus the known noun count. The legacy hnsw sharding-depth probe and
// its depth-migration machinery are gone: the 8.0 write path never
// populated the directory they inspected, so the probe concluded "new
// installation" for every store on every boot and the migration branch
// was unreachable.
const established =
this.totalNounCount > 0 || (await this.hasCanonicalEntities())
console.log(
established
? `📁 Using depth ${this.SHARDING_DEPTH} sharding (${this.totalNounCount} entities)`
: `📁 New installation: using depth ${this.SHARDING_DEPTH} sharding (optimal for 1-2.5M entities)`
)
// Initialize GraphAdjacencyIndex and type statistics
await super.init()
} catch (error) {
console.error('Error initializing FileSystemStorage:', error)
throw error
}
}
/**
* Check if a directory exists
*/
private async directoryExists(dirPath: string): Promise<boolean> {
try {
const stats = await fs.promises.stat(dirPath)
return stats.isDirectory()
} catch (error) {
return false
}
}
/**
* Ensure a directory exists, creating it if necessary
*/
private async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
await fs.promises.mkdir(dirPath, { recursive: true })
} catch (error: any) {
// Ignore EEXIST error, which means the directory already exists
if (error.code !== 'EEXIST') {
throw error
}
}
}
/**
* Primitive operation: Write object to path
* All metadata operations use this internally via base class routing
* Supports gzip compression for 60-80% disk savings
* CRITICAL FIX: Added atomic write pattern to prevent file corruption during concurrent imports
*/
protected async writeObjectToPath(pathStr: string, data: any): Promise<void> {
await this.ensureInitialized()
const fullPath = path.join(this.rootDir, pathStr)
await this.ensureDirectoryExists(path.dirname(fullPath))
if (this.compressionEnabled) {
// Write compressed data with .gz extension using atomic pattern
const compressedPath = `${fullPath}.gz`
const tempPath = `${compressedPath}.tmp.${Date.now()}.${Math.random().toString(36).substring(2)}`
try {
// ATOMIC WRITE SEQUENCE:
// 1. Compress and write to temp file
const jsonString = JSON.stringify(data, null, 2)
const compressed = await new Promise<Buffer>((resolve, reject) => {
zlib.gzip(Buffer.from(jsonString, 'utf-8'), { level: this.compressionLevel }, (err: any, result: Buffer) => {
if (err) reject(err)
else resolve(result)
})
})
await fs.promises.writeFile(tempPath, compressed)
// 2. Atomic rename temp → final (crash-safe, prevents truncation during concurrent writes)
await fs.promises.rename(tempPath, compressedPath)
} catch (error: any) {
// Clean up temp file on any error
try {
await fs.promises.unlink(tempPath)
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error
}
// Clean up uncompressed file if it exists (migration from uncompressed)
try {
await fs.promises.unlink(fullPath)
} catch (error: any) {
// Ignore if file doesn't exist
if (error.code !== 'ENOENT') {
console.warn(`Failed to remove uncompressed file ${fullPath}:`, error)
}
}
} else {
// Write uncompressed data using atomic pattern
const tempPath = `${fullPath}.tmp.${Date.now()}.${Math.random().toString(36).substring(2)}`
try {
// ATOMIC WRITE SEQUENCE:
// 1. Write to temp file
await fs.promises.writeFile(tempPath, JSON.stringify(data, null, 2))
// 2. Atomic rename temp → final (crash-safe, prevents truncation during concurrent writes)
await fs.promises.rename(tempPath, fullPath)
} catch (error: any) {
// Clean up temp file on any error
try {
await fs.promises.unlink(tempPath)
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error
}
}
// Transaction durability barrier: record the write so the generation store
// can fsync it before advancing the counter. Reached only on a successful
// rename; the logical (non-`.gz`) path is stored — flushWriteBarrier's
// syncRawObjects resolves the compressed variant.
this.writeBarrierPaths?.add(pathStr)
}
/**
* Primitive operation: Read object from path
* All metadata operations use this internally via base class routing
* Enhanced error handling for corrupted metadata files (Bug #3 mitigation)
* Supports reading both compressed (.gz) and uncompressed files for backward compatibility
*/
protected async readObjectFromPath(pathStr: string): Promise<any | null> {
await this.ensureInitialized()
const fullPath = path.join(this.rootDir, pathStr)
const compressedPath = `${fullPath}.gz`
// Try reading compressed file first (if compression is enabled or file exists)
try {
const compressedData = await fs.promises.readFile(compressedPath)
const decompressed = await new Promise<Buffer>((resolve, reject) => {
zlib.gunzip(compressedData, (err: any, result: Buffer) => {
if (err) reject(err)
else resolve(result)
})
})
return JSON.parse(decompressed.toString('utf-8'))
} catch (error: any) {
// If compressed file doesn't exist, fall back to uncompressed
if (error.code !== 'ENOENT') {
console.warn(`Failed to read compressed file ${compressedPath}:`, error)
}
}
// Fall back to reading uncompressed file (for backward compatibility)
try {
const data = await fs.promises.readFile(fullPath, 'utf-8')
return JSON.parse(data)
} catch (error: any) {
if (error.code === 'ENOENT') {
return null
}
// Enhanced error handling for corrupted JSON files (race condition from Bug #3)
if (error instanceof SyntaxError || error.name === 'SyntaxError') {
console.warn(
`⚠️ Corrupted metadata file detected: ${pathStr}\n` +
` This may be caused by concurrent writes during import.\n` +
` Gracefully skipping this entry. File may be repaired on next write.`
)
return null
}
console.error(`Error reading object from ${pathStr}:`, error)
return null
}
}
/**
* Primitive operation: Delete object from path
* All metadata operations use this internally via base class routing
* Deletes both compressed and uncompressed versions (for cleanup)
*/
protected async deleteObjectFromPath(pathStr: string): Promise<void> {
await this.ensureInitialized()
const fullPath = path.join(this.rootDir, pathStr)
const compressedPath = `${fullPath}.gz`
// Try deleting both compressed and uncompressed files (for cleanup during migration)
let deletedCount = 0
// Delete compressed file
try {
await fs.promises.unlink(compressedPath)
deletedCount++
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.warn(`Error deleting compressed file ${compressedPath}:`, error)
}
}
// Delete uncompressed file
try {
await fs.promises.unlink(fullPath)
deletedCount++
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error deleting uncompressed file ${pathStr}:`, error)
throw error
}
}
// If neither file existed, it's not an error (already deleted)
if (deletedCount === 0) {
// File doesn't exist - this is fine
}
// Transaction durability barrier: an unlink is durable only once its parent
// directory is fsync'd. Record the dir (root-relative; '.' for a top-level
// object) so flushWriteBarrier can sync it before the counter advances.
this.writeBarrierDeleteDirs?.add(path.dirname(pathStr))
}
/**
* Primitive operation: List objects under path prefix
* All metadata operations use this internally via base class routing
* Handles both .json and .json.gz files, normalizes paths
*/
protected async listObjectsUnderPath(prefix: string): Promise<string[]> {
await this.ensureInitialized()
const fullPath = path.join(this.rootDir, prefix)
const paths: string[] = []
const seen = new Set<string>() // Track files to avoid duplicates (both .json and .json.gz)
try {
const entries = await fs.promises.readdir(fullPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile()) {
// Handle multiple compression formats for broad compatibility
// - .json.gz: Standard entity/metadata files (JSON compressed)
// - .gz: Raw compressed payloads (e.g. blob-store binary values)
// - .json: Uncompressed JSON files
if (entry.name.endsWith('.json.gz')) {
// Strip .gz extension and add the .json path
const normalizedName = entry.name.slice(0, -3) // Remove .gz
const normalizedPath = path.join(prefix, normalizedName)
if (!seen.has(normalizedPath)) {
paths.push(normalizedPath)
seen.add(normalizedPath)
}
} else if (entry.name.endsWith('.gz')) {
// Raw payloads stored as .gz (not .json.gz)
// Strip .gz extension and return path
const normalizedName = entry.name.slice(0, -3) // Remove .gz
const normalizedPath = path.join(prefix, normalizedName)
if (!seen.has(normalizedPath)) {
paths.push(normalizedPath)
seen.add(normalizedPath)
}
} else if (entry.name.endsWith('.json')) {
const filePath = path.join(prefix, entry.name)
if (!seen.has(filePath)) {
paths.push(filePath)
seen.add(filePath)
}
}
} else if (entry.isDirectory()) {
const subpath = path.join(prefix, entry.name)
const subdirPaths = await this.listObjectsUnderPath(subpath)
paths.push(...subdirPaths)
}
}
return paths.sort()
} catch (error: any) {
if (error.code === 'ENOENT') {
return []
}
throw error
}
}
// ===========================================================================
// Generational record layer (8.0 MVCC) — durability + snapshot primitives
// ===========================================================================
/**
* Storage-root-relative paths that are mutated **in place** (appended to)
* rather than replaced via atomic tmp+rename. `snapshotToDirectory()` must
* byte-copy these instead of hard-linking them: a hard link shares the
* inode, so a post-snapshot append to the live file would silently mutate
* the snapshot. Every other persisted file in this adapter is written via
* tmp+rename (objects, blobs, counts, locks are excluded entirely), which
* makes hard links safe — a rewrite swaps in a new inode and the snapshot
* keeps the old one.
*/
private static readonly SNAPSHOT_BYTE_COPY_PATHS = new Set<string>([
`${SYSTEM_DIR}/tx-log.jsonl`
])
/**
* Top-level directories whose EVERY file is mutated in place (not tmp+rename)
* and must therefore be byte-copied into a snapshot, not hard-linked — the
* directory analogue of {@link SNAPSHOT_BYTE_COPY_PATHS}.
*
* `_id_mapper` holds the native provider's shared mmap `BinaryIdMapper`, which
* `flush()` msyncs and a migration rebuild can truncate+re-inject IN PLACE
* (confirmed by the native side). A hard link shares the inode, so an in-place
* msync/truncate on the live file would reach through into the pre-upgrade
* backup — byte-copy keeps the snapshot a faithful, independent copy. It is
* bounded (the id map), not the large index files, so the copy cost is small.
*/
private static readonly SNAPSHOT_BYTE_COPY_DIRS = new Set<string>(['_id_mapper'])
/**
* Top-level directories excluded from snapshots: process-local lock state
* (writer lock, flush-request RPC files) must never travel with the data, and
* the restore staging area ({@link RESTORE_STAGING_DIR}) is transient scratch
* that must never be captured or restored.
*/
private static readonly SNAPSHOT_EXCLUDED_TOP_DIRS = new Set<string>(['locks', '_restore_staging'])
/**
* Transient top-level directory holding a restore-in-progress: the snapshot is
* fully copied here (sparse-aware) BEFORE any live data is touched, then an
* atomic per-entry swap moves it into place. Its presence + the completion
* marker let {@link completeInterruptedRestore} resume a crashed restore.
*/
private static readonly RESTORE_STAGING_DIR = '_restore_staging'
/**
* Written+fsync'd inside the staging dir ONLY after the whole snapshot has
* copied successfully. Its presence authorizes the swap (and its resume): a
* staging dir WITHOUT this marker is an interrupted copy — discardable debris,
* live data still authoritative.
*/
private static readonly RESTORE_MARKER = '.restore-manifest.json'
/** Chunk size for sparse-aware copying (holes are preserved at this grain). */
private static readonly SPARSE_CHUNK_BYTES = 4 * 1024 * 1024
/** A zero buffer the size of one sparse chunk, for all-zero (hole) detection. */
private static readonly SPARSE_ZERO_CHUNK = Buffer.alloc(4 * 1024 * 1024)
/**
* Remove every object under a storage-root-relative prefix — one recursive
* directory removal instead of the base class's list+delete loop.
*
* @param prefix - Storage-root-relative directory prefix to remove.
*/
public override async removeRawPrefix(prefix: string): Promise<void> {
await this.ensureInitialized()
await fs.promises.rm(path.join(this.rootDir, prefix), { recursive: true, force: true })
}
/**
* Durability barrier: `fsync` each listed object file (resolving the
* compressed `.gz` variant when present) and then the set of parent
* directories, so both the file contents and the rename directory entries
* are durable before the commit protocol proceeds. Paths whose file no
* longer exists are skipped (a later step may have replaced them).
*
* @param paths - Storage-root-relative object paths previously written.
*/
public override async syncRawObjects(paths: string[]): Promise<void> {
await this.ensureInitialized()
const parentDirs = new Set<string>()
for (const objectPath of paths) {
const fullPath = path.join(this.rootDir, objectPath)
for (const candidate of [`${fullPath}.gz`, fullPath]) {
let handle: any
try {
handle = await fs.promises.open(candidate, 'r')
} catch (error: any) {
if (error.code === 'ENOENT') continue
throw error
}
try {
await handle.sync()
} finally {
await handle.close()
}
parentDirs.add(path.dirname(fullPath))
break
}
}
for (const dir of parentDirs) {
let handle: any
try {
handle = await fs.promises.open(dir, 'r')
} catch {
continue // directory vanished or platform disallows opening dirs
}
try {
await handle.sync()
} catch {
// Some platforms (and some filesystems) reject directory fsync —
// file-level fsync above already covers the data itself.
} finally {
await handle.close()
}
}
}
/**
* Begin a transaction durability barrier: start recording every canonical
* object write and delete so {@link flushWriteBarrier} can fsync them before
* the generation counter advances. Resets unconditionally, discarding any
* tracking left by a transaction that aborted without flushing.
*
* @see GenerationStorage.beginWriteBarrier
*/
public beginWriteBarrier(): void {
this.writeBarrierPaths = new Set()
this.writeBarrierDeleteDirs = new Set()
}
/**
* Flush the transaction durability barrier: fsync every canonical write since
* {@link beginWriteBarrier} (file contents AND the rename directory entries,
* via {@link syncRawObjects}), then fsync the parent directory of every
* canonical delete so the unlinks are durable too. Clears the tracking. After
* this resolves, the transaction's entire canonical footprint is on disk, so
* the generation counter/manifest can be advanced without risking a
* counter-ahead-of-state torn store on a hard kill.
*
* @see GenerationStorage.flushWriteBarrier
*/
public async flushWriteBarrier(): Promise<void> {
const paths = this.writeBarrierPaths
const deleteDirs = this.writeBarrierDeleteDirs
this.writeBarrierPaths = null
this.writeBarrierDeleteDirs = null
if (paths && paths.size > 0) {
// syncRawObjects fsyncs each file and its parent directory.
await this.syncRawObjects([...paths])
}
if (deleteDirs && deleteDirs.size > 0) {
for (const relDir of deleteDirs) {
const dirPath = path.join(this.rootDir, relDir)
let handle: any
try {
handle = await fs.promises.open(dirPath, 'r')
} catch {
continue // directory vanished or platform disallows opening dirs
}
try {
await handle.sync()
} catch {
// Some platforms/filesystems reject directory fsync — best effort.
} finally {
await handle.close()
}
}
}
}
/**
* Append one line to `_system/tx-log.jsonl`. Plain `appendFile` — the
* tx-log is the one append-in-place file in the store (and is byte-copied,
* never hard-linked, by {@link FileSystemStorage.snapshotToDirectory}).
*
* @param line - One complete JSON document, without trailing newline.
*/
public async appendTxLogLine(line: string): Promise<void> {
await this.ensureInitialized()
const logPath = path.join(this.systemDir, 'tx-log.jsonl')
await fs.promises.appendFile(logPath, `${line}\n`, 'utf-8')
}
/**
* Read all tx-log lines, oldest first (empty array when no log exists).
* Torn trailing lines from a crashed append are returned as-is — callers
* tolerate unparseable lines.
*/
public async readTxLogLines(): Promise<string[]> {
await this.ensureInitialized()
const logPath = path.join(this.systemDir, 'tx-log.jsonl')
try {
const content: string = await fs.promises.readFile(logPath, 'utf-8')
return content.split('\n').filter((l: string) => l.length > 0)
} catch (error: any) {
if (error.code === 'ENOENT') return []
throw error
}
}
/**
* Snapshot the entire store into `targetPath` as a hard-link farm
* (Cassandra-style: instant, space-shared). Safe because every data file
* is immutable-by-rename — rewrites swap in new inodes, leaving the
* snapshot's links pointing at the old bytes. The two exceptions are
* handled explicitly: append-in-place files
* ({@link FileSystemStorage.SNAPSHOT_BYTE_COPY_PATHS}) are byte-copied, and
* process-local lock state
* ({@link FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS}) is excluded.
* Cross-device targets (where `link(2)` fails with `EXDEV`) fall back to
* byte copies per file.
*
* @param targetPath - Absolute directory for the snapshot. Created if
* missing; must be empty or absent (refuses to overwrite).
*/
public async snapshotToDirectory(targetPath: string): Promise<void> {
await this.ensureInitialized()
try {
const existing = await fs.promises.readdir(targetPath)
if (existing.length > 0) {
throw new Error(
`snapshotToDirectory: target ${targetPath} already exists and is not empty. ` +
`Choose a fresh directory per snapshot.`
)
}
} catch (error: any) {
if (error.code !== 'ENOENT') throw error
}
await fs.promises.mkdir(targetPath, { recursive: true })
const files: string[] = []
await this.collectSnapshotFiles(this.rootDir, '', files)
for (const relPath of files) {
const sourceFile = path.join(this.rootDir, relPath)
const targetFile = path.join(targetPath, relPath)
await fs.promises.mkdir(path.dirname(targetFile), { recursive: true })
// Byte-copy list: compare against the normalized (extension-preserving)
// relative path with separators unified, so `_system/tx-log.jsonl`
// matches on every platform.
const normalized = relPath.split(path.sep).join('/')
// Byte-copy (never hard-link) files that are mutated in place: the exact
// append-in-place paths, and every file under a mmap-mutated directory
// (e.g. the native `_id_mapper/*`). A shared inode would let a live
// msync/truncate reach through into the snapshot.
if (
FileSystemStorage.SNAPSHOT_BYTE_COPY_PATHS.has(normalized) ||
FileSystemStorage.SNAPSHOT_BYTE_COPY_DIRS.has(normalized.split('/')[0])
) {
await fs.promises.copyFile(sourceFile, targetFile)
continue
}
try {
await fs.promises.link(sourceFile, targetFile)
} catch (error: any) {
// EXDEV: cross-device; EPERM/ENOTSUP: filesystem forbids links.
// ENOENT: the live file was atomically replaced mid-walk — retry as
// a copy of whatever is current (single-writer discipline means this
// only happens for derived files being flushed concurrently).
if (['EXDEV', 'EPERM', 'ENOTSUP', 'ENOENT'].includes(error.code)) {
try {
await fs.promises.copyFile(sourceFile, targetFile)
} catch (copyError: any) {
if (copyError.code !== 'ENOENT') throw copyError
}
} else {
throw error
}
}
}
}
/**
* @description Pre-upgrade backup: a hard-link snapshot of the whole store into
* a SIBLING directory (`<rootDir>.migration-backup`, outside `rootDir` so the
* snapshot never recurses into itself), taken before a 7.x → 8.0 upgrade
* rebuilds the derived indexes. Zero-copy (shared inodes; the store is
* immutable-by-rename) and instant even at scale. Returns the backup path, or
* `null` when the store holds no canonical nouns (nothing to protect). If a
* backup already exists from a prior FAILED upgrade, it is REUSED as-is (that
* is the true pre-upgrade state; a retry must not overwrite it).
*/
public async createMigrationBackup(): Promise<string | null> {
await this.ensureInitialized()
// Nothing to protect if the store has no canonical nouns (e.g. a brand-new
// brain whose marker is simply absent — `epochStale` is true but there is no
// 7.x data to migrate).
const probe = await this.getNouns({ pagination: { limit: 1 } })
if ((probe.totalCount || 0) === 0 && probe.items.length === 0) {
return null
}
const backupPath = `${this.rootDir}.migration-backup`
// Reuse an existing backup (a prior failed upgrade's pre-state) rather than
// overwrite it — snapshotToDirectory also refuses a non-empty target.
try {
const existing = await fs.promises.readdir(backupPath)
if (existing.length > 0) return backupPath
} catch (error: any) {
if (error.code !== 'ENOENT') throw error
}
await this.snapshotToDirectory(backupPath)
return backupPath
}
/**
* @description Remove a {@link createMigrationBackup} snapshot. Best-effort: a
* missing path is a no-op, and any error is swallowed (a leftover backup dir is
* harmless — the operator can delete it). Removing the hard-links never touches
* the live store's bytes (shared inodes; only the extra links go away).
*/
public async removeMigrationBackup(location: string): Promise<void> {
try {
await fs.promises.rm(location, { recursive: true, force: true })
} catch {
// best-effort — leaving the backup behind is safe
}
}
/**
* Recursively collect snapshot-eligible files under `dirAbs`, excluding the
* lock directory and in-flight `*.tmp.*` write files.
*/
private async collectSnapshotFiles(dirAbs: string, relPrefix: string, out: string[]): Promise<void> {
let entries: any[]
try {
entries = await fs.promises.readdir(dirAbs, { withFileTypes: true })
} catch (error: any) {
if (error.code === 'ENOENT') return
throw error
}
for (const entry of entries) {
const rel = relPrefix ? path.join(relPrefix, entry.name) : entry.name
if (entry.isDirectory()) {
if (relPrefix === '' && FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS.has(entry.name)) {
continue
}
await this.collectSnapshotFiles(path.join(dirAbs, entry.name), rel, out)
} else if (entry.isFile()) {
if (entry.name.includes('.tmp.')) continue // in-flight atomic write
out.push(rel)
}
}
}
/**
* Replace the store's contents from a snapshot directory: every current
* top-level entry except `locks/` (the live writer lock must survive) is
* removed, the snapshot is byte-copied in (`fs.cp` — never hard-linked, so
* the snapshot stays independent of the restored store), and all
* adapter-internal derived state is reloaded.
*
* @param sourcePath - Absolute path of a directory produced by
* {@link FileSystemStorage.snapshotToDirectory}.
*
* Non-destructive: the snapshot is copied into a staging area (sparse-aware,
* so a store of mostly-hole mmap blobs cannot balloon and ENOSPC) BEFORE any
* live data is touched. Only once the full copy has succeeded and a completion
* marker is fsync'd does an atomic per-entry swap move it into place. A copy
* failure (including ENOSPC) leaves the live store exactly as it was; a crash
* mid-swap is resumed forward on the next {@link init} by
* {@link completeInterruptedRestore}. The previous implementation removed the
* live store first and then `fs.cp`'d — a copy failure destroyed the brain it
* was meant to recover.
*/
public async restoreFromDirectory(sourcePath: string): Promise<void> {
await this.ensureInitialized()
const sourceStat = await fs.promises.stat(sourcePath).catch(() => null)
if (!sourceStat || !sourceStat.isDirectory()) {
throw new Error(`restoreFromDirectory: ${sourcePath} is not a directory`)
}
const staging = path.join(this.rootDir, FileSystemStorage.RESTORE_STAGING_DIR)
// Discard any staging left by a prior aborted restore, then stage fresh.
await fs.promises.rm(staging, { recursive: true, force: true })
await fs.promises.mkdir(staging, { recursive: true })
// Copy the snapshot into staging (sparse-aware). ANY failure here — most
// importantly ENOSPC — leaves the live store untouched: we remove only the
// half-written staging area and re-throw.
const staged: string[] = []
try {
const sourceEntries = await fs.promises.readdir(sourcePath)
for (const entry of sourceEntries) {
if (FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS.has(entry)) continue
await this.copyTreeSparse(
path.join(sourcePath, entry),
path.join(staging, entry)
)
staged.push(entry)
}
// Commit point: fsync a marker naming the fully-staged entries. Only after
// this does the swap (and its resume) become authorized.
const markerPath = path.join(staging, FileSystemStorage.RESTORE_MARKER)
await fs.promises.writeFile(markerPath, JSON.stringify({ entries: staged }))
const mfh = await fs.promises.open(markerPath, 'r')
try {
await mfh.sync()
} finally {
await mfh.close()
}
const dfh = await fs.promises.open(staging, 'r').catch(() => null)
if (dfh) {
try {
await dfh.sync()
} catch {
// platform may reject directory fsync — best effort
} finally {
await dfh.close()
}
}
} catch (error: any) {
await fs.promises.rm(staging, { recursive: true, force: true }).catch(() => {})
throw new Error(
`restoreFromDirectory: staging copy failed, live store left untouched: ${error.message}`
)
}
// Atomic per-entry swap (metadata-only renames within rootDir — cannot ENOSPC).
await this.swapStagedRestoreIn()
await this.reloadDerivedState()
}
/**
* Move a fully-staged, marker-committed restore into place, then clear the
* staging area. Idempotent and resumable: driven by the marker's entry list
* and by which staged entries remain, so a crash at any point is completed by
* simply calling it again (from {@link completeInterruptedRestore} on the next
* open). Every step is a same-filesystem rename or a remove — no operation can
* fail for disk space, so once the marker exists the store is guaranteed to
* reach the restored state.
*/
private async swapStagedRestoreIn(): Promise<void> {
const staging = path.join(this.rootDir, FileSystemStorage.RESTORE_STAGING_DIR)
const markerPath = path.join(staging, FileSystemStorage.RESTORE_MARKER)
let staged: string[]
try {
const marker = JSON.parse(await fs.promises.readFile(markerPath, 'utf-8'))
staged = Array.isArray(marker?.entries) ? marker.entries : []
} catch {
return // no committed marker — nothing to swap
}
const stagedSet = new Set(staged)
// 1. Remove stale live entries the snapshot does not contain (excluding
// process-local dirs and the staging area itself). An already-placed
// staged entry is in stagedSet, so it is kept.
for (const entry of await fs.promises.readdir(this.rootDir)) {
if (FileSystemStorage.SNAPSHOT_EXCLUDED_TOP_DIRS.has(entry)) continue
if (entry === FileSystemStorage.RESTORE_STAGING_DIR) continue
if (stagedSet.has(entry)) continue
await fs.promises.rm(path.join(this.rootDir, entry), { recursive: true, force: true })
}
// 2. Place each staged entry (idempotent: a prior attempt that already moved
// it leaves staging/entry absent, so we skip). `rm` the old first — a
// rename onto an existing non-empty directory is not allowed; the staged
// copy is the durable source until it is placed, so a crash between the
// rm and the rename is recovered forward on the next call.
for (const entry of staged) {
const from = path.join(staging, entry)
const to = path.join(this.rootDir, entry)
const exists = await fs.promises.lstat(from).then(() => true, () => false)
if (!exists) continue
await fs.promises.rm(to, { recursive: true, force: true })
await fs.promises.rename(from, to)
}
// 3. Clear the staging area (marker last-standing entry).
await fs.promises.rm(staging, { recursive: true, force: true })
}
/**
* On open, finish any restore interrupted by a crash. A staging dir WITH the
* completion marker means the copy had succeeded — resume the swap forward
* (loudly). A staging dir WITHOUT the marker is an interrupted copy — pure
* debris; the live store is authoritative, so discard it. Called from
* {@link init} before counts/derived state load, so recovery is invisible to
* the rest of startup. Returns `true` if a swap was resumed.
*/
private async completeInterruptedRestore(): Promise<boolean> {
const staging = path.join(this.rootDir, FileSystemStorage.RESTORE_STAGING_DIR)
const stagingStat = await fs.promises.stat(staging).catch(() => null)
if (!stagingStat || !stagingStat.isDirectory()) return false
const markerPath = path.join(staging, FileSystemStorage.RESTORE_MARKER)
const hasMarker = await fs.promises
.stat(markerPath)
.then(() => true, () => false)
if (!hasMarker) {
// Interrupted before the copy committed — live data untouched, discard.
await fs.promises.rm(staging, { recursive: true, force: true }).catch(() => {})
return false
}
console.log('♻️ Resuming an interrupted restore (completing the staged swap)')
await this.swapStagedRestoreIn()
return true
}
/**
* Recursively copy `src` to `dest`, preserving holes (sparse regions). Files
* are copied chunk-by-chunk skipping all-zero chunks, so a store of
* mostly-hole mmap blobs restores at its true allocated size instead of
* materializing every hole (the failure that made `fs.cp` ENOSPC a restore
* that would otherwise fit). Directories recurse; symlinks are recreated.
*/
private async copyTreeSparse(src: string, dest: string): Promise<void> {
const stat = await fs.promises.lstat(src)
if (stat.isDirectory()) {
await fs.promises.mkdir(dest, { recursive: true })
for (const child of await fs.promises.readdir(src)) {
await this.copyTreeSparse(path.join(src, child), path.join(dest, child))
}
} else if (stat.isSymbolicLink()) {
await fs.promises.symlink(await fs.promises.readlink(src), dest)
} else if (stat.isFile()) {
await this.copyFileSparse(src, dest, stat.size, stat.mode)
}
// Other node types (sockets, devices) do not occur in a brain store.
}
/** Sparse-aware single-file copy — see {@link copyTreeSparse}. */
private async copyFileSparse(
src: string,
dest: string,
size: number,
mode: number
): Promise<void> {
const CHUNK = FileSystemStorage.SPARSE_CHUNK_BYTES
const srcFh = await fs.promises.open(src, 'r')
try {
const destFh = await fs.promises.open(dest, 'w', mode)
try {
// Pre-size the destination so unwritten regions are holes.
await destFh.truncate(size)
const buf = Buffer.allocUnsafe(CHUNK)
let pos = 0
while (pos < size) {
const { bytesRead } = await srcFh.read(buf, 0, CHUNK, pos)
if (bytesRead === 0) break
const chunk = buf.subarray(0, bytesRead)
// Skip all-zero chunks: leaving them unwritten preserves the hole.
const isHole = chunk.equals(
FileSystemStorage.SPARSE_ZERO_CHUNK.subarray(0, bytesRead)
)
if (!isHole) {
await destFh.write(chunk, 0, bytesRead, pos)
}
pos += bytesRead
}
} finally {
await destFh.close()
}
} finally {
await srcFh.close()
}
}
// ===========================================================================
// Raw binary-blob primitive (mmap-friendly)
// ===========================================================================
/**
* Resolve a blob key to its on-disk path under `<rootDir>/_blobs`.
*
* The key's "/"-separated segments become nested directories and the file is
* suffixed with `.bin`, e.g. `"graph-lsm/source/sstable-123"` →
* `<rootDir>/_blobs/graph-lsm/source/sstable-123.bin`. This convention is
* shared with cor's `MmapFileSystemStorage` so native code and the TS layer
* agree on exactly where each blob lives.
*
* @param key - The blob key.
* @returns The absolute on-disk path for the blob.
* @private
*/
private blobPath(key: string): string {
// `path` and `blobsDir` are populated in init(). If a caller resolves a blob
// path before init() (e.g. native code probing for a mmap target), fall back
// to a POSIX-style join off rootDir so this synchronous method never throws.
if (path && this.blobsDir) {
return path.join(this.blobsDir, ...key.split('/')) + '.bin'
}
return `${this.rootDir}/_blobs/${key}.bin`
}
/**
* Persist a raw binary blob verbatim under `key`, using an atomic
* temp-file + rename so concurrent readers never observe a torn write.
* Parent directories are created on demand.
*
* @param key - The blob key (see {@link getBinaryBlobPath} for the convention).
* @param data - The exact bytes to store.
*/
public async saveBinaryBlob(key: string, data: Buffer): Promise<void> {
await this.ensureInitialized()
const filePath = this.blobPath(key)
await this.ensureDirectoryExists(path.dirname(filePath))
// Unique per-writer temp suffix — matches the pattern used at every other
// atomic-write site in this file (lines 336, 551, 744, 781, 1529, 2908).
// Without a unique suffix, two concurrent saveBinaryBlob() calls for the
// same key collide on `${filePath}.tmp`: both writeFile, the first rename
// succeeds, the second fires against a missing temp and throws ENOENT.
// Reproduced in production: column-store compaction running alongside an
// explicit flush() repeatedly raced on `_column_index/<field>/DELETED.bin`.
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`
await fs.promises.writeFile(tmpPath, data)
try {
await fs.promises.rename(tmpPath, filePath)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
// The writes are idempotent for a given key — every caller persists the
// same logical bytes for that key — so ENOENT on rename means the temp
// is already gone (rare with the unique suffix, but defensive against
// crash-resume cleanup paths and external file-system sweepers).
if (code === 'ENOENT') return
// Any other failure: clean up our own temp to avoid orphans before rethrow.
await fs.promises.unlink(tmpPath).catch(() => {})
throw err
}
}
/**
* Load the raw bytes stored under `key`, or `null` if the blob does not exist.
*
* @param key - The blob key.
* @returns The blob bytes, or `null` if absent.
*/
public async loadBinaryBlob(key: string): Promise<Buffer | null> {
await this.ensureInitialized()
try {
return await fs.promises.readFile(this.blobPath(key))
} catch {
return null
}
}
/**
* Delete the blob stored under `key`. Missing blobs are ignored.
*
* @param key - The blob key.
*/
public async deleteBinaryBlob(key: string): Promise<void> {
await this.ensureInitialized()
try {
await fs.promises.unlink(this.blobPath(key))
} catch {
/* ignore missing files */
}
}
/**
* Return the real on-disk path for `key` so native code can mmap the file
* directly. The path is returned whether or not the file currently exists —
* callers are expected to write before mapping.
*
* @param key - The blob key.
* @returns The absolute on-disk path (never `null` for filesystem storage).
*/
public getBinaryBlobPath(key: string): string | null {
return this.blobPath(key)
}
/**
* Get multiple metadata objects in batches (CRITICAL: Prevents socket exhaustion)
* FileSystem implementation uses controlled concurrency to prevent too many file reads
*/
public async getMetadataBatch(ids: string[]): Promise<Map<string, any>> {
await this.ensureInitialized()
const results = new Map<string, any>()
const batchSize = 10 // Process 10 files at a time
// Process in batches to avoid overwhelming the filesystem
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize)
const batchPromises = batch.map(async (id) => {
try {
// CRITICAL: Use getNounMetadata() instead of deprecated getMetadata()
// This ensures we fetch from the correct noun metadata store (2-file system)
const metadata = await this.getNounMetadata(id)
return { id, metadata }
} catch (error) {
console.debug(`Failed to read metadata for ${id}:`, error)
return { id, metadata: null }
}
})
const batchResults = await Promise.all(batchPromises)
for (const { id, metadata } of batchResults) {
if (metadata !== null) {
results.set(id, metadata)
}
}
// Small yield between batches
await new Promise(resolve => setImmediate(resolve))
}
return results
}
/**
* Get nouns with pagination support
* @param options Pagination options
*/
// Removed getNounsWithPagination override - now uses BaseStorage's type-first implementation
/**
* Clear all data from storage
*/
public async clear(): Promise<void> {
await this.ensureInitialized()
// Check if fs module is available
if (!fs || !fs.promises) {
console.warn('FileSystemStorage.clear: fs module not available, skipping clear operation')
return
}
// Helper function to remove all files in a directory
const removeDirectoryContents = async (dirPath: string): Promise<void> => {
try {
const files = await fs.promises.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) {
await removeDirectoryContents(filePath)
await fs.promises.rmdir(filePath)
} else {
await fs.promises.unlink(filePath)
}
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`Error removing directory contents ${dirPath}:`, error)
throw error
}
}
}
// Clear the canonical entity/verb data area
const entitiesDir = path.join(this.rootDir, 'entities')
if (await this.directoryExists(entitiesDir)) {
await removeDirectoryContents(entitiesDir)
}
// Remove all files in both system directories
await removeDirectoryContents(this.systemDir)
if (await this.directoryExists(this.indexDir)) {
await removeDirectoryContents(this.indexDir)
}
// Remove the content-addressed blob store (VFS file content)
const casDir = path.join(this.rootDir, '_cas')
if (await this.directoryExists(casDir)) {
// Delete the entire _cas/ directory (not just contents)
await fs.promises.rm(casDir, { recursive: true, force: true })
}
// Reset ALL shared derived state via the same path restore uses: the
// write-through cache, id→type/subtype caches, per-type/subtype count
// rollups, statistics cache, graph-index singleton, and the BlobStorage
// instance (its LRU cache holds deleted blobs), then recount from the
// now-empty data area. Without the rollup reset, stats() would keep
// reporting per-type counts for deleted entities.
await this.reloadDerivedState()
}
/**
* Enhanced clear operation with safety mechanisms and performance optimizations
* Provides progress tracking, backup options, and instance name confirmation
*/
public async clearEnhanced(options: import('../enhancedClearOperations.js').ClearOptions = {}): Promise<import('../enhancedClearOperations.js').ClearResult> {
await this.ensureInitialized()
// Check if fs module is available
if (!fs || !fs.promises) {
throw new Error('FileSystemStorage.clearEnhanced: fs module not available')
}
const { EnhancedFileSystemClear } = await import('../enhancedClearOperations.js')
const enhancedClear = new EnhancedFileSystemClear(this.rootDir, fs, path)
const result = await enhancedClear.clear(options)
if (result.success) {
// Clear the statistics cache
this.statisticsCache = null
this.statisticsModified = false
}
return result
}
/**
* Get information about storage usage and capacity
*/
public async getStorageStatus(): Promise<{
type: string
used: number
quota: number | null
details?: Record<string, any>
}> {
await this.ensureInitialized()
// Check if fs module is available
if (!fs || !fs.promises) {
console.warn('FileSystemStorage.getStorageStatus: fs module not available, returning default values')
return {
type: 'filesystem',
used: 0,
quota: null,
details: {
nounsCount: 0,
verbsCount: 0,
metadataCount: 0,
directorySizes: {
nouns: 0,
verbs: 0,
metadata: 0,
index: 0
}
}
}
}
try {
// Calculate the total size of all files in the storage directories
let totalSize = 0
// Helper function to calculate directory size
const calculateSize = async (dirPath: string): Promise<number> => {
let size = 0
try {
const files = await fs.promises.readdir(dirPath)
for (const file of files) {
const filePath = path.join(dirPath, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) {
size += await calculateSize(filePath)
} else {
size += stats.size
}
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(
`Error calculating size for directory ${dirPath}:`,
error
)
}
}
return size
}
// Calculate size for each directory
const nounsDirSize = await calculateSize(this.nounsDir)
const verbsDirSize = await calculateSize(this.verbsDir)
const metadataDirSize = await calculateSize(this.metadataDir)
const indexDirSize = await calculateSize(this.indexDir)
totalSize = nounsDirSize + verbsDirSize + metadataDirSize + indexDirSize
// CRITICAL FIX: Use persisted counts instead of directory reads
// This is O(1) instead of O(n), and handles sharded structure correctly
const nounsCount = this.totalNounCount
const verbsCount = this.totalVerbCount
// Count metadata files (these are NOT sharded)
const metadataCount = (
await fs.promises.readdir(this.metadataDir)
).filter((file: string) => file.endsWith('.json')).length
// Use persisted entity counts by type (O(1) instead of scanning all files)
const nounTypeCounts: Record<string, number> = Object.fromEntries(this.entityCounts)
// Skip the expensive metadata file scan since we have counts
const metadataFiles: string[] = [] // Empty array to skip the loop below
for (const file of metadataFiles) {
if (file.endsWith('.json')) {
try {
const filePath = path.join(this.metadataDir, file)
const data = await fs.promises.readFile(filePath, 'utf-8')
const metadata = JSON.parse(data)
if (metadata.noun) {
nounTypeCounts[metadata.noun] =
(nounTypeCounts[metadata.noun] || 0) + 1
}
} catch (error) {
console.error(`Error reading metadata file ${file}:`, error)
}
}
}
return {
type: 'filesystem',
used: totalSize,
quota: null, // File system doesn't provide quota information
details: {
rootDirectory: this.rootDir,
nounsCount,
verbsCount,
metadataCount,
nounsDirSize,
verbsDirSize,
metadataDirSize,
indexDirSize,
nounTypes: nounTypeCounts
}
}
} catch (error) {
console.error('Failed to get storage status:', error)
return {
type: 'filesystem',
used: 0,
quota: null,
details: { error: String(error) }
}
}
}
// Removed 10 *_internal method overrides - now inherit from BaseStorage's type-first implementation
// Removed 2 pagination methods (getNounsWithPagination, getVerbsWithPagination) - use BaseStorage's implementation
public override supportsMultiProcessLocking(): boolean {
return true
}
/**
* Acquire the process-level writer lock for this data directory. Called by
* `Brainy.init()` in writer mode. Refuses to open if another live writer
* holds the lock — the worst possible default is to "succeed" with stale
* indexes and silently lie about query results.
*
* Lock file: `<rootDir>/locks/_writer.lock`.
*
* Stale-lock detection: a lock is considered stale when ALL of:
* 1. Same hostname as the current process (cross-host PID checks are unsafe).
* 2. The recorded PID is no longer alive (`process.kill(pid, 0)` → ESRCH), OR
* `lastHeartbeat` is older than `WRITER_STALE_THRESHOLD_MS` (60s).
* Stale locks are overwritten with a warning. `force: true` overrides even live locks.
*/
public override async acquireWriterLock(options?: { force?: boolean }): Promise<WriterLockInfo | null> {
await this.ensureInitialized()
await this.ensureDirectoryExists(this.lockDir)
const lockFile = path.join(this.lockDir, FileSystemStorage.WRITER_LOCK_FILE)
const os = await import('node:os')
const hostname = os.hostname()
const now = new Date().toISOString()
const existing = await this.readWriterLock()
if (existing && !options?.force) {
// Same-process re-open: a second Brainy instance in this Node process
// (e.g. test "simulate server restart" patterns, or a consumer that
// explicitly re-instantiates without closing first). This isn't the
// dangerous cross-process case the lock exists to prevent — the two
// instances share a memory space and can't silently diverge from each
// other beyond what their callers already see. Warn and take over the lock.
if (existing.pid === (typeof process !== 'undefined' ? process.pid : 0) &&
existing.hostname === hostname) {
console.warn(
`[brainy] Re-acquiring writer lock for ${this.rootDir} held by the same process (PID ${existing.pid}). ` +
`If you intended to keep the previous Brainy instance alive, this is a bug — close it first.`
)
} else {
const stale = await this.isWriterLockStale(existing)
if (!stale) {
// Consumer-facing error contract: callers detect this case via
// err.code and read the holder's details from err.lockInfo.
const err = new Error(
`Another writer holds this Brainy directory.\n` +
` PID: ${existing.pid} on host ${existing.hostname}\n` +
` Started: ${existing.startedAt}\n` +
` Heartbeat: ${existing.lastHeartbeat}\n` +
` Version: ${existing.version}\n` +
` Directory: ${this.rootDir}\n\n` +
`For diagnostic queries against this live store, use:\n` +
` const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: '${this.rootDir}' } })\n\n` +
`If you have verified the existing lock is stale (e.g. a crashed writer on a different host that PID liveness cannot reach), pass { force: true }.`
) as Error & { code: string; lockInfo: WriterLockInfo }
err.code = 'BRAINY_WRITER_LOCKED'
err.lockInfo = existing
throw err
}
console.warn(
`[brainy] Overwriting stale writer lock for ${this.rootDir} ` +
`(PID ${existing.pid} on ${existing.hostname} appears dead).`
)
}
} else if (existing && options?.force) {
console.warn(
`[brainy] Force-overwriting writer lock for ${this.rootDir} ` +
`(was held by PID ${existing.pid} on ${existing.hostname}).`
)
}
const info: WriterLockInfo = {
pid: typeof process !== 'undefined' && process.pid ? process.pid : 0,
hostname,
startedAt: existing && options?.force ? existing.startedAt : now,
lastHeartbeat: now,
version: getBrainyVersion(),
rootDir: this.rootDir
}
await this.writeFileAtomic(lockFile, JSON.stringify(info, null, 2))
this.writerLockInfo = info
// Heartbeat — rewrite lastHeartbeat every WRITER_HEARTBEAT_MS so other
// processes can tell a live writer from one that crashed without releasing.
this.writerLockHeartbeat = setInterval(() => {
this.refreshWriterLockHeartbeat().catch((err) => {
console.warn('[brainy] Failed to refresh writer lock heartbeat:', err)
})
}, FileSystemStorage.WRITER_HEARTBEAT_MS)
if (typeof this.writerLockHeartbeat.unref === 'function') {
// Don't keep the event loop alive just for the heartbeat.
this.writerLockHeartbeat.unref()
}
return info
}
public override async releaseWriterLock(): Promise<void> {
if (this.writerLockHeartbeat) {
clearInterval(this.writerLockHeartbeat)
this.writerLockHeartbeat = undefined
}
if (!this.writerLockInfo) {
return
}
const lockFile = path.join(this.lockDir, FileSystemStorage.WRITER_LOCK_FILE)
try {
// Only delete if we still own it — avoid clobbering a successor that
// claimed the lock via force-override.
const current = await this.readWriterLock()
if (current && current.pid === this.writerLockInfo.pid && current.hostname === this.writerLockInfo.hostname) {
await fs.promises.unlink(lockFile)
}
} catch (err: any) {
if (err.code !== 'ENOENT') {
console.warn('[brainy] Failed to release writer lock file:', err)
}
} finally {
this.writerLockInfo = undefined
}
}
public override async readWriterLock(): Promise<WriterLockInfo | null> {
await this.ensureInitialized()
const lockFile = path.join(this.lockDir, FileSystemStorage.WRITER_LOCK_FILE)
try {
const raw = await fs.promises.readFile(lockFile, 'utf-8')
return JSON.parse(raw) as WriterLockInfo
} catch (err: any) {
if (err.code === 'ENOENT') return null
console.warn('[brainy] Failed to read writer lock file:', err)
return null
}
}
/**
* Atomically refresh `lastHeartbeat` on the writer lock we own. Skips if the
* lock file has been deleted out from under us (e.g. operator removed it).
*/
private async refreshWriterLockHeartbeat(): Promise<void> {
if (!this.writerLockInfo) return
const current = await this.readWriterLock()
if (!current) return
// Defensive: don't overwrite if a successor claimed the lock.
if (current.pid !== this.writerLockInfo.pid || current.hostname !== this.writerLockInfo.hostname) {
return
}
const updated: WriterLockInfo = {
...this.writerLockInfo,
lastHeartbeat: new Date().toISOString()
}
const lockFile = path.join(this.lockDir, FileSystemStorage.WRITER_LOCK_FILE)
await this.writeFileAtomic(lockFile, JSON.stringify(updated, null, 2))
this.writerLockInfo = updated
}
/**
* Determine whether an existing writer lock is stale (safe to overwrite).
* Same hostname and (dead PID OR heartbeat older than threshold) → stale.
* Different hostname → cannot prove stale, treat as live.
*/
private async isWriterLockStale(lock: WriterLockInfo): Promise<boolean> {
const os = await import('node:os')
if (lock.hostname !== os.hostname()) {
return false
}
const heartbeatAge = Date.now() - new Date(lock.lastHeartbeat).getTime()
const pidAlive = this.isPidAlive(lock.pid)
if (!pidAlive) return true
return heartbeatAge > FileSystemStorage.WRITER_STALE_THRESHOLD_MS
}
/**
* `process.kill(pid, 0)` sends signal 0 — no signal is actually delivered;
* it just checks whether the kernel still considers `pid` reachable from
* this process. ESRCH = no such process. EPERM = exists but we can't signal
* it, which still proves it's alive.
*/
private isPidAlive(pid: number): boolean {
if (!pid || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch (err: any) {
if (err.code === 'EPERM') return true
return false
}
}
/**
* Atomic write via temp-file-then-rename so concurrent readers never see a
* half-written lock JSON. Reused by writer-lock writes + heartbeat.
*/
private async writeFileAtomic(filePath: string, contents: string): Promise<void> {
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`
await fs.promises.writeFile(tmp, contents)
await fs.promises.rename(tmp, filePath)
}
/**
* Start watching for cross-process flush requests. Called by Brainy.init()
* in writer mode. Polls `locks/_flush_requests/` every
* FLUSH_WATCH_INTERVAL_MS — each new `.req` file triggers the supplied
* callback (`brain.flush()`), after which an `.ack` is written to
* `locks/_flush_responses/` with the same request ID. Stale `.req` files
* (>FLUSH_REQUEST_TTL_MS) are garbage-collected on every tick.
*/
public override startFlushRequestWatcher(onRequest: () => Promise<void>): void {
if (this.flushWatcherInterval) return // already watching
this.flushWatcherOnRequest = onRequest
const reqDir = path.join(this.lockDir, FileSystemStorage.FLUSH_REQUEST_DIR)
const ackDir = path.join(this.lockDir, FileSystemStorage.FLUSH_RESPONSE_DIR)
// Ensure both dirs exist up front so the first .req drop doesn't race with mkdir.
this.ensureDirectoryExists(reqDir).catch(() => {})
this.ensureDirectoryExists(ackDir).catch(() => {})
this.flushWatcherInterval = setInterval(() => {
if (this.flushWatcherInFlight) return // skip overlapping tick
this.flushWatcherInFlight = true
this.processFlushRequests(reqDir, ackDir).finally(() => {
this.flushWatcherInFlight = false
})
}, FileSystemStorage.FLUSH_WATCH_INTERVAL_MS)
if (typeof this.flushWatcherInterval.unref === 'function') {
this.flushWatcherInterval.unref()
}
}
public override stopFlushRequestWatcher(): void {
if (this.flushWatcherInterval) {
clearInterval(this.flushWatcherInterval)
this.flushWatcherInterval = undefined
}
this.flushWatcherOnRequest = undefined
}
/**
* Process any pending `.req` files: invoke the flush callback once, then
* write an `.ack` for each request. Multiple requests that arrived in the
* same tick share a single flush — they all see the same ack timestamp.
*/
private async processFlushRequests(reqDir: string, ackDir: string): Promise<void> {
let entries: string[]
try {
entries = await fs.promises.readdir(reqDir)
} catch (err: any) {
if (err.code !== 'ENOENT') {
console.warn('[brainy] Flush watcher readdir failed:', err)
}
return
}
const reqs = entries.filter((e: string) => e.endsWith('.req'))
if (reqs.length === 0) return
// One flush per tick — N pending requests share it.
const callback = this.flushWatcherOnRequest
if (!callback) return
let flushError: Error | null = null
try {
await callback()
} catch (err: any) {
flushError = err instanceof Error ? err : new Error(String(err))
console.warn('[brainy] Flush callback threw inside flush-request watcher:', err)
}
const ackTimestamp = new Date().toISOString()
for (const filename of reqs) {
const requestId = filename.replace(/\.req$/, '')
const ackPath = path.join(ackDir, `${requestId}.ack`)
const ackBody = JSON.stringify({
requestId,
completedAt: ackTimestamp,
ok: !flushError,
error: flushError ? flushError.message : undefined
})
try {
await this.writeFileAtomic(ackPath, ackBody)
await fs.promises.unlink(path.join(reqDir, filename))
} catch (err: any) {
if (err.code !== 'ENOENT') {
console.warn('[brainy] Failed to write flush ack:', err)
}
}
}
// Garbage-collect stale .req files in case a watcher missed them (e.g.
// the writer was restarted between request and processing).
const now = Date.now()
for (const filename of entries) {
if (!filename.endsWith('.req')) continue
const fp = path.join(reqDir, filename)
try {
const stat = await fs.promises.stat(fp)
if (now - stat.mtimeMs > FileSystemStorage.FLUSH_REQUEST_TTL_MS) {
await fs.promises.unlink(fp).catch(() => {})
}
} catch {
// ignore — file already removed
}
}
}
/**
* Inspector side: drop a `.req` file and poll for the corresponding `.ack`.
* Returns true if the writer acknowledged in time, false on timeout.
*/
public override async requestFlushOverFilesystem(timeoutMs: number): Promise<boolean> {
await this.ensureInitialized()
const reqDir = path.join(this.lockDir, FileSystemStorage.FLUSH_REQUEST_DIR)
const ackDir = path.join(this.lockDir, FileSystemStorage.FLUSH_RESPONSE_DIR)
await this.ensureDirectoryExists(reqDir)
await this.ensureDirectoryExists(ackDir)
const requestId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
const reqPath = path.join(reqDir, `${requestId}.req`)
const ackPath = path.join(ackDir, `${requestId}.ack`)
const body = JSON.stringify({
requestId,
requestedAt: new Date().toISOString(),
requesterPid: process.pid
})
await this.writeFileAtomic(reqPath, body)
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
await fs.promises.access(ackPath, fs.constants.F_OK)
await fs.promises.unlink(ackPath).catch(() => {})
return true
} catch {
// not yet
}
await new Promise((r) => setTimeout(r, FileSystemStorage.FLUSH_POLL_INTERVAL_MS))
}
// Timeout — best effort to clean up our request so the writer doesn't act
// on stale work later. Watcher will GC anyway after FLUSH_REQUEST_TTL_MS.
await fs.promises.unlink(reqPath).catch(() => {})
return false
}
/**
* Acquire a file-based lock for coordinating operations across multiple processes
* @param lockKey The key to lock on
* @param ttl Time to live for the lock in milliseconds (default: 30 seconds)
* @returns Promise that resolves to true if lock was acquired, false otherwise
*/
private async acquireLock(
lockKey: string,
ttl: number = 30000
): Promise<boolean> {
await this.ensureInitialized()
// Ensure lock directory exists
await this.ensureDirectoryExists(this.lockDir)
const lockFile = path.join(this.lockDir, `${lockKey}.lock`)
const lockValue = `${Date.now()}_${Math.random()}_${process.pid || 'unknown'}`
const expiresAt = Date.now() + ttl
try {
// Check if lock file already exists and is still valid
try {
const lockData = await fs.promises.readFile(lockFile, 'utf-8')
const lockInfo = JSON.parse(lockData)
if (lockInfo.expiresAt > Date.now()) {
// Lock exists and is still valid
return false
}
} catch (error: any) {
// If file doesn't exist or can't be read, we can proceed to create the lock
if (error.code !== 'ENOENT') {
console.warn(`Error reading lock file ${lockFile}:`, error)
}
}
// Try to create the lock file
const lockInfo = {
lockValue,
expiresAt,
pid: process.pid || 'unknown',
timestamp: Date.now()
}
await fs.promises.writeFile(lockFile, JSON.stringify(lockInfo, null, 2))
// Add to active locks for cleanup
this.activeLocks.add(lockKey)
// Schedule automatic cleanup when lock expires
setTimeout(() => {
this.releaseLock(lockKey, lockValue).catch((error) => {
console.warn(`Failed to auto-release expired lock ${lockKey}:`, error)
})
}, ttl)
return true
} catch (error) {
console.warn(`Failed to acquire lock ${lockKey}:`, error)
return false
}
}
/**
* Release a file-based lock
* @param lockKey The key to unlock
* @param lockValue The value used when acquiring the lock (for verification)
* @returns Promise that resolves when lock is released
*/
private async releaseLock(
lockKey: string,
lockValue?: string
): Promise<void> {
await this.ensureInitialized()
const lockFile = path.join(this.lockDir, `${lockKey}.lock`)
try {
// If lockValue is provided, verify it matches before releasing
if (lockValue) {
try {
const lockData = await fs.promises.readFile(lockFile, 'utf-8')
const lockInfo = JSON.parse(lockData)
if (lockInfo.lockValue !== lockValue) {
// Lock was acquired by someone else, don't release it
return
}
} catch (error: any) {
// If lock file doesn't exist, that's fine
if (error.code === 'ENOENT') {
return
}
throw error
}
}
// Delete the lock file
await fs.promises.unlink(lockFile)
// Remove from active locks
this.activeLocks.delete(lockKey)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.warn(`Failed to release lock ${lockKey}:`, error)
}
}
}
/**
* Clean up expired lock files
*/
private async cleanupExpiredLocks(): Promise<void> {
await this.ensureInitialized()
try {
const lockFiles = await fs.promises.readdir(this.lockDir)
const now = Date.now()
for (const lockFile of lockFiles) {
if (!lockFile.endsWith('.lock')) continue
const lockPath = path.join(this.lockDir, lockFile)
try {
const lockData = await fs.promises.readFile(lockPath, 'utf-8')
const lockInfo = JSON.parse(lockData)
if (lockInfo.expiresAt <= now) {
await fs.promises.unlink(lockPath)
const lockKey = lockFile.replace('.lock', '')
this.activeLocks.delete(lockKey)
}
} catch (error) {
// If we can't read or parse the lock file, remove it
try {
await fs.promises.unlink(lockPath)
} catch (unlinkError) {
console.warn(
`Failed to cleanup invalid lock file ${lockPath}:`,
unlinkError
)
}
}
}
} catch (error) {
console.warn('Failed to cleanup expired locks:', error)
}
}
/**
* Save statistics data to storage with file-based locking
*/
protected async saveStatisticsData(
statistics: StatisticsData
): Promise<void> {
const lockKey = 'statistics'
const lockAcquired = await this.acquireLock(lockKey, 10000) // 10 second timeout
if (!lockAcquired) {
console.warn(
'Failed to acquire lock for statistics update, proceeding without lock'
)
}
try {
// Get existing statistics to merge with new data
const existingStats = await this.getStatisticsData()
if (existingStats) {
// Merge statistics data
const mergedStats: StatisticsData = {
totalNodes: Math.max(
statistics.totalNodes || 0,
existingStats.totalNodes || 0
),
totalEdges: Math.max(
statistics.totalEdges || 0,
existingStats.totalEdges || 0
),
totalMetadata: Math.max(
statistics.totalMetadata || 0,
existingStats.totalMetadata || 0
),
// Preserve any additional fields from existing stats
...existingStats,
// Override with new values where provided
...statistics,
// Always update lastUpdated to current time
lastUpdated: new Date().toISOString()
}
await this.saveStatisticsWithBackwardCompat(mergedStats)
} else {
// No existing statistics, save new ones
const newStats: StatisticsData = {
...statistics,
lastUpdated: new Date().toISOString()
}
await this.saveStatisticsWithBackwardCompat(newStats)
}
} finally {
if (lockAcquired) {
await this.releaseLock(lockKey)
}
}
}
/**
* Get statistics data from storage
*/
protected async getStatisticsData(): Promise<StatisticsData | null> {
try {
const statsPath = path.join(this.systemDir, `${STATISTICS_KEY}.json`)
const data = await fs.promises.readFile(statsPath, 'utf-8')
return JSON.parse(data)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error('Error reading statistics:', error)
}
return null
}
}
/**
* Save statistics to storage
*/
private async saveStatisticsWithBackwardCompat(statistics: StatisticsData): Promise<void> {
const statsPath = path.join(this.systemDir, `${STATISTICS_KEY}.json`)
await this.ensureDirectoryExists(this.systemDir)
await fs.promises.writeFile(statsPath, JSON.stringify(statistics, null, 2))
}
// =============================================
// Count Management for O(1) Scalability
// =============================================
/**
* Initialize counts from filesystem storage
*/
protected async initializeCounts(): Promise<void> {
if (!this.countsFilePath) return
try {
if (await this.fileExists(this.countsFilePath)) {
const data = await fs.promises.readFile(this.countsFilePath, 'utf-8')
const counts = JSON.parse(data)
// Restore entity counts
this.entityCounts = new Map(Object.entries(counts.entityCounts || {}))
this.verbCounts = new Map(Object.entries(counts.verbCounts || {}))
this.totalNounCount = counts.totalNounCount || 0
this.totalVerbCount = counts.totalVerbCount || 0
// Also populate the cache for backward compatibility
this.countCache.set('nouns_count', {
count: this.totalNounCount,
timestamp: Date.now()
})
this.countCache.set('verbs_count', {
count: this.totalVerbCount,
timestamp: Date.now()
})
} else {
// If no counts file exists, do one initial count
await this.initializeCountsFromDisk()
}
} catch (error) {
console.warn('Could not load persisted counts, will initialize from disk:', error)
await this.initializeCountsFromDisk()
}
}
/**
* Initialize counts by scanning disk (only done once)
*/
private async initializeCountsFromDisk(): Promise<void> {
try {
// Count the CANONICAL 8.0 layout (`entities/<kind>/<shard>/<id>/…`) —
// the tree saveNoun/getNouns actually read and write. The previous scan
// counted the vestigial `entities/*/hnsw` directories, which the 8.0
// write path never populates, so a store recovering from a lost or
// corrupted counts.json re-initialized every counter to ZERO on real
// data (wrong stats/boot logs and a mis-sized rebuild-strategy
// decision at open).
const nouns = await this.scanCanonicalEntities('nouns')
this.totalNounCount = nouns.count
const verbs = await this.scanCanonicalEntities('verbs')
this.totalVerbCount = verbs.count
// Sample some entities for the type distribution (don't read all).
// Read the metadata files DIRECTLY with fs — this runs inside init(),
// and every guarded accessor (getNounMetadata → ensureInitialized)
// re-enters init() from here, deadlocking the open. (Latent in the old
// code too: its scan of the empty hnsw dirs just never sampled.)
for (const entityDir of nouns.sampleDirs) {
const metadata = await this.readEntityMetadataRaw(entityDir)
if (metadata) {
const type = metadata.noun || 'default'
this.entityCounts.set(type, (this.entityCounts.get(type) || 0) + 1)
}
}
// Extrapolate counts if we sampled
const sampleSize = nouns.sampleDirs.length
if (sampleSize < this.totalNounCount && sampleSize > 0) {
const multiplier = this.totalNounCount / sampleSize
for (const [type, count] of this.entityCounts.entries()) {
this.entityCounts.set(type, Math.round(count * multiplier))
}
}
await this.persistCounts()
} catch (error) {
console.error('Error initializing counts from disk:', error)
}
}
/**
* Walk the canonical `entities/<kind>/<2-hex-shard>/<id>/` tree, counting
* one entity per id directory (the layout `getNounVectorPath`/`getNouns`
* use). Returns up to 100 sampled entity directories (absolute paths) —
* nouns feed the type-distribution estimate above. An absent tree (fresh
* store) counts zero.
*/
private async scanCanonicalEntities(
kind: 'nouns' | 'verbs'
): Promise<{ count: number; sampleDirs: string[] }> {
const base = path.join(this.rootDir, 'entities', kind)
const SAMPLE_MAX = 100
let count = 0
const sampleDirs: string[] = []
try {
const shards = await fs.promises.readdir(base, { withFileTypes: true })
for (const shard of shards) {
if (!shard.isDirectory() || !/^[0-9a-f]{2}$/i.test(shard.name)) continue
const shardPath = path.join(base, shard.name)
const ids = await fs.promises.readdir(shardPath, { withFileTypes: true })
for (const entry of ids) {
if (!entry.isDirectory()) continue
count++
if (sampleDirs.length < SAMPLE_MAX) {
sampleDirs.push(path.join(shardPath, entry.name))
}
}
}
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error
}
return { count, sampleDirs }
}
/**
* Read one canonical entity's `metadata.json` (or `.json.gz`) directly with
* fs — NO guarded accessors. Used only by the init-time count recovery,
* where `getNounMetadata`'s `ensureInitialized()` would re-enter `init()`.
* @param entityDir - Absolute `entities/<kind>/<shard>/<id>` directory.
* @returns The parsed metadata, or null when absent/unreadable.
*/
private async readEntityMetadataRaw(entityDir: string): Promise<any | null> {
const base = path.join(entityDir, 'metadata.json')
try {
return JSON.parse(await fs.promises.readFile(base, 'utf-8'))
} catch {
// fall through to the compressed variant
}
try {
const gz = await fs.promises.readFile(`${base}.gz`)
return JSON.parse(zlib.gunzipSync(gz).toString('utf-8'))
} catch {
return null
}
}
/**
* Persist counts to filesystem storage
*/
protected async persistCounts(): Promise<void> {
if (!this.countsFilePath) return
try {
const counts = {
entityCounts: Object.fromEntries(this.entityCounts),
verbCounts: Object.fromEntries(this.verbCounts),
totalNounCount: this.totalNounCount,
totalVerbCount: this.totalVerbCount,
lastUpdated: new Date().toISOString()
}
await fs.promises.writeFile(
this.countsFilePath,
JSON.stringify(counts, null, 2)
)
} catch (error) {
console.error('Error persisting counts:', error)
}
}
// =============================================
// Intelligent Directory Sharding
// =============================================
/**
* Migrate a single file atomically
*/
private async migrateFile(
fileInfo: { oldPath: string; id: string; type: 'noun' | 'verb' },
fromDepth: number,
toDepth: number
): Promise<void> {
const baseDir = fileInfo.type === 'noun' ? this.nounsDir : this.verbsDir
// Calculate old path (already known)
const oldPath = fileInfo.oldPath
// Calculate new path using target depth
const shard = fileInfo.id.substring(0, 2).toLowerCase()
const newPath = path.join(baseDir, shard, `${fileInfo.id}.json`)
// Check if file already exists at new location
if (await this.fileExists(newPath)) {
// File already migrated or duplicate - skip
return
}
// Atomic rename/move
await fs.promises.rename(oldPath, newPath)
}
/**
* Whether this store already holds canonical 8.0 entities.
*
* 8.0 writes nouns to `entities/nouns/<shard>/<id>/vectors.json` (see
* `getNounVectorPath`). This checks that canonical shard tree — the one the
* DB actually reads and writes (the same `entities/nouns/<hex>` shards
* `getNounsWithPagination` walks) — so the new-vs-established boot log is
* truthful. (The 7.x hnsw sharding probe this replaced inspected a directory
* the 8.0 write path never populated, so it mislabeled every established
* store "New installation" on every boot; that probe and its depth-migration
* machinery are removed.)
*
* @returns true if at least one 2-hex shard directory (00ff) exists under
* `entities/nouns/`, i.e. the store has previously persisted entities.
*/
private async hasCanonicalEntities(): Promise<boolean> {
const canonicalNounsDir = path.join(this.rootDir, 'entities', 'nouns')
try {
const entries = await fs.promises.readdir(canonicalNounsDir, {
withFileTypes: true
})
// A populated store has ≥1 two-hex shard dir (00ff). The vestigial
// `hnsw` subdir is 4 chars and correctly excluded by the hex test.
return entries.some(
(e: any) => e.isDirectory() && /^[0-9a-f]{2}$/i.test(e.name)
)
} catch {
// Directory absent (fresh store) or unreadable → no persisted entities.
return false
}
}
/**
* Check if a file exists (handles both sharded and non-sharded)
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK)
return true
} catch {
return false
}
}
// =============================================
// HNSW Index Persistence
// =============================================
/**
* Get vector for a noun
* Uses BaseStorage's getNoun (type-first paths)
*/
public async getNounVector(id: string): Promise<number[] | null> {
const noun = await this.getNoun(id)
return noun ? noun.vector : null
}
/**
* Save HNSW graph data for a noun
*
* Uses BaseStorage's getNoun/saveNoun (type-first paths)
* CRITICAL: Preserves mutex locking to prevent read-modify-write races
*/
public async saveVectorIndexData(nounId: string, hnswData: {
level: number
connections: Record<string, string[]>
}): Promise<void> {
const lockKey = `hnsw/${nounId}`
// CRITICAL FIX: Mutex lock to prevent read-modify-write races
// Problem: Without mutex, concurrent operations can:
// 1. Thread A reads noun (connections: [1,2,3])
// 2. Thread B reads noun (connections: [1,2,3])
// 3. Thread A adds connection 4, writes [1,2,3,4]
// 4. Thread B adds connection 5, writes [1,2,3,5] ← Connection 4 LOST!
// Solution: Mutex serializes operations per entity (like Memory/OPFS adapters)
// Production scale: Prevents corruption at 1000+ concurrent operations
// Wait for any pending operations on this entity
while (this.hnswLocks.has(lockKey)) {
await this.hnswLocks.get(lockKey)
}
// Acquire lock
let releaseLock!: () => void
const lockPromise = new Promise<void>(resolve => { releaseLock = resolve })
this.hnswLocks.set(lockKey, lockPromise)
try {
// Use BaseStorage's getNoun (type-first paths)
// Read existing noun data (if exists)
const existingNoun = await this.getNoun(nounId)
if (!existingNoun) {
// Noun doesn't exist - cannot update HNSW data for non-existent noun
throw new Error(`Cannot save HNSW data: noun ${nounId} not found`)
}
// Convert connections from Record to Map format for storage
const connectionsMap = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(hnswData.connections)) {
connectionsMap.set(Number(level), new Set(nodeIds))
}
// Preserve id and vector, update only HNSW graph metadata
const updatedNoun: HNSWNoun = {
...existingNoun,
level: hnswData.level,
connections: connectionsMap
}
// Use BaseStorage's saveNoun (type-first paths, atomic write)
await this.saveNoun(updatedNoun)
} finally {
// Release lock (ALWAYS runs, even if error thrown)
this.hnswLocks.delete(lockKey)
releaseLock()
}
}
/**
* Get HNSW graph data for a noun
* Uses BaseStorage's getNoun (type-first paths)
*/
public async getVectorIndexData(nounId: string): Promise<{
level: number
connections: Record<string, string[]>
} | null> {
const noun = await this.getNoun(nounId)
if (!noun) {
return null
}
// Convert connections from Map to Record format
const connectionsRecord: Record<string, string[]> = {}
if (noun.connections) {
for (const [level, nodeIds] of noun.connections.entries()) {
connectionsRecord[String(level)] = Array.from(nodeIds)
}
}
return {
level: noun.level || 0,
connections: connectionsRecord
}
}
/**
* Save HNSW system data (entry point, max level)
*
* CRITICAL FIX: Mutex lock + atomic write to prevent race conditions
*/
public async saveHNSWSystem(systemData: {
entryPointId: string | null
maxLevel: number
}): Promise<void> {
await this.ensureInitialized()
const lockKey = 'hnsw/system'
// CRITICAL FIX: Mutex lock to serialize system updates
// System data (entry point, max level) updated frequently during HNSW construction
// Without mutex, concurrent updates can lose data (same as entity-level problem)
// Wait for any pending system updates
while (this.hnswLocks.has(lockKey)) {
await this.hnswLocks.get(lockKey)
}
// Acquire lock
let releaseLock!: () => void
const lockPromise = new Promise<void>(resolve => { releaseLock = resolve })
this.hnswLocks.set(lockKey, lockPromise)
try {
const filePath = path.join(this.systemDir, 'hnsw-system.json')
const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(2)}`
try {
// Write to temp file
await this.ensureDirectoryExists(path.dirname(tempPath))
await fs.promises.writeFile(tempPath, JSON.stringify(systemData, null, 2))
// Atomic rename temp → final (POSIX atomicity guarantee)
await fs.promises.rename(tempPath, filePath)
} catch (error: any) {
// Clean up temp file on any error
try {
await fs.promises.unlink(tempPath)
} catch (cleanupError) {
// Ignore cleanup errors
}
throw error
}
} finally {
// Release lock
this.hnswLocks.delete(lockKey)
releaseLock()
}
}
/**
* Get HNSW system data
*/
public async getHNSWSystem(): Promise<{
entryPointId: string | null
maxLevel: number
} | null> {
await this.ensureInitialized()
const filePath = path.join(this.systemDir, 'hnsw-system.json')
try {
const data = await fs.promises.readFile(filePath, 'utf-8')
return JSON.parse(data)
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error('Error reading HNSW system data:', error)
}
return null
}
}
}