Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ _| _| _| _| _| _| _| _| _| _| _| _|

[![CI](https://github.com/halaprix/domino/actions/workflows/ci.yml/badge.svg)](https://github.com/halaprix/domino/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@halaprix/domino)](https://www.npmjs.com/package/@halaprix/domino)
[![bundle size](https://img.shields.io/badge/gzip-11.5KB-brightgreen)](https://www.npmjs.com/package/@halaprix/domino)
[![bundle size](https://img.shields.io/badge/gzip-12.2KB-brightgreen)](https://www.npmjs.com/package/@halaprix/domino)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue)](https://www.typescriptlang.org/)
[![MIT License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Expand Down
558 changes: 558 additions & 0 deletions src/__tests__/bisection.test.ts

Large diffs are not rendered by default.

57 changes: 42 additions & 15 deletions src/core/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
* back into per-task `StepResult[]` arrays.
*
* The ONLY behavior that differs between `run` (fail-fast) and `runSettled`
* (record-and-continue) is captured in the 3-hook `StepEnginePolicy` each
* runner builds for itself, closing over its own state (`ts`, and — for
* (record-and-continue) is captured in the `StepEnginePolicy` each runner
* builds for itself, closing over its own state (`ts`, and — for
* `runSettled` only — its `dead`/`deadError` bookkeeping). `runSteps` never
* sees that state; it just calls the hooks and trusts their return values.
* F6b adds one more (optional) hook, `recordTerminal` — see its doc comment
* below and `src/core/pool.ts`'s "Terminal policy hook" section for the full
* adaptive-bisection design.
*
* What stays OUTSIDE this module (deliberately): the F2 consumption
* pipeline (`prepareRun`/`resolvePinnedBlock`, `internal.ts`) and the final
Expand All @@ -22,9 +25,10 @@ import type { MultistepTask, StepCall, StepResult, RawResult } from './types'
import { runBatchPool } from './pool'

/**
* The 3 hooks that fully capture `run` vs `runSettled`'s divergence. Each
* runner builds one instance of this per call, as plain closures over its
* own local state — see `runMultistepTasks.ts`/`runSettled.ts`.
* The hooks that fully capture `run` vs `runSettled`'s divergence — 3 as of
* F6a, plus the optional `recordTerminal` added by F6b. Each runner builds
* one instance of this per call, as plain closures over its own local state
* — see `runMultistepTasks.ts`/`runSettled.ts`.
*/
export interface StepEnginePolicy {
/**
Expand All @@ -43,14 +47,19 @@ export interface StepEnginePolicy {
/**
* Execute ONE physical batch of calls. This is the only hook
* `runBatchPool` actually calls, so it is the only one whose rejection
* behavior matters for cancellation:
* behavior matters for cancellation/bisection:
* - `run`'s hook is the raw, unwrapped `executor.executeMulticall` call
* (plus the length-mismatch guard) — a rejection here is exactly what
* triggers fail-fast.
* - `runSettled`'s hook catches a transport rejection and *resolves*
* with synthesized per-call `kind:'batch'` failures instead; it only
* ever rejects for the length-mismatch executor-bug case (which is
* deliberately NOT converted into a recorded failure).
* — a rejection here is exactly what feeds the pool's bisection (F6b)
* and, once terminal, its fail-fast cancellation. The length-mismatch
* guard now lives in the pool itself (`src/core/pool.ts`), not here —
* it applies uniformly to whole batches AND bisected sub-batches.
* - `runSettled`'s hook, with adaptive bisection OFF (default), catches
* a transport rejection and *resolves* with synthesized per-call
* `kind:'batch'` failures instead — this is what makes `runSettled`
* never cancel on an ordinary transport failure. With adaptive ON, it
* deliberately lets the rejection propagate to the pool instead, so
* bisection can split it — the per-call synthesis then happens once,
* at the TERMINAL point, via `recordTerminal` below.
*/
executeBatch(batch: StepCall[]): Promise<RawResult[]>

Expand All @@ -60,12 +69,28 @@ export interface StepEnginePolicy {
* and marks the task dead.
*/
consumeStepResults(taskIndex: number, step: number, results: StepResult[]): void

/**
* Adaptive bisection's terminal hook (F6b) — see
* `BisectionPolicy.recordTerminal` in `src/core/pool.ts` for the full
* contract. Present only for `runSettled` (never cancel on a terminal
* batch failure — synthesize a per-call `kind:'batch'` `DominoCallError`
* instead). Absent for `run`, whose terminal handling is the pool's plain
* fail-fast cancellation, unchanged from T14 in shape.
*/
recordTerminal?(calls: StepCall[], error: unknown): RawResult[]
}

/** The subset of `BatchOptions` the engine itself needs. */
export interface StepEngineOptions {
batchSize: number
maxConcurrentBatches: number
/** F6b — see `BatchOptions.adaptiveBatching`. */
adaptiveBatching: boolean
/** F6b — see `BatchOptions.maxBatchAttempts`. Consulted by the pool only
* when `adaptiveBatching` is true; otherwise any rejection is terminal
* on its first occurrence regardless of this value (T14 behavior). */
maxBatchAttempts: number
}

/**
Expand Down Expand Up @@ -107,9 +132,11 @@ export async function runSteps<TResult>(
batches.push(calls.slice(batchStart, batchStart + options.batchSize))
}

const outcome = await runBatchPool(batches, options.maxConcurrentBatches, (batch) =>
policy.executeBatch(batch),
)
const outcome = await runBatchPool(batches, options.maxConcurrentBatches, (batch) => policy.executeBatch(batch), {
adaptive: options.adaptiveBatching,
maxBatchAttempts: options.maxBatchAttempts,
...(policy.recordTerminal ? { recordTerminal: policy.recordTerminal } : {}),
})

if (outcome.outcome === 'cancelled') {
// Spec (d): in-flight results are discarded — we never look at
Expand Down
21 changes: 15 additions & 6 deletions src/core/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,26 @@ const consumed = new WeakSet<object>()
type Branded<T> = MultistepTask<T> & SingleUseCarrier

/**
* Numeric options validated + defaulted by `validateOptions` (F6a). All
* three ride through `prepareRun`'s return value; `maxBatchAttempts` is
* validated and defaulted here but not yet CONSUMED by either runner —
* bisection (T15) wires it into the engine without touching this function
* again.
* Numeric (+ one boolean) options validated + defaulted by `validateOptions`
* (F6a/F6b). All four ride through `prepareRun`'s return value.
* `maxBatchAttempts` and `adaptiveBatching` are both consumed by the engine
* (`src/core/engine.ts`/`src/core/pool.ts`, F6b) — `adaptiveBatching` gates
* whether bisection ever runs at all; `maxBatchAttempts` bounds it once it
* does.
*/
export interface ValidatedRunOptions {
batchSize: number
maxConcurrentBatches: number
maxBatchAttempts: number
adaptiveBatching: boolean
}

/** Options `validateOptions` reads — a structural subset of `BatchOptions`. */
export interface NumericOptionsInput {
batchSize?: number
maxConcurrentBatches?: number
maxBatchAttempts?: number
adaptiveBatching?: boolean
}

/**
Expand Down Expand Up @@ -125,7 +128,13 @@ export function validateOptions(o: NumericOptionsInput | undefined): ValidatedRu
const maxBatchAttempts = o?.maxBatchAttempts ?? defaultMaxBatchAttempts
validatePositiveSafeInteger('maxBatchAttempts', maxBatchAttempts)

return { batchSize, maxConcurrentBatches, maxBatchAttempts }
// Not a positive-safe-integer field — a plain boolean flag (F6b), default
// `false` (see `BatchOptions.adaptiveBatching`'s doc comment for why: rate
// limiting makes bisection's retry amplification actively harmful unless a
// caller has opted in with knowledge of their transport's failure modes).
const adaptiveBatching = o?.adaptiveBatching ?? false

return { batchSize, maxConcurrentBatches, maxBatchAttempts, adaptiveBatching }
}

/**
Expand Down
Loading
Loading