2026-08-05 16:00:39 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @module tests/unit/brainy/persistence-policy
|
|
|
|
|
|
* @description THE ENGINE-OWNED FLUSH CADENCE pins (A4,
|
|
|
|
|
|
* SELF-ENGINE-LIFECYCLE-SPRINT, David-directed: callers NEVER call flush()
|
|
|
|
|
|
* in hot paths). The production disease: 829 caller-scheduled per-write
|
|
|
|
|
|
* flushes convoying into 45–66 second write walls — cadence hand-rolled a
|
|
|
|
|
|
* layer above the only layer that can see dirty state and IO pressure.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Pinned here: (1) the write-count trigger fires a BACKGROUND flush without
|
|
|
|
|
|
* any caller flush(); (2) the idle trigger; (3) `'manual'` restores
|
|
|
|
|
|
* caller-owned cadence exactly; (4) THE ACK LAW — a write acknowledges
|
|
|
|
|
|
* without awaiting any background flush, even one that never resolves.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
|
|
|
|
import { Brainy } from '../../../src/index.js'
|
|
|
|
|
|
import { NounType } from '../../../src/types/graphTypes.js'
|
|
|
|
|
|
|
|
|
|
|
|
const brains: Brainy[] = []
|
|
|
|
|
|
|
|
|
|
|
|
async function mk(persistence?: {
|
|
|
|
|
|
policy?: 'auto' | 'manual'
|
|
|
|
|
|
flushEveryWrites?: number
|
|
|
|
|
|
flushIntervalMs?: number
|
|
|
|
|
|
flushOnIdleMs?: number
|
|
|
|
|
|
}): Promise<Brainy> {
|
|
|
|
|
|
const b = new Brainy({
|
|
|
|
|
|
storage: { type: 'memory' },
|
|
|
|
|
|
requireSubtype: false,
|
|
|
|
|
|
...(persistence && { persistence })
|
|
|
|
|
|
})
|
|
|
|
|
|
await b.init()
|
|
|
|
|
|
brains.push(b)
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
|
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('persistence policy — the engine owns its flush cadence', () => {
|
|
|
|
|
|
it('write-count trigger: N committed writes fire ONE background flush, no caller flush()', async () => {
|
|
|
|
|
|
const brain = await mk({ flushEveryWrites: 5, flushOnIdleMs: 60_000, flushIntervalMs: 600_000 })
|
|
|
|
|
|
const flushSpy = vi.spyOn(brain, 'flush')
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
|
await brain.add({ data: `w${i}`, type: NounType.Document, metadata: { i } })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => expect(flushSpy).toHaveBeenCalled(), { timeout: 5000 })
|
|
|
|
|
|
// Single-flight: the threshold crossing kicks exactly one.
|
|
|
|
|
|
expect(flushSpy.mock.calls.length).toBe(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('idle trigger: a quiet store with dirty writes flushes itself', async () => {
|
|
|
|
|
|
const brain = await mk({ flushEveryWrites: 10_000, flushIntervalMs: 600_000, flushOnIdleMs: 60 })
|
|
|
|
|
|
const flushSpy = vi.spyOn(brain, 'flush')
|
|
|
|
|
|
|
|
|
|
|
|
await brain.add({ data: 'lone write', type: NounType.Document, metadata: {} })
|
|
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => expect(flushSpy).toHaveBeenCalled(), { timeout: 5000 })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-10 12:15:02 -07:00
|
|
|
|
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 })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-05 16:00:39 -07:00
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
|
await brain.add({ data: `m${i}`, type: NounType.Document, metadata: { i } })
|
|
|
|
|
|
}
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 150))
|
|
|
|
|
|
|
|
|
|
|
|
expect(flushSpy).not.toHaveBeenCalled()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('THE ACK LAW: writes acknowledge without awaiting the background flush — even a hung one', async () => {
|
|
|
|
|
|
const brain = await mk({ flushEveryWrites: 2, flushOnIdleMs: 60_000, flushIntervalMs: 600_000 })
|
|
|
|
|
|
// A flush that NEVER resolves: if any write ack awaited it, the test
|
|
|
|
|
|
// would time out. (The engine's background flight must be fire-and-log.)
|
|
|
|
|
|
vi.spyOn(brain, 'flush').mockImplementation(() => new Promise<void>(() => {}))
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
|
const id = await brain.add({ data: `a${i}`, type: NounType.Document, metadata: { i } })
|
|
|
|
|
|
expect(id).toBeTruthy()
|
|
|
|
|
|
}
|
|
|
|
|
|
// All six writes acked while the "flush" hangs forever.
|
|
|
|
|
|
const rows = await brain.find({ type: NounType.Document, limit: 10 })
|
|
|
|
|
|
expect(rows.length).toBe(6)
|
|
|
|
|
|
|
|
|
|
|
|
// Un-hang before afterEach close(): restore the method AND drop the
|
|
|
|
|
|
// never-resolving in-flight promise (close() awaits the flight — with a
|
|
|
|
|
|
// real flush that is correct; here it is the test's own artifact).
|
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
|
;(brain as unknown as { _persistBackgroundFlight: Promise<void> | null })._persistBackgroundFlight =
|
|
|
|
|
|
null
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|