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:
David Snelling 2026-07-19 14:54:36 -07:00
parent d08679fc84
commit f8e6da2b66
3 changed files with 99 additions and 2 deletions

View file

@ -102,12 +102,26 @@ export interface FactScanBatch {
segmentId: string
}
/**
* Liveness bound on a scan's FIRST batch (Stage-2 co-freeze, D1 contract):
* `batches()` must yield its first batch or fail loudly within this many
* ms of the first pull. A backlogged or damaged store may be SLOW, but it may
* never be SILENT: a consumer awaiting the first batch is otherwise
* indistinguishable from a wedge (the exact failure shape a production heal
* hit against a generations-backlogged brain).
*/
export const SCANFACTS_FIRST_BATCH_MS = 10_000
/** The telemetry a scan OPEN returns (frozen shape). */
export interface FactScanHandle {
headGeneration: number
segmentCount: number
approxFactCount: number
/** Ordered batches; a detected gap aborts LOUDLY, never a silent skip. */
/**
* Ordered batches; a detected gap aborts LOUDLY, never a silent skip.
* Liveness contract: the FIRST batch resolves or rejects within
* {@link SCANFACTS_FIRST_BATCH_MS} of the first pull never a silent hang.
*/
batches: () => AsyncGenerator<FactScanBatch>
/** Close telemetry — the invariant cross-check, valid after iteration ends. */
summary: () => { factsYielded: number; segmentsRead: number }
@ -440,6 +454,8 @@ export class FactLog {
toGeneration?: number
kinds?: Array<'noun' | 'verb'>
batchSize?: number
/** Test override for the first-batch liveness bound (default {@link SCANFACTS_FIRST_BATCH_MS}). */
firstBatchTimeoutMs?: number
}): FactScanHandle {
const from = options?.fromGeneration ?? 1
const to = options?.toGeneration ?? this.head
@ -514,11 +530,43 @@ export class FactLog {
}
}
// Liveness wrapper: the FIRST pull races the contract deadline. Only the
// first — the bound is time-to-first-batch (proof the producer is alive),
// not per-batch pacing; and it runs only while a pull is actually pending,
// so consumer think-time between pulls never counts against the producer.
const firstBatchTimeoutMs = options?.firstBatchTimeoutMs ?? SCANFACTS_FIRST_BATCH_MS
async function* batchesWithLiveness(this: void): AsyncGenerator<FactScanBatch> {
const inner = batches()
let timer: NodeJS.Timeout | undefined
try {
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`fact log: scanFacts produced no first batch within ${firstBatchTimeoutMs}ms ` +
`(liveness contract) — the store is wedged or unreadably slow; aborting scan LOUDLY ` +
`instead of hanging the consumer.`
)
),
firstBatchTimeoutMs
)
timer.unref?.()
})
const first = await Promise.race([inner.next(), deadline])
if (first.done) return
yield first.value
} finally {
clearTimeout(timer)
}
yield* inner
}
return {
headGeneration: this.head,
segmentCount: segments.length + (tailSnapshot.length > 0 ? 1 : 0),
approxFactCount,
batches,
batches: batchesWithLiveness,
summary: () => ({ factsYielded, segmentsRead })
}
}

View file

@ -213,6 +213,7 @@ export type {
CommitFact,
FactOp,
FactScanBatch,
SCANFACTS_FIRST_BATCH_MS,
FactScanHandle
} from './db/factLog.js'
// The generalized family stamp — which source generation a projection