Skip to content

Commit f282750

Browse files
authored
test: deflake epochs_optimistic_proving reorg-during-proving gate (#24308)
## Problem `e2e_epochs/epochs_optimistic_proving.parallel.test.ts` › "handles a reorg arriving while the top of the epoch is proving" flakes (e.g. CI runs `a8d5f346e269c668`, `5039f193fa054832`), timing out at: ``` TimeoutError: Timeout awaiting prover-node sees the prune and recreates session with fewer provers ``` ## Root cause The test installs a `beforeTopTreeProve` gate meant to pause top-tree proving so the L1 reorg lands at a deterministic point (session parked mid-proof, all sub-trees done). The gate never actually engaged: - The hook fires from `TopTreeJob.run()` at the `beforeProve` boundary (`top-tree-job.ts:198`), which runs *after* `EpochSession` has already flipped the session state from `awaiting-checkpoints` to `awaiting-root` (`epoch-session.ts:426-431`). - The gate predicate looked for a job in `awaiting-checkpoints`, so at hook time it matched nothing and returned early — never blocking. (`Top-tree proving gated` appears 0 times in both the failed and passed CI logs.) With the gate disabled, the reorg raced real, pipelined sub-tree proving (`top-tree-orchestrator.ts:85-87`), and the outcome depended on epoch size: - **Small gated epoch** (epoch 0, 2 checkpoints, near-instant proving): a sub-tree was still mid-execution when the prune removed its block → `world-state ... Unable to get meta data for block 2` → the `EpochSession` caught the error and went terminal `failed` (`epoch-session.ts:211-218`) → the prune-reconcile (`recreateInvalidSessions`, `session-manager.ts:266-269`) found it already terminal and **deleted it without recreating** → the test's wait for a trimmed session timed out. - **Large gated epoch** (4 checkpoints): all sub-trees finished before the prune, so the session was still non-terminal (`awaiting-root`) and the prune took the clean cancel-then-recreate path — the test passed. The flake is the small-epoch case, which is exactly the `2 → 1` survivor transition the test is meant to cover. ## Fix (test only) `yarn-project/end-to-end/src/e2e_epochs/epochs_optimistic_proving.parallel.test.ts`: - Query the session for a job in `awaiting-root` (the state it is actually in when `beforeProve` fires), keeping the `>= 2` checkpoint condition. - Resolve a `gateEntered` signal from *inside* the hook, right before awaiting the gate, so the gate genuinely blocks and the test learns the gated epoch only once the session is parked. - Fire the reorg only after `await executeTimeout(() => gateEntered, ...)`, instead of acting on a transient `awaiting-checkpoints` observation. This makes the gate do what its comment always claimed: deterministically park the session at the top-tree boundary (sub-trees proven, root prove not yet started) before the reorg. That removes the sub-tree-vs-prune race — when the reorg fires the session is non-terminal, so the prune always takes the cancel-and-recreate-with-survivors path the test verifies. No behavioral assertion is relaxed; the `>= 2` gating and the final "proven up to the surviving checkpoint" assertions are unchanged. Not a skip, not a `.test_patterns.yml` entry. ## Verification - `yarn build`: exit 0; `yarn lint end-to-end`: clean. - Local run (`ANVIL_PORT=8600`) passed and reproduced the exact previously-failing condition — it gated epoch 0 with 2 checkpoints, the gate engaged this time (`Top-tree proving gated for epoch 0`, absent in both CI logs), and the recreate path ran (`Prover-node trimmed in-flight session: 2 → 1 tracked checkpoints`), proving up to the surviving checkpoint. ## Also in this PR - Refactored the two proving-gate deferred promises to `promiseWithResolvers` from `@aztec/foundation/promise`, replacing `new Promise(resolve => { outerVar = resolve })` with escaped `let` placeholders. - Added a TypeScript style note to `yarn-project/CLAUDE.md` preferring `promiseWithResolvers` for promises settled from outside the executor. ## Note This flake incidentally exposed a separate product edge, tracked separately and intentionally not bundled into this test fix: if a reorg prunes a block out from under an in-flight sub-tree, the `EpochSession` can go terminal `failed`, and `recreateInvalidSessions` drops terminal sessions without recreating them (unlike the non-terminal cancel→recreate path). This is **not a liveness risk**: the network self-heals (a different prover node proves the epoch, and publishing dedups against the proven chain), and the affected node itself usually recovers on the next checkpoint event for the epoch (a path not gated by `lastTickEpoch`) or on a process restart. It is durably stuck on a single node only in the narrow case where no further checkpoint event ever arrives for the epoch, leaving recovery to the periodic tick — which `lastTickEpoch` blocks. The likely fix is to classify a prune-induced sub-tree read fault as a cancellation so the session recreates via the existing tested path, while keeping the `lastTickEpoch` anti-resubmission guard intact.
1 parent 731985c commit f282750

2 files changed

Lines changed: 34 additions & 26 deletions

File tree

yarn-project/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ When working with `AztecAsyncKVStore`, wrap related reads and writes in `store.t
198198
<general_style>
199199
Prefer `const` over `let`. Prefer `async`/`await` over `.then()`/`.catch()` callbacks. Named exports only (no default exports). Explicit return types on public API methods; inferred types acceptable on private/internal methods. Only export types needed by external consumers. Avoid `const self = this`; use arrow functions.
200200

201+
When you need a promise whose `resolve`/`reject` are called from outside the executor (deferred gates, signals, manual settlement), use `promiseWithResolvers` from `@aztec/foundation/promise` instead of `new Promise(resolve => { outerVar = resolve })` with an escaped `let`. The helper returns `{ promise, resolve, reject }` directly, avoiding the mutable placeholder.
202+
201203
Prefer high-level collection functions (`find`, `filter`, `map`, helpers from `foundation/src/collection/`) over imperative loops, but prefer imperative loops over `forEach` and complex `reduce`. Prefer `sum(items.map(item => item.value))` over `reduce(...)` for addition.
202204

203205
Simplify function arguments to single expressions where possible. Use expression bodies instead of block bodies when the block only contains a `return`. Block bodies are appropriate when the callback has multiple statements.

yarn-project/end-to-end/src/e2e_epochs/epochs_optimistic_proving.parallel.test.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { Logger } from '@aztec/aztec.js/log';
22
import { RollupContract } from '@aztec/ethereum/contracts';
33
import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
44
import { Fr } from '@aztec/foundation/curves/bn254';
5+
import { promiseWithResolvers } from '@aztec/foundation/promise';
56
import { retryUntil } from '@aztec/foundation/retry';
7+
import { executeTimeout } from '@aztec/foundation/timer';
68
import type { TestProverNode } from '@aztec/prover-node/test';
79
import { getEpochAtSlot, getSlotRangeForEpoch } from '@aztec/stdlib/epoch-helpers';
810
import type { AztecNode } from '@aztec/stdlib/interfaces/server';
@@ -669,46 +671,50 @@ describe('e2e_epochs/epochs_optimistic_proving', () => {
669671

670672
it('handles a reorg arriving while the top of the epoch is proving', async () => {
671673
// Gate top-tree proving so it deterministically blocks until we release it. This
672-
// gives us a window where the session is mid-proof, and we can fire the reorg
674+
// gives us a window where the session is parked at the top-tree boundary (all
675+
// sub-trees proven, root prove not yet started), and we can fire the reorg
673676
// precisely during that window. We use the session's `beforeTopTreeProve` hook
674677
// rather than monkey-patching the orchestrator factory.
675678
const proverNode = test.proverNodes[0].getProverNode() as TestProverNode;
676-
let releaseProvingGate: () => void = () => {};
677-
const provingGate = new Promise<void>(resolve => {
678-
releaseProvingGate = resolve;
679-
});
680-
// Only gate sessions with at least 2 checkpoints — reorging the last checkpoint
681-
// of a single-checkpoint epoch leaves nothing to prove, the session is cancelled
682-
// without replacement, and the test's "wait for fewer checkpoints" check never
683-
// converges. Sessions with one checkpoint just pass through.
684-
const sessionHasMultipleCheckpoints = async () => {
685-
const job = (await proverNode.getJobs()).find(j => j.status === 'awaiting-checkpoints');
679+
const provingGate = promiseWithResolvers<void>();
680+
// Resolves with the gated epoch once a session is actually parked inside the gate.
681+
// Firing the reorg only after this settles guarantees the session is blocked at the
682+
// top-tree boundary — not racing real proving — so the prune deterministically takes
683+
// the cancel-and-recreate path instead of failing a half-run sub-tree.
684+
const gateEntered = promiseWithResolvers<EpochNumber>();
685+
// The hook fires from inside `beforeProve`, by which point the session has already
686+
// transitioned from `awaiting-checkpoints` to `awaiting-root`; query that state to
687+
// find the gateable session. Only gate sessions with at least 2 checkpoints —
688+
// reorging the last checkpoint of a single-checkpoint epoch leaves nothing to prove,
689+
// the session is cancelled without replacement, and the test's "wait for fewer
690+
// checkpoints" check never converges. Sessions with one checkpoint just pass through.
691+
const findGateableEpoch = () => {
692+
const job = proverNode.sessionManager.getJobs().find(j => j.status === 'awaiting-root');
686693
const session = job && proverNode.sessionManager.getFullSession(job.epochNumber);
687-
return !!session && session.getCheckpoints().length >= 2;
694+
return session && session.getCheckpoints().length >= 2 ? job!.epochNumber : undefined;
688695
};
689696
proverNode.setSessionHooks({
690697
beforeTopTreeProve: async () => {
691-
if (!(await sessionHasMultipleCheckpoints())) {
698+
const epoch = findGateableEpoch();
699+
if (epoch === undefined) {
692700
return;
693701
}
694-
logger.warn('Top-tree proving gated — waiting for test to release');
695-
await provingGate;
702+
logger.warn(`Top-tree proving gated for epoch ${epoch} — waiting for test to release`);
703+
gateEntered.resolve(epoch);
704+
await provingGate.promise;
696705
logger.warn('Proving gate released');
697706
},
698707
});
699708

700-
// Wait for a session with at least 2 checkpoints to hit the gate. The session
701-
// manager opens one full session at a time, starting with the lowest unproven
702-
// epoch; small epochs pass through (see hook above) and we keep polling until a
703-
// gateable epoch lands. `getJobs()` tells us which epoch is actually blocked.
704-
await retryUntil(
705-
sessionHasMultipleCheckpoints,
709+
// Wait until a session with at least 2 checkpoints is actually parked inside the
710+
// gate. The session manager opens one full session at a time, starting with the
711+
// lowest unproven epoch; small epochs pass through (see hook above) and we keep
712+
// waiting until a gateable epoch lands.
713+
const gatedEpoch = await executeTimeout(
714+
() => gateEntered.promise,
715+
L2_SLOT_DURATION_IN_S * 12 * 1000,
706716
'gateable session blocks at proving gate',
707-
L2_SLOT_DURATION_IN_S * 12,
708-
0.5,
709717
);
710-
const gatedJob = (await proverNode.getJobs()).find(j => j.status === 'awaiting-checkpoints')!;
711-
const gatedEpoch = gatedJob.epochNumber;
712718
logger.info(`Job for epoch ${gatedEpoch} is blocked inside proving — firing reorg now`);
713719

714720
// Capture the in-flight session and the last checkpoint of the gated epoch —
@@ -771,7 +777,7 @@ describe('e2e_epochs/epochs_optimistic_proving', () => {
771777
// Release the gate. The cancelled top tree #1 short-circuits with
772778
// TopTreeCancelledError, the finalize loop restarts with the surviving sub-trees,
773779
// and a fresh top tree submits a valid proof for checkpoints 1..afterReorgCheckpoint.
774-
releaseProvingGate();
780+
provingGate.resolve();
775781

776782
// The in-flight epoch should now be proven on L1
777783
await test.waitUntilProvenCheckpointNumber(afterReorgCheckpoint, 240);

0 commit comments

Comments
 (0)