fix(flush): the gate settles its waiter from the machine, never from a chain
Some checks failed
CI / Node 22 (push) Successful in 12m26s
CI / Node 24 (push) Successful in 12m19s
CI / Bun (latest) (push) Successful in 12m28s
CI / Integration + conformance (Node 22) (push) Failing after 17m12s

The single-flight gate queued its follow-up as
`leader.catch().then(() => this.flush())`. That waiter is settled ONLY by
resolving the very promise the leader is being awaited through, so the moment
anything inside a flush body awaits flush(), the promise graph closes on itself
and nobody resolves — an unbounded hang, not a slow flush, presenting exactly
like a bulk write timing out. No current call site awaits a flush from inside
one, so this is a latent cycle rather than an observed one; the gate should not
depend on that staying true.

The queue is now a bare deferred. The leader's finally opens the gate and
PROMOTES the waiter to a new leader, settling the deferred from that run; the
finally returns nothing, so the leader never awaits its own follower. Every
exit runs the same promotion — the leader resolving, the leader rejecting, the
promoted run rejecting — so a queued caller is settled exactly once on every
path, and a synchronous failure starting the promoted run is reported to the
waiter instead of thrown into the leader's finally. close() drains both handles.

tests/unit/brainy/flush-single-flight.test.ts pins the invariant on each path
that must settle a waiter: many callers during one flush all resolve within a
bound (one body, one follow-up, peak concurrency 1); a REJECTING leader still
runs and settles the queued waiter; a rejecting follow-up settles its waiter
and leaves the gate open; and the leader returns without waiting for a
deliberately slower follower.
This commit is contained in:
David Snelling 2026-09-02 12:33:59 -07:00
parent ebb3a4bf13
commit dea3ec2031
3 changed files with 243 additions and 18 deletions

View file

@ -0,0 +1,175 @@
/**
* @module tests/unit/brainy/flush-single-flight
* @description THE FLUSH GATE NEVER STRANDS A WAITER.
*
* The gate serialises flushes: one body runs, at most one waits. The failure
* mode that shape invites is a promise CYCLE a queued follow-up expressed as
* `leader.then(() => this.flush())` is settled only by resolving the promise
* the leader is being awaited through, so anything that awaits `flush()` from
* inside a flush body closes the graph on itself and nobody ever resolves.
* That is an unbounded hang, not a slow flush, and it presents exactly like a
* test timing out inside a bulk write.
*
* The gate therefore settles its waiter from the MACHINE (a bare deferred
* promoted in the leader's `finally`), never from a chain. The laws pinned
* here, each on a path that must settle the waiter:
*
* (a) many callers during one running flush one body, one follow-up, and
* EVERY caller resolves within a bound;
* (b) the leader REJECTS its own caller rejects, and the queued caller is
* still run and still settled;
* (c) the promoted follow-up itself rejects its waiter rejects (settled,
* not stranded) and the gate is left open for the next flush;
* (d) the leader's promise does not wait for its follower.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy'
import { NounType } from '../../../src/types/graphTypes'
type GateInternals = {
_flushInFlight: Promise<void> | null
_flushQueued: Promise<void> | null
_flushBodyRuns: number
_flushConcurrencyPeak: number
_flushSteps: () => Promise<void>
kickBackgroundFlush: (reason: 'threshold' | 'idle') => void
}
/** Fail loudly rather than hanging the suite: a stranded waiter never settles. */
function withinBound<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>
return Promise.race([
p,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${what} did not settle within ${ms}ms`)), ms)
})
]).finally(() => clearTimeout(timer)) as Promise<T>
}
describe('the flush gate settles every waiter', () => {
const brains: Brainy<any>[] = []
afterEach(async () => {
for (const b of brains.splice(0)) {
try { await b.close() } catch { /* already closed */ }
}
})
async function openBrain(): Promise<Brainy<any>> {
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
brains.push(brain)
await brain.init()
await brain.add({ data: 'a write, so a flush has work', type: NounType.Thing })
return brain
}
it('(a) every caller arriving during one flush resolves, and only one follows', async () => {
const brain = await openBrain()
const inner = brain as unknown as GateInternals
const realSteps = inner._flushSteps.bind(inner)
inner._flushSteps = async () => {
await new Promise((r) => setTimeout(r, 120))
return realSteps()
}
const runsBefore = inner._flushBodyRuns
const leader = brain.flush()
await new Promise((r) => setTimeout(r, 20))
const joiners = [brain.flush(), brain.flush(), brain.flush(), brain.flush()]
for (let i = 0; i < 4; i++) inner.kickBackgroundFlush('threshold')
expect(inner._flushQueued, 'exactly one waiter is queued').not.toBeNull()
await withinBound(Promise.all([leader, ...joiners]), 15_000, 'the flush callers')
expect(inner._flushBodyRuns - runsBefore).toBe(2)
expect(inner._flushConcurrencyPeak).toBe(1)
expect(inner._flushQueued).toBeNull()
})
it('(b) a leader that REJECTS still runs and settles the queued waiter', async () => {
const brain = await openBrain()
const inner = brain as unknown as GateInternals
const realSteps = inner._flushSteps.bind(inner)
let call = 0
inner._flushSteps = async () => {
call++
await new Promise((r) => setTimeout(r, 80))
if (call === 1) throw new Error('injected: the leader flush failed')
return realSteps()
}
const leader = brain.flush()
await new Promise((r) => setTimeout(r, 20))
const queued = brain.flush()
await expect(leader).rejects.toThrow(/injected: the leader flush failed/)
// The waiter is NOT collateral damage of the leader's failure: it gets its
// own run, and it settles.
await withinBound(queued, 15_000, 'the queued waiter after a failed leader')
expect(call).toBe(2)
expect(inner._flushQueued).toBeNull()
expect(inner._flushInFlight).toBeNull()
})
it('(c) a promoted follow-up that rejects settles its waiter and opens the gate', async () => {
const brain = await openBrain()
const inner = brain as unknown as GateInternals
const realSteps = inner._flushSteps.bind(inner)
let call = 0
inner._flushSteps = async () => {
call++
await new Promise((r) => setTimeout(r, 80))
if (call === 2) throw new Error('injected: the follow-up flush failed')
return realSteps()
}
const leader = brain.flush()
await new Promise((r) => setTimeout(r, 20))
const queued = brain.flush()
await withinBound(leader, 15_000, 'the leader')
await withinBound(
expect(queued).rejects.toThrow(/injected: the follow-up flush failed/),
15_000,
'the rejected follow-up'
)
// The gate is open: a later flush still runs.
inner._flushSteps = realSteps
await brain.add({ data: 'another write', type: NounType.Thing })
await withinBound(brain.flush(), 15_000, 'the flush after a failed follow-up')
expect(inner._flushInFlight).toBeNull()
expect(inner._flushQueued).toBeNull()
})
it('(d) the leader does not wait for its follower', async () => {
const brain = await openBrain()
const inner = brain as unknown as GateInternals
const realSteps = inner._flushSteps.bind(inner)
let call = 0
inner._flushSteps = async () => {
call++
// The follow-up is deliberately far slower than the leader.
await new Promise((r) => setTimeout(r, call === 1 ? 60 : 600))
return realSteps()
}
const leader = brain.flush()
await new Promise((r) => setTimeout(r, 20))
const queued = brain.flush()
const t0 = Date.now()
await withinBound(leader, 15_000, 'the leader')
const leaderWall = Date.now() - t0
// If the leader awaited its follower it could not return before the
// follower's own 600ms body had run.
expect(leaderWall).toBeLessThan(500)
await withinBound(queued, 15_000, 'the follower')
})
})