2026-01-31 09:09:36 -08:00
|
|
|
/**
|
|
|
|
|
* Metadata Write Buffer — Deduplicates rapid writes to the same cloud storage path.
|
|
|
|
|
*
|
|
|
|
|
* When multiple brain.add() calls happen in rapid succession (e.g., chat: store message
|
|
|
|
|
* + create conversation + auto-title), the SAME sparse index and chunk files get written
|
|
|
|
|
* repeatedly. This buffer deduplicates writes to the same cloud storage path across
|
|
|
|
|
* multiple operations using a time-windowed buffer.
|
|
|
|
|
*
|
|
|
|
|
* Latest data wins — if the same path is written 5 times in 200ms, only the final
|
|
|
|
|
* version is actually sent to cloud storage.
|
|
|
|
|
*
|
|
|
|
|
* NOT used by FileSystem adapter — local writes are already fast (~1ms), and buffering
|
|
|
|
|
* would add unnecessary latency.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { prodLog } from './logger.js'
|
|
|
|
|
|
|
|
|
|
export class MetadataWriteBuffer {
|
|
|
|
|
private pendingWrites = new Map<string, any>()
|
|
|
|
|
private flushTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
private isFlushing = false
|
|
|
|
|
private pendingFlushPromise: Promise<void> | null = null
|
|
|
|
|
private writeFunction: (path: string, data: any) => Promise<void>
|
|
|
|
|
|
|
|
|
|
private maxBufferSize: number
|
|
|
|
|
private flushIntervalMs: number
|
|
|
|
|
private concurrencyLimit: number
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
writeFunction: (path: string, data: any) => Promise<void>,
|
|
|
|
|
options?: {
|
|
|
|
|
maxBufferSize?: number
|
|
|
|
|
flushIntervalMs?: number
|
|
|
|
|
concurrencyLimit?: number
|
|
|
|
|
}
|
|
|
|
|
) {
|
|
|
|
|
this.writeFunction = writeFunction
|
|
|
|
|
this.maxBufferSize = options?.maxBufferSize ?? 200
|
|
|
|
|
this.flushIntervalMs = options?.flushIntervalMs ?? 200
|
|
|
|
|
this.concurrencyLimit = options?.concurrencyLimit ?? 10
|
|
|
|
|
this.startPeriodicFlush()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Buffer a write to the given path. Latest data wins — if the same path
|
|
|
|
|
* is written multiple times before flush, only the last version is sent.
|
|
|
|
|
*/
|
|
|
|
|
async write(path: string, data: any): Promise<void> {
|
|
|
|
|
this.pendingWrites.set(path, data)
|
|
|
|
|
|
|
|
|
|
if (this.pendingWrites.size >= this.maxBufferSize) {
|
|
|
|
|
await this.flush()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Flush all pending writes to cloud storage.
|
|
|
|
|
* Respects concurrency limits to avoid overwhelming the cloud API.
|
|
|
|
|
*/
|
|
|
|
|
async flush(): Promise<void> {
|
|
|
|
|
if (this.isFlushing) {
|
|
|
|
|
if (this.pendingFlushPromise) await this.pendingFlushPromise
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (this.pendingWrites.size === 0) return
|
|
|
|
|
|
|
|
|
|
this.isFlushing = true
|
|
|
|
|
const writes = new Map(this.pendingWrites)
|
|
|
|
|
this.pendingWrites.clear()
|
|
|
|
|
|
|
|
|
|
this.pendingFlushPromise = this.doFlush(writes)
|
|
|
|
|
try {
|
|
|
|
|
await this.pendingFlushPromise
|
|
|
|
|
} finally {
|
|
|
|
|
this.isFlushing = false
|
|
|
|
|
this.pendingFlushPromise = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async doFlush(writes: Map<string, any>): Promise<void> {
|
|
|
|
|
const entries = Array.from(writes.entries())
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < entries.length; i += this.concurrencyLimit) {
|
|
|
|
|
const batch = entries.slice(i, i + this.concurrencyLimit)
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
batch.map(([path, data]) => this.writeFunction(path, data))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Log failures but don't throw — individual write errors are handled by retry logic
|
|
|
|
|
for (let j = 0; j < results.length; j++) {
|
|
|
|
|
if (results[j].status === 'rejected') {
|
|
|
|
|
const [path] = batch[j]
|
|
|
|
|
prodLog.warn(`MetadataWriteBuffer: failed to write ${path}:`, (results[j] as PromiseRejectedResult).reason)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private startPeriodicFlush(): void {
|
|
|
|
|
this.flushTimer = setInterval(() => {
|
|
|
|
|
if (this.pendingWrites.size > 0) {
|
|
|
|
|
this.flush().catch((err) => {
|
|
|
|
|
prodLog.warn('MetadataWriteBuffer: periodic flush error:', err)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}, this.flushIntervalMs)
|
fix: no script shape can hang on brainy's internals — unref every maintenance timer + one-shot beforeExit
A consumer's clean-room verification of the 8.0.10 fix found relate() still
hanging their scripts. Root-causing the CLASS instead of the repro found two
mechanisms:
1. The 'beforeExit' auto-flush hook looped forever on any script that never
reaches close(): Node re-emits beforeExit after every event-loop drain, and
the async flush schedules new work — flush, drain, flush, forever. The
listener now self-deregisters BEFORE its one flush, so the next drain exits.
(Empirically: process.on('beforeExit', async () => await anything) alone
never exits — this was the deepest root of the whole hang class.)
2. Every background-maintenance interval is now unref'd at creation — graph
auto-flush, LSM compaction, metadata write-buffer flush, VFS cache
maintenance, PathResolver cache maintenance, statistics debounce (the
writer-lock heartbeat, flush watcher, and cache monitors already were).
Durability is owned by close() and the beforeExit flush, both deterministic;
a best-effort interval must never keep the host process alive.
Proof: the reported shape (add x2 + relate, filesystem) exits cleanly BOTH
with close() (~0.5 s) and with NO teardown at all — and in the no-teardown
case the beforeExit flush still lands the data (verified by reopen: both
nouns + the edge present). New per-op-class sweep test asserts no ref'd
timer survives close() for add / relate / graph find / metadata update /
vfs — turning this bug class off permanently instead of per-repro.
2026-07-02 17:26:22 -07:00
|
|
|
// Best-effort background flush — must not keep the process alive
|
|
|
|
|
// (close() drains the buffer for durability).
|
|
|
|
|
if (this.flushTimer && typeof this.flushTimer.unref === 'function') {
|
|
|
|
|
this.flushTimer.unref()
|
|
|
|
|
}
|
2026-01-31 09:09:36 -08:00
|
|
|
|
|
|
|
|
// Prevent timer from keeping the process alive
|
|
|
|
|
if (this.flushTimer && typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
|
|
|
|
|
this.flushTimer.unref()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Drain all pending writes and stop the periodic flush timer.
|
|
|
|
|
* Must be called during close/destroy to ensure all data is written.
|
|
|
|
|
*/
|
|
|
|
|
async destroy(): Promise<void> {
|
|
|
|
|
if (this.flushTimer) {
|
|
|
|
|
clearInterval(this.flushTimer)
|
|
|
|
|
this.flushTimer = null
|
|
|
|
|
}
|
|
|
|
|
await this.flush()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the number of pending writes in the buffer.
|
|
|
|
|
*/
|
|
|
|
|
get size(): number {
|
|
|
|
|
return this.pendingWrites.size
|
|
|
|
|
}
|
|
|
|
|
}
|