diff --git a/src/brainy.ts b/src/brainy.ts index 20dccbfa..6d04f78d 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -2342,10 +2342,42 @@ export class Brainy implements BrainyInterface { } if (this._persistIdleTimer) clearTimeout(this._persistIdleTimer) + this.armIdleFlushTimer(idleMs, intervalMs) + } + + /** + * @description Arm the idle-flush timer — DEBOUNCED UNDER LOAD. The idle + * trigger exists to make a QUIET system durable fast; it must never add + * flush pressure to a BUSY one. When individual writes are slower than + * the idle window (a contended disk), every inter-write gap looks like + * "idle" and would fire a full flush per write — a measured 15-flush + * amplifier during 100 contended adds on a production-shaped box. The + * law: an idle fire landing within `intervalMs` of the last flush DEFERS + * (re-arms for the remaining interval) rather than flushing — deferred, + * never dropped, so a lone write on a then-quiet system still persists at + * the interval boundary without any further write arriving; a genuinely + * quiet system (last flush long past) flushes on idle exactly as before. + */ + private armIdleFlushTimer(idleMs: number, intervalMs: number, delayMs = idleMs): void { + // The idle-fire spacing floor: 10× the CONFIGURED idle window, capped by + // the interval — always derived from idleMs, never from a deferred + // re-arm delay (recomputing from the delay compounds into runaway + // deferral). Scales with intent — a caller configuring a tiny idle + // window gets fast idle-driven durability (small floor); default config + // (2s idle / 30s interval) gets a 20s floor, capping the contended-disk + // shape at ~1 idle flush per 20s instead of one per inter-write gap. + const floorMs = Math.min(intervalMs, idleMs * 10) const timer = setTimeout(() => { this._persistIdleTimer = null - if (this._persistDirtyWrites > 0) this.kickBackgroundFlush('idle') - }, idleMs) + if (this._persistDirtyWrites === 0) return + const sinceFlush = Date.now() - this._persistLastFlushAt + if (sinceFlush >= floorMs) { + this.kickBackgroundFlush('idle') + } else { + // Deferred, never dropped: land exactly at the floor boundary. + this.armIdleFlushTimer(idleMs, intervalMs, Math.max(idleMs, floorMs - sinceFlush)) + } + }, delayMs) // Never hold the process open for a cadence timer. ;(timer as { unref?: () => void }).unref?.() this._persistIdleTimer = timer diff --git a/tests/unit/brainy/persistence-policy.test.ts b/tests/unit/brainy/persistence-policy.test.ts index 98a0afc2..92bb4e3c 100644 --- a/tests/unit/brainy/persistence-policy.test.ts +++ b/tests/unit/brainy/persistence-policy.test.ts @@ -61,6 +61,30 @@ describe('persistence policy — the engine owns its flush cadence', () => { await vi.waitFor(() => expect(flushSpy).toHaveBeenCalled(), { timeout: 5000 }) }) + it('idle debounce under load: slow writes never fire a flush per inter-write gap', async () => { + // The contended-disk amplifier: writes slower than the idle window make + // every gap look idle — without the spacing floor this fired a full + // flush per write (measured 15 background flushes in 100 contended adds + // on a production-shaped box). The floor (min(interval, 10×idle)) caps + // idle fires; deferred, never dropped. + const brain = await mk({ flushEveryWrites: 10_000, flushIntervalMs: 600_000, flushOnIdleMs: 50 }) + const flushSpy = vi.spyOn(brain, 'flush') + + // Six writes spaced wider than the idle window (50ms) with the whole + // span inside ~one floor window (500ms): the old behavior fires ~an + // idle flush per gap (≈6); the debounced behavior fires at most two + // (one immediate boot-window fire + one at the floor boundary). + for (let i = 0; i < 6; i++) { + await brain.add({ data: `slow ${i}`, type: NounType.Document, metadata: {} }) + await new Promise((r) => setTimeout(r, 70)) + } + expect(flushSpy.mock.calls.length, 'no flush-per-gap amplifier').toBeLessThanOrEqual(2) + + // Deferred, never dropped: the dirty writes still persist once the + // floor elapses on the now-quiet store. + await vi.waitFor(() => expect(flushSpy).toHaveBeenCalled(), { timeout: 5000 }) + }) + it("'manual' policy: the engine NEVER flushes on its own", async () => { const brain = await mk({ policy: 'manual', flushEveryWrites: 2, flushOnIdleMs: 30 }) const flushSpy = vi.spyOn(brain, 'flush')