Skip to content

Commit 5d46ed1

Browse files
authored
fix(sequencer): only warn about missing proposed checkpoint once overdue (#23807)
## Motivation The orphan-block guard in `checkSync` (added in #23606) was logging at `warn` on every non-proposer validator, ~once per second for a full slot, every slot. Under pipelining a node receives and re-executes a block proposal for the next checkpoint up to one slot before the matching checkpoint proposal arrives, so the world-state tip legitimately sits in an as-yet-unproposed checkpoint for that whole window. That is the happy path, not the abnormal "proposer published blocks but never the checkpoint" case the guard is meant to flag. Observed on `next-net`: 118 warnings in ~59s on a healthy validator for a single slot. ## Approach The condition that distinguishes "checkpoint hasn't arrived yet" from "checkpoint will never arrive" is purely temporal — which is exactly what the archiver already computes in `pruneOrphanProposedBlocks` to decide when to prune an orphan block. The guard now reuses that same deadline: it still refuses to build (`return undefined`) whenever the orphan-shaped state holds, but only escalates to `warn` once the enclosing checkpoint is overdue by that deadline; within the normal pipelining window it logs at `debug`. The warn therefore fires at the same instant the archiver would prune the orphan. ## Changes - **sequencer-client**: Add `isProposedCheckpointOverdue`, mirroring the archiver's orphan-prune deadline (`start of slot after the block's build slot + grace`, grace derived from `blockDurationMs` as the node wiring does). Gate the existing guard's log level on it — `warn` when overdue, `debug` otherwise. Control flow is unchanged. - **sequencer-client (tests)**: Thread a real `blockSlot` through the orphan-guard test setup and split the warning test into an overdue case (expects `warn`) and a within-window case (expects no `warn`).
1 parent 75fdca8 commit 5d46ed1

2 files changed

Lines changed: 66 additions & 6 deletions

File tree

yarn-project/sequencer-client/src/sequencer/sequencer.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from '@aztec/stdlib/interfaces/server';
4343
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
4444
import { CheckpointHeader } from '@aztec/stdlib/rollup';
45+
import { MIN_EXECUTION_TIME } from '@aztec/stdlib/timetable';
4546
import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees';
4647
import { BlockHeader, GlobalVariables, type Tx } from '@aztec/stdlib/tx';
4748
import type { FullNodeCheckpointsBuilder, ValidatorClient } from '@aztec/validator-client';
@@ -1335,6 +1336,7 @@ describe('sequencer', () => {
13351336
// checkpointed and proposed-checkpoint tips sit at the given checkpoint numbers.
13361337
const setupSyncedToBlock = (opts: {
13371338
blockNumber: BlockNumber;
1339+
blockSlot: SlotNumber;
13381340
blockCheckpointNumber: CheckpointNumber;
13391341
checkpointedCheckpointNumber: CheckpointNumber;
13401342
proposedCheckpointTipNumber: CheckpointNumber;
@@ -1376,7 +1378,9 @@ describe('sequencer', () => {
13761378
l1ToL2MessageSource.getL2Tips.mockResolvedValue(tips);
13771379
p2p.getStatus.mockResolvedValue({ syncedToL2Block: { number: opts.blockNumber, hash } } as any);
13781380
l2BlockSource.getBlockData.mockResolvedValue({
1379-
header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: opts.blockNumber }) }),
1381+
header: BlockHeader.empty({
1382+
globalVariables: GlobalVariables.empty({ blockNumber: opts.blockNumber, slotNumber: opts.blockSlot }),
1383+
}),
13801384
archive: AppendOnlyTreeSnapshot.empty(),
13811385
blockHash: BlockHash.ZERO,
13821386
checkpointNumber: opts.blockCheckpointNumber,
@@ -1385,16 +1389,23 @@ describe('sequencer', () => {
13851389
l2BlockSource.getProposedCheckpointData.mockResolvedValue(opts.proposedCheckpointData);
13861390
};
13871391

1388-
it('returns undefined and warns when the proposed block has no matching proposed checkpoint', async () => {
1392+
// The orphan block sits at slot 3; with pipelining offset 1 and a grace of MIN_EXECUTION_TIME (no
1393+
// blockDurationMs configured) its enclosing checkpoint is due at l1GenesisTime + 3 * slotDuration + 2.
1394+
const orphanCheckpointDueSeconds = () => Number(l1Constants.l1GenesisTime) + 3 * slotDuration + MIN_EXECUTION_TIME;
1395+
1396+
it('returns undefined and warns once the missing proposed checkpoint is overdue', async () => {
13891397
// Local tip is a block at checkpoint 3, but the checkpointed and proposed-checkpoint tips are
1390-
// still at checkpoint 2 and no proposed checkpoint 3 exists: an orphan block-only tip.
1398+
// still at checkpoint 2 and no proposed checkpoint 3 exists: an orphan block-only tip whose
1399+
// enclosing checkpoint should have been proposed by now.
13911400
setupSyncedToBlock({
13921401
blockNumber: BlockNumber(3),
1402+
blockSlot: SlotNumber(3),
13931403
blockCheckpointNumber: CheckpointNumber(3),
13941404
checkpointedCheckpointNumber: CheckpointNumber(2),
13951405
proposedCheckpointTipNumber: CheckpointNumber(2),
13961406
proposedCheckpointData: undefined,
13971407
});
1408+
dateProvider.setTime((orphanCheckpointDueSeconds() + 1) * 1000);
13981409
const warnSpy = jest.spyOn(sequencer.getLogger(), 'warn');
13991410

14001411
const result = await sequencer.checkSyncForTest({ ts: 1000n, slot: SlotNumber(2) });
@@ -1411,9 +1422,31 @@ describe('sequencer', () => {
14111422
);
14121423
});
14131424

1425+
it('returns undefined without warning while the proposed checkpoint is not yet overdue', async () => {
1426+
// Same orphan-shaped tip, but we are still within the normal pipelining window: the block proposal
1427+
// for checkpoint 3 has arrived ahead of its checkpoint proposal, which is not yet due. This is the
1428+
// happy-path steady state and must not warn.
1429+
setupSyncedToBlock({
1430+
blockNumber: BlockNumber(3),
1431+
blockSlot: SlotNumber(3),
1432+
blockCheckpointNumber: CheckpointNumber(3),
1433+
checkpointedCheckpointNumber: CheckpointNumber(2),
1434+
proposedCheckpointTipNumber: CheckpointNumber(2),
1435+
proposedCheckpointData: undefined,
1436+
});
1437+
dateProvider.setTime((orphanCheckpointDueSeconds() - 1) * 1000);
1438+
const warnSpy = jest.spyOn(sequencer.getLogger(), 'warn');
1439+
1440+
const result = await sequencer.checkSyncForTest({ ts: 1000n, slot: SlotNumber(2) });
1441+
1442+
expect(result).toBeUndefined();
1443+
expect(warnSpy).not.toHaveBeenCalled();
1444+
});
1445+
14141446
it('proceeds when a matching proposed checkpoint exists for the block', async () => {
14151447
setupSyncedToBlock({
14161448
blockNumber: BlockNumber(3),
1449+
blockSlot: SlotNumber(3),
14171450
blockCheckpointNumber: CheckpointNumber(3),
14181451
checkpointedCheckpointNumber: CheckpointNumber(2),
14191452
proposedCheckpointTipNumber: CheckpointNumber(3),

yarn-project/sequencer-client/src/sequencer/sequencer.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
} from '@aztec/stdlib/block';
2121
import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
2222
import type { ChainConfig } from '@aztec/stdlib/config';
23-
import { getEpochAtSlot, getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers';
23+
import { getEpochAtSlot, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
2424
import {
2525
type ResolvedSequencerConfig,
2626
type SequencerConfig,
@@ -30,6 +30,7 @@ import {
3030
import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging';
3131
import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p';
3232
import { pickFromSchema } from '@aztec/stdlib/schemas';
33+
import { MIN_EXECUTION_TIME } from '@aztec/stdlib/timetable';
3334
import { Attributes, type TelemetryClient, type Tracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client';
3435
import { FullNodeCheckpointsBuilder, NodeKeystoreAdapter, type ValidatorClient } from '@aztec/validator-client';
3536

@@ -727,7 +728,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
727728
(l2Tips.proposedCheckpoint.checkpoint.number !== blockData.checkpointNumber ||
728729
proposedCheckpointData?.checkpointNumber !== blockData.checkpointNumber)
729730
) {
730-
this.log.warn(`Sequencer sync check failed: proposed block has no matching proposed checkpoint`, {
731+
const logCtx = {
731732
blockCheckpointNumber: blockData.checkpointNumber,
732733
checkpointedCheckpointNumber: l2Tips.checkpointed.checkpoint.number,
733734
proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number,
@@ -736,7 +737,18 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
736737
blockSlot: blockData.header.getSlot(),
737738
syncedL2Slot,
738739
...args,
739-
});
740+
};
741+
742+
// Under pipelining the block proposal for a checkpoint leads its checkpoint proposal by up to one
743+
// slot, so a world-state tip sitting in an as-yet-unproposed checkpoint is the expected steady state
744+
// until that checkpoint is due. Only treat it as abnormal — and warn — once the checkpoint is overdue
745+
// by the same deadline the archiver uses to prune the orphan block (see pruneOrphanProposedBlocks).
746+
// Before then this is normal pipelining and we wait it out quietly.
747+
if (this.isProposedCheckpointOverdue(blockData.header.getSlot())) {
748+
this.log.warn(`Sequencer sync check failed: proposed block has no matching proposed checkpoint`, logCtx);
749+
} else {
750+
this.log.debug(`Waiting for proposed checkpoint to catch up with reexecuted block`, logCtx);
751+
}
740752
return undefined;
741753
}
742754

@@ -787,6 +799,21 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
787799
};
788800
}
789801

802+
/**
803+
* Whether the enclosing checkpoint of a reexecuted block is overdue: past the deadline by which a
804+
* well-behaved proposer should have published it. Mirrors the archiver's orphan-prune deadline (the
805+
* start of the slot after the block's build slot, plus a grace period) so the sequencer only warns
806+
* about a missing proposed checkpoint once the archiver itself would prune the orphan block. The grace
807+
* is derived from the block build duration the same way the archiver defaults it at node wiring.
808+
*/
809+
private isProposedCheckpointOverdue(blockSlot: SlotNumber): boolean {
810+
const expectedBySlot = SlotNumber(Number(blockSlot) - PROPOSER_PIPELINING_SLOT_OFFSET + 1);
811+
const graceSeconds =
812+
this.config.blockDurationMs !== undefined ? Math.ceil(this.config.blockDurationMs / 1000) : MIN_EXECUTION_TIME;
813+
const expectedByTime = getTimestampForSlot(expectedBySlot, this.l1Constants) + BigInt(graceSeconds);
814+
return BigInt(this.dateProvider.nowInSeconds()) >= expectedByTime;
815+
}
816+
790817
/**
791818
* Checks if we are the proposer for the next slot.
792819
* @returns True if we can propose, and the proposer address (undefined if anyone can propose)

0 commit comments

Comments
 (0)