feat: scanFacts liveness contract — first batch or loud failure within a documented bound
Stage-2 D1 contract item (co-frozen): a fact scan may be slow, never silent. batches() now races its FIRST pull against SCANFACTS_FIRST_BATCH_MS (10s, exported; test-overridable) — a wedged or unreadably slow store produces a loud abort naming the contract instead of a consumer hanging indistinguishably from progress (the production shape: a heal against a generations-backlogged brain wedged silently on the first segment read). Only the first pull is raced: the bound is time-to-first-batch (proof the producer is alive), not per-batch pacing, and it runs only while a pull is pending — consumer think-time between pulls never counts against the producer (pinned). Three pins: wedged-store loud failure within the bound, healthy scan untouched end-to-end, slow-consumer immunity.
This commit is contained in:
parent
d08679fc84
commit
f8e6da2b66
3 changed files with 99 additions and 2 deletions
|
|
@ -186,4 +186,52 @@ describe('fact log — round-trip, framing, reconcile, rotation, scan', () => {
|
|||
await log.sync()
|
||||
expect(log.segmentPaths()).toEqual([]) // only a tail exists — nothing sealed
|
||||
})
|
||||
|
||||
describe('scanFacts liveness contract (Stage-2 D1)', () => {
|
||||
it('a wedged store fails LOUDLY within the first-batch bound — never a silent hang', async () => {
|
||||
// Force a sealed segment (tiny rotateBytes) so the scan must READ from
|
||||
// storage, then wedge that read: the exact production shape (a
|
||||
// backlogged brain whose segment read never returned).
|
||||
const mem: any = new MemoryStorage()
|
||||
await mem.init()
|
||||
const wedgeable = new FactLog(mem, { rotateBytes: 1 })
|
||||
await wedgeable.open(0)
|
||||
await wedgeable.append(fact(1))
|
||||
await wedgeable.append(fact(2)) // second append rotates → seg 1 sealed
|
||||
await wedgeable.sync()
|
||||
|
||||
const realRead = mem.readRawBytes.bind(mem)
|
||||
mem.readRawBytes = (p: string) =>
|
||||
p.includes('facts/seg-') ? new Promise(() => {}) : realRead(p) // hangs forever
|
||||
|
||||
const scan = wedgeable.scanFacts({ firstBatchTimeoutMs: 200 })
|
||||
const started = Date.now()
|
||||
await expect(scan.batches().next()).rejects.toThrow(/no first batch within 200ms/)
|
||||
expect(Date.now() - started).toBeLessThan(5_000) // bound held, not a hang
|
||||
})
|
||||
|
||||
it('a healthy scan is unaffected — first batch well inside the bound, all facts delivered', async () => {
|
||||
for (let g = 1; g <= 5; g++) await log.append(fact(g))
|
||||
await log.sync()
|
||||
const scan = log.scanFacts({ batchSize: 2 })
|
||||
const all: CommitFact[] = []
|
||||
for await (const b of scan.batches()) all.push(...b.facts)
|
||||
expect(all.map((f) => f.generation)).toEqual([1, 2, 3, 4, 5])
|
||||
expect(scan.summary().factsYielded).toBe(5)
|
||||
})
|
||||
|
||||
it('consumer think-time between pulls never counts against the producer', async () => {
|
||||
for (let g = 1; g <= 4; g++) await log.append(fact(g))
|
||||
await log.sync()
|
||||
// Bound tighter than the consumer's pause: only the FIRST pull is
|
||||
// raced, so a slow consumer after batch 1 must not trip the deadline.
|
||||
const gen = log.scanFacts({ batchSize: 2, firstBatchTimeoutMs: 150 }).batches()
|
||||
const first = await gen.next()
|
||||
expect(first.done).toBe(false)
|
||||
await new Promise((r) => setTimeout(r, 400)) // dawdle past the bound
|
||||
const second = await gen.next()
|
||||
expect(second.done).toBe(false)
|
||||
expect((await gen.next()).done).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue