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
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue