Skip to content

Commit 7e7d8d0

Browse files
authored
feat(sequencer): make sequencer pausable (#24475)
Supersedes #24449 and #24450, reimplementing both on a simpler design. Single PR since the lifecycle work is only exercised by the e2e primitive. ## Context Several multi-node e2e tests spend minutes of wall-clock waiting for the L1 clock to roll while live sequencers sit idle. Warping the shared clock under a running sequencer interrupts whatever iteration is mid-build, producing the reorg / "Sequencer was interrupted" failures behind earlier revert attempts. Pausing sequencers around the warp requires a lifecycle that can actually stop and resume, which it couldn't: a second `start()` orphaned the previous poll loop (two loops racing), and a restarted sequencer never cleared the publishers' `interrupted` flag, so it would build and propose blocks but silently never publish to L1. ## Approach **Idempotent, restartable lifecycle.** `start()`/`stop()` are idempotent across `SequencerClient`, `Sequencer`, and `PublisherManager`, and `start()` while `STOPPING` is refused so a mid-stop start cannot orphan a fresh loop. `PublisherManager.start()` clears the interrupted flag via the previously-unused `restart()` methods, and `SequencerClient.start()` starts the publisher manager before the sequencer loop so publishers are un-interrupted before the first post-restart publish. The funding loop is created once in the constructor and restarted across cycles, and a start that failed to load publisher state can be retried. **`pause()` for restarts.** Restartability is funneled through a dedicated `Sequencer.pause()` / `SequencerClient.pause()`: it halts the poll loop and waits for the in-flight `work()` iteration and every pending L1 submission / fallback send to finish *without interrupting them* — no interrupt lands mid-build, so no spurious `checkpoint-error` is emitted and no enqueued checkpoint is dropped. It deliberately does **not** stop inner services: the validator/HA signer and publishers keep running, so the slashing-protection store stays open and a later `start()` cleanly resumes. Draining happens without entering `STOPPING`, since in that state the iteration's own `setState` calls throw `SequencerInterruptedError`, which would fail the very iteration being drained. This replaces the wait-for-IDLE heuristic from #24450, which raced: sequencers reach IDLE at different times, and any of them could re-enter `work()` before the stop landed, flaking any test that asserts no sequencer failure events. **`stop()` stays a full teardown.** `stop()` keeps the fast interrupting shutdown used for production teardown, and stopping the validator client closes its slashing-protection database (LMDB single-node / Postgres HA), as it did before this line of work. Because restarts now go through `pause()` — which never closes the store — `stop()` no longer needs to leave the store open, so there is no DB-ownership bookkeeping to distinguish "owned" from "shared" databases. **Single shared request tracker.** The checkpoint proposal jobs' backgrounded L1 submissions and the sequencer's own fire-and-forget fallback sends (vote-and-prune when we cannot build, escape-hatch votes) are tracked in one `RequestsTracker` owned by the sequencer and handed to each job it creates. `stop()` interrupts and drains that single tracker in one place; `pause()` awaits it untouched. A sender sleeping until its target slot therefore cannot survive a `stop()` and publish a stale-slot tx after a restart clears the pooled publishers' interrupted flag: interrupting the wrapper publisher is permanent, since wrappers are never restarted. **e2e primitive + pilot.** `warpWithSequencersPaused(nodes, cheatCodes, target, opts)` on `SingleNodeTestContext` (inherited by `MultiNodeTestContext`) pauses every sequencer, runs the warp with nobody building, and resumes them (`restart: false` leaves them paused). Archivers, provers, and the chain monitor keep running, so clock-driven effects such as an orphan-block prune still fire. Applied to `pipeline_prune` to collapse its ~2-minute dead wait for the orphan slot's prune deadline: warp two L1 slots into the slot after the orphaned one, and resume the sequencers only once the prune is confirmed. `TX_COUNT` is unchanged, so `assertMultipleBlocksPerSlot` and `assertProposerPipelining` still hold. Lifecycle calls are assumed to be serialized by the caller (all current callers are); this does not add a mutex for truly concurrent start/stop/pause. The `pipeline_prune` speedup is validated by this PR's CI run — the multi-node suite can't be run locally.
1 parent 181ceed commit 7e7d8d0

13 files changed

Lines changed: 676 additions & 41 deletions

File tree

yarn-project/end-to-end/src/multi-node/recovery/pipeline_prune.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/bra
66
import { executeTimeout } from '@aztec/foundation/timer';
77
import type { SequencerEvents } from '@aztec/sequencer-client';
88
import { L2BlockSourceEvents } from '@aztec/stdlib/block';
9+
import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
910

1011
import { proveAndSendTxs } from '../../test-wallet/utils.js';
1112
import {
@@ -102,6 +103,29 @@ describe('multi-node/recovery/pipeline_prune', () => {
102103
// The sequencer keeps building blocks and broadcasting via P2P, but won't submit the checkpoint to L1
103104
targetSequencer.updateConfig({ skipPublishingCheckpointsPercent: 100 });
104105

106+
// Wait for the orphan blocks to actually exist before warping: the target proposer builds them during
107+
// slot proposerSlotToNotPublish - 1 and broadcasts them via P2P carrying that submission slot, but never
108+
// publishes the enclosing checkpoint. Only once node[0]'s archiver holds them as a proposed (uncheckpointed)
109+
// tip is there anything for pruneOrphanProposedBlocks to prune — warping before they arrive would fire no prune.
110+
await test.waitForAllNodesToReachBlockAtSlot(
111+
proposerSlotToNotPublish,
112+
'proposed',
113+
block => block.header.globalVariables.slotNumber >= proposerSlotToNotPublish,
114+
{ nodes: [nodes[0]], timeout: test.L2_SLOT_DURATION_IN_S * 3 },
115+
);
116+
logger.warn(`Orphan blocks for slot ${proposerSlotToNotPublish} are present; warping past the prune deadline`);
117+
118+
// Collapse the ~2-minute dead gap where the chain just waits for the L1 clock to roll past the orphan
119+
// slot's checkpoint-proposal-received deadline so pruneOrphanProposedBlocks fires. The archiver reads the
120+
// shared TestDateProvider that eth.warp advances, so jumping the clock into the slot after the orphan one
121+
// takes us safely past that deadline and the next archiver sync prunes. The sequencers are kept stopped
122+
// (restart: false) until the prune is confirmed, so no proposer builds against the still-unpruned tip;
123+
// they are restarted for recovery below.
124+
const pruneWarpTarget =
125+
getTimestampForSlot(SlotNumber(proposerSlotToNotPublish + 1), test.constants) +
126+
BigInt(2 * test.constants.ethereumSlotDuration);
127+
await test.warpWithSequencersPaused(nodes, test.context.cheatCodes, pruneWarpTarget, { restart: false });
128+
105129
const pruneTimeout = test.L2_SLOT_DURATION_IN_S * 5 * 1000;
106130
logger.warn(`Waiting for uncheckpointed blocks to be pruned (timeout=${pruneTimeout}ms)`);
107131
await executeTimeout(() => prunePromise, pruneTimeout);
@@ -119,9 +143,13 @@ describe('multi-node/recovery/pipeline_prune', () => {
119143
}
120144
logger.warn(`Pruning detected, block number now ${await archiver.getBlockNumber()}`);
121145

122-
// Re-enable checkpoint publishing
146+
// Re-enable checkpoint publishing, then restart the sequencers to build the recovery checkpoint.
147+
// Restarting only now (after the prune and after listeners are attached) keeps recovery from racing the
148+
// prune and ensures every recovery block is captured for the pipelining assertion.
123149
logger.warn(`Re-enabling checkpoint publishing for validator ${proposerIndex}`);
124150
targetSequencer.updateConfig({ skipPublishingCheckpointsPercent: 0 });
151+
await test.startSequencers(nodes);
152+
logger.warn(`Restarted all sequencers for recovery`);
125153

126154
// Wait for a new checkpoint (recovery) - where all txs end up mined
127155
const timeout = test.L2_SLOT_DURATION_IN_S * 5;

yarn-project/end-to-end/src/single-node/single_node_test_context.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Fr } from '@aztec/aztec.js/fields';
99
import type { Logger } from '@aztec/aztec.js/log';
1010
import { MerkleTreeId } from '@aztec/aztec.js/trees';
1111
import type { Wallet } from '@aztec/aztec.js/wallet';
12+
import type { CheatCodes } from '@aztec/aztec/testing';
1213
import { EpochCache } from '@aztec/epoch-cache';
1314
import { createExtendedL1Client } from '@aztec/ethereum/client';
1415
import { DefaultL1ContractsConfig } from '@aztec/ethereum/config';
@@ -991,4 +992,50 @@ export class SingleNodeTestContext {
991992
}
992993
expect(failEvents).toEqual([]);
993994
}
995+
996+
/**
997+
* Warps the L1 clock to `target` with every given node's sequencer paused, then resumes them
998+
* (pass `restart: false` to leave them paused, e.g. until some clock-driven effect is confirmed).
999+
*
1000+
* Warping the shared date provider under live sequencers interrupts whatever iteration is mid-build,
1001+
* producing spurious `block-build-failed` / `checkpoint-error` events and dropped checkpoints. The
1002+
* sequencers are therefore paused first: the poll loop halts and the in-flight iteration, its pending L1
1003+
* submission, and any pending fallback vote all finish untouched before the warp, so nothing fires with a
1004+
* stale slot afterwards. Pausing leaves the validator clients (and their slashing-protection stores) and
1005+
* publishers running, so a later {@link SequencerClient.start} cleanly resumes; archivers, provers, and
1006+
* the chain monitor keep running throughout, so clock-driven effects of the warp (e.g. an orphan-block
1007+
* prune) still fire.
1008+
*
1009+
* The warp is performed here (rather than via a caller-supplied callback) so it happens only after the
1010+
* pause has drained. Draining can take several slots, so a `target` computed before the pause may already
1011+
* lie in the past by the time the sequencers are down. The warp is therefore skipped when the L1 clock has
1012+
* already reached or passed `target` — `evm_setNextBlockTimestamp` rejects a non-advancing timestamp, so
1013+
* warping there would throw "timestamp in the past".
1014+
*/
1015+
public async warpWithSequencersPaused(
1016+
nodes: AztecNodeService[],
1017+
cheatCodes: CheatCodes,
1018+
target: bigint,
1019+
opts: { restart?: boolean } = {},
1020+
): Promise<void> {
1021+
const sequencers = this.getSequencers(nodes);
1022+
await testSpan('warp:sequencers-paused', async () => {
1023+
this.logger.warn(`Pausing ${sequencers.length} sequencers before warp`);
1024+
await Promise.all(sequencers.map(sequencer => sequencer.pause()));
1025+
const currentTs = BigInt(await cheatCodes.eth.lastBlockTimestamp());
1026+
if (currentTs < target) {
1027+
this.logger.warn(`Warping L1 to ${target} with all sequencers paused`, { currentTs, target });
1028+
await cheatCodes.eth.warp(Number(target), { resetBlockInterval: true });
1029+
} else {
1030+
this.logger.verbose(`Skipping warp: L1 clock ${currentTs} already at or past target ${target}`, {
1031+
currentTs,
1032+
target,
1033+
});
1034+
}
1035+
if (opts.restart ?? true) {
1036+
this.logger.warn(`Resuming ${sequencers.length} sequencers after warp`);
1037+
await Promise.all(sequencers.map(sequencer => sequencer.start()));
1038+
}
1039+
});
1040+
}
9941041
}

yarn-project/ethereum/src/publisher_manager.test.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,105 @@ describe('PublisherManager', () => {
418418
});
419419
});
420420

421+
describe('lifecycle', () => {
422+
let funder: TestL1TxUtils & L1TxUtils;
423+
424+
beforeEach(() => {
425+
funder = new TestL1TxUtils(EthAddress.random()) as TestL1TxUtils & L1TxUtils;
426+
funder.balance = 5000n;
427+
});
428+
429+
it('stop interrupts all publishers and the funder', async () => {
430+
mockPublishers = createMockPublishers(3);
431+
publisherManager = new PublisherManager(mockPublishers, {}, { funder });
432+
433+
await publisherManager.start();
434+
await publisherManager.stop();
435+
436+
expect(mockPublishers.every(p => p.interrupted)).toBe(true);
437+
expect(funder.interrupted).toBe(true);
438+
});
439+
440+
it('start after stop clears the interrupted flag so publishing works again', async () => {
441+
mockPublishers = createMockPublishers(3);
442+
publisherManager = new PublisherManager(mockPublishers, {}, { funder });
443+
444+
await publisherManager.start();
445+
await publisherManager.stop();
446+
expect(mockPublishers.every(p => p.interrupted)).toBe(true);
447+
448+
// Restart: interrupted must be cleared, otherwise sendTransaction would throw InterruptError.
449+
await publisherManager.start();
450+
451+
expect(mockPublishers.every(p => !p.interrupted)).toBe(true);
452+
expect(funder.interrupted).toBe(false);
453+
});
454+
455+
it('a second start does not reload state, which would duplicate background monitors', async () => {
456+
mockPublishers = createMockPublishers(1);
457+
publisherManager = new PublisherManager(mockPublishers, {});
458+
459+
await publisherManager.start();
460+
await publisherManager.start();
461+
462+
expect(mockPublishers[0].loadCount).toBe(1);
463+
});
464+
465+
it('a start after a stop reloads state so in-flight txs resume monitoring', async () => {
466+
mockPublishers = createMockPublishers(1);
467+
publisherManager = new PublisherManager(mockPublishers, {});
468+
469+
await publisherManager.start();
470+
await publisherManager.stop();
471+
await publisherManager.start();
472+
473+
expect(mockPublishers[0].loadCount).toBe(2);
474+
});
475+
476+
it('a failed start can be retried', async () => {
477+
mockPublishers = createMockPublishers(1);
478+
publisherManager = new PublisherManager(mockPublishers, {});
479+
480+
mockPublishers[0].failNextLoad = true;
481+
await expect(publisherManager.start()).rejects.toThrow('load failed');
482+
483+
await publisherManager.start();
484+
expect(mockPublishers[0].loadCount).toBe(1);
485+
});
486+
487+
it('is idempotent on double stop', async () => {
488+
mockPublishers = createMockPublishers(2);
489+
publisherManager = new PublisherManager(mockPublishers, {});
490+
491+
await publisherManager.start();
492+
await publisherManager.stop();
493+
await expect(publisherManager.stop()).resolves.not.toThrow();
494+
495+
expect(mockPublishers.every(p => p.interrupted)).toBe(true);
496+
});
497+
498+
it('resumes periodic funding checks after a restart', async () => {
499+
mockPublishers = createMockPublishers(1);
500+
mockPublishers[0].balance = 50n; // stays below threshold, so every funding check funds it
501+
publisherManager = new PublisherManager(
502+
mockPublishers,
503+
{ publisherFundingThreshold: 100n, publisherFundingAmount: 50n },
504+
{ funder },
505+
);
506+
507+
await publisherManager.start();
508+
await new Promise(resolve => setTimeout(resolve, 10));
509+
await publisherManager.stop();
510+
expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1);
511+
512+
// The funding loop must be re-armed by the restart, triggering another immediate check.
513+
await publisherManager.start();
514+
await new Promise(resolve => setTimeout(resolve, 10));
515+
await publisherManager.stop();
516+
expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2);
517+
});
518+
});
519+
421520
function createMockPublishers(count: number, addresses: EthAddress[] = []): (TestL1TxUtils & L1TxUtils)[] {
422521
const tempAddress = [...addresses];
423522
return times(
@@ -431,6 +530,10 @@ class TestL1TxUtils {
431530
public state: TxUtilsState = TxUtilsState.IDLE;
432531
public lastMinedAtBlockNumber: bigint | undefined = undefined;
433532
public balance: bigint = 1000n;
533+
/** Mirrors the real ReadOnlyL1TxUtils.interrupted flag so tests can assert publishing is re-enabled. */
534+
public interrupted = false;
535+
public loadCount = 0;
536+
public failNextLoad = false;
434537
public sendAndMonitorTransaction = jest.fn<() => Promise<any>>().mockResolvedValue({
435538
receipt: { transactionHash: '0xabc', status: 'success' },
436539
state: {},
@@ -446,7 +549,20 @@ class TestL1TxUtils {
446549
return this.senderAddress;
447550
}
448551

449-
public async loadStateAndResumeMonitoring() {}
552+
public loadStateAndResumeMonitoring() {
553+
if (this.failNextLoad) {
554+
this.failNextLoad = false;
555+
return Promise.reject(new Error('load failed'));
556+
}
557+
this.loadCount++;
558+
return Promise.resolve();
559+
}
450560

451-
public interrupt() {}
561+
public interrupt() {
562+
this.interrupted = true;
563+
}
564+
565+
public restart() {
566+
this.interrupted = false;
567+
}
452568
}

yarn-project/ethereum/src/publisher_manager.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
4141
private config: PublisherManagerConfig;
4242
private static readonly FUNDING_CHECK_INTERVAL_MS = 2 * 60 * 1000;
4343
protected funder?: UtilsType;
44-
protected fundingPromise?: RunningPromise;
44+
protected readonly fundingPromise?: RunningPromise;
45+
private started = false;
4546

4647
constructor(
4748
protected publishers: UtilsType[],
@@ -67,31 +68,50 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
6768
this.funder = undefined;
6869
}
6970
}
71+
72+
if (this.funder && hasThreshold && hasAmount) {
73+
this.fundingPromise = new RunningPromise(
74+
() => this.triggerFundingIfNeeded(),
75+
this.log,
76+
PublisherManager.FUNDING_CHECK_INTERVAL_MS,
77+
);
78+
}
7079
}
7180

72-
/** Loads the state of all publishers and the funder, and starts periodic funding checks. */
81+
/**
82+
* Clears any interrupted flag left by a previous {@link stop} so publishing works again after a restart,
83+
* loads the state of all publishers and the funder, and starts periodic funding checks. Idempotent: a
84+
* start while already started is a no-op, so it never re-runs `loadStateAndResumeMonitoring` (which
85+
* would spawn a duplicate background monitor per pending nonce). Lifecycle calls are expected to be
86+
* serialized by the caller.
87+
*/
7388
public async start(): Promise<void> {
89+
if (this.started) {
90+
this.log.debug('PublisherManager already started, ignoring start');
91+
return;
92+
}
93+
94+
// Clear the interrupted flag set by a previous stop() so a restarted manager can publish again.
95+
// On a first start this is a no-op (the flag is already clear).
96+
this.publishers.forEach(pub => pub.restart());
97+
this.funder?.restart();
98+
7499
await Promise.all([
75100
...this.publishers.map(pub => pub.loadStateAndResumeMonitoring()),
76101
this.funder?.loadStateAndResumeMonitoring(),
77102
]);
78103

79-
if (
80-
this.funder &&
81-
this.config.publisherFundingThreshold !== undefined &&
82-
this.config.publisherFundingAmount !== undefined
83-
) {
84-
this.fundingPromise = new RunningPromise(
85-
() => this.triggerFundingIfNeeded(),
86-
this.log,
87-
PublisherManager.FUNDING_CHECK_INTERVAL_MS,
88-
);
89-
this.fundingPromise.start();
90-
}
104+
this.fundingPromise?.start();
105+
// Marked started only once fully up, so a start that failed to load state can be retried.
106+
this.started = true;
91107
}
92108

93-
/** Stops the funding loop and interrupts all publishers. */
109+
/**
110+
* Stops the funding loop and interrupts all publishers so no further L1 txs are sent. Idempotent, and
111+
* the manager may be restarted afterwards via {@link start}, which clears the interrupted flag.
112+
*/
94113
public async stop(): Promise<void> {
114+
this.started = false;
95115
await this.fundingPromise?.stop();
96116
this.publishers.forEach(pub => pub.interrupt());
97117
this.funder?.interrupt();

yarn-project/foundation/src/promise/running-promise.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,44 @@ describe('RunningPromise', () => {
5252
});
5353
});
5454

55+
describe('lifecycle', () => {
56+
it('a second start does not spawn a second poll loop', async () => {
57+
runningPromise.start();
58+
expect(counter).toEqual(1);
59+
60+
// Starting again while already running must be a no-op, not a second concurrent loop.
61+
runningPromise.start();
62+
expect(counter).toEqual(1);
63+
64+
await jest.advanceTimersToNextTimerAsync();
65+
// Exactly one loop advanced the counter, not two.
66+
expect(counter).toEqual(2);
67+
});
68+
69+
it('stop is safe to call when never started and when already stopped', async () => {
70+
await expect(runningPromise.stop()).resolves.toBeUndefined();
71+
72+
runningPromise.start();
73+
await runningPromise.stop();
74+
expect(runningPromise.isRunning()).toBe(false);
75+
76+
await expect(runningPromise.stop()).resolves.toBeUndefined();
77+
});
78+
79+
it('can be restarted after a stop', async () => {
80+
runningPromise.start();
81+
expect(counter).toEqual(1);
82+
await runningPromise.stop();
83+
84+
runningPromise.start();
85+
expect(runningPromise.isRunning()).toBe(true);
86+
expect(counter).toEqual(2);
87+
88+
await jest.advanceTimersToNextTimerAsync();
89+
expect(counter).toEqual(3);
90+
});
91+
});
92+
5593
describe('handles errors', () => {
5694
beforeEach(() => {
5795
fn.mockImplementation(() => {

0 commit comments

Comments
 (0)