Skip to content

Commit f0b9428

Browse files
committed
feat(sequencer): add waitForBuildDeadlineOnFinalBlock flag
The proposer seals the final block of a checkpoint as soon as minTxsPerBlock are available and then snapshots the mempool. When txs are submitted as a burst late in the slot, they propagate over gossip one at a time, so the snapshot captures only a subset and the remainder spill into the next checkpoint. Add an opt-in sequencer flag (default off, so production behaviour is unchanged) that makes the final block of a checkpoint keep collecting txs until its build deadline before sealing. The build deadline already reserves the post-build window for attestation collection, so this does not change checkpoint timing. Enable the flag in e2e_epochs/epochs_mbps_redistribution, which dumps a burst of late txs and asserts they all land in the final block; without this it raced the proposer's snapshot and flaked. Covered by new checkpoint_proposal_job timing unit tests for both the default (seal early) and flag-on (wait) behaviour.
1 parent 103dc95 commit f0b9428

6 files changed

Lines changed: 96 additions & 5 deletions

File tree

yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ describe('e2e_epochs/epochs_mbps_redistribution', () => {
109109
// With 3 blocks and multiplier 1.2: maxTxsPerBlock = ceil(TOTAL_TX_COUNT/3*1.2).
110110
// The redistribution should cap early blocks, preserving budget for the last block.
111111
maxTxsPerCheckpoint: TOTAL_TX_COUNT,
112+
// Make the final block collect txs until its build deadline rather than sealing on the first late tx.
113+
// Otherwise the proposer snapshots the mempool mid-arrival and the late txs (which propagate over gossip
114+
// one at a time) spill across two blocks, making the `lateBlockNumbers` assertion flaky.
115+
waitForBuildDeadlineOnFinalBlock: true,
112116
// PXE syncs on checkpointed chain tip.
113117
pxeOpts: { syncChainTip: 'checkpointed' },
114118
...contextConfigOverride,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const DefaultSequencerConfig = {
4444
sequencerPollingIntervalMS: 500,
4545
minTxsPerBlock: 1,
4646
buildCheckpointIfEmpty: false,
47+
waitForBuildDeadlineOnFinalBlock: false,
4748
publishTxsWithProposals: false,
4849
perBlockAllocationMultiplier: MIN_PER_BLOCK_ALLOCATION_MULTIPLIER,
4950
perBlockDAAllocationMultiplier: MIN_PER_BLOCK_DA_ALLOCATION_MULTIPLIER,
@@ -241,6 +242,12 @@ export const sequencerConfigMappings: ConfigMappingsType<SequencerConfig> = {
241242
description: 'Have sequencer build and publish an empty checkpoint if there are no txs',
242243
...booleanConfigHelper(DefaultSequencerConfig.buildCheckpointIfEmpty),
243244
},
245+
waitForBuildDeadlineOnFinalBlock: {
246+
description:
247+
'Wait until the build deadline before sealing the final block of a checkpoint instead of sealing as ' +
248+
'soon as minTxsPerBlock are available (for testing only)',
249+
...booleanConfigHelper(DefaultSequencerConfig.waitForBuildDeadlineOnFinalBlock),
250+
},
244251
skipPushProposedBlocksToArchiver: {
245252
description: 'Skip pushing proposed blocks to archiver (default: true)',
246253
...booleanConfigHelper(DefaultSequencerConfig.skipPushProposedBlocksToArchiver),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,7 @@ describe('CheckpointProposalJob', () => {
13521352
const result = await job.buildSingleBlock(checkpointBuilder, {
13531353
blockNumber: newBlockNumber,
13541354
indexWithinCheckpoint: IndexWithinCheckpoint(1),
1355+
isLastBlock: false,
13551356
buildDeadline: undefined,
13561357
blockTimestamp: 0n,
13571358
txHashesAlreadyIncluded: new Set<string>(),
@@ -1373,6 +1374,7 @@ describe('CheckpointProposalJob', () => {
13731374
const result = await job.buildSingleBlock(checkpointBuilder, {
13741375
blockNumber: newBlockNumber,
13751376
indexWithinCheckpoint: IndexWithinCheckpoint(1),
1377+
isLastBlock: false,
13761378
buildDeadline: undefined,
13771379
blockTimestamp: 0n,
13781380
txHashesAlreadyIncluded: new Set<string>(),
@@ -1742,6 +1744,7 @@ class TestCheckpointProposalJob extends CheckpointProposalJob {
17421744
checkpointBuilder: CheckpointBuilder,
17431745
opts: {
17441746
forceCreate?: boolean;
1747+
isLastBlock: boolean;
17451748
blockTimestamp: bigint;
17461749
blockNumber: BlockNumber;
17471750
indexWithinCheckpoint: IndexWithinCheckpoint;

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,59 @@ describe('CheckpointProposalJob Timing Tests', () => {
561561
expect(checkpointMetrics.noteCheckpointBroadcast).toHaveBeenCalledWith(expect.any(Number));
562562
});
563563

564+
// 30s block duration in the 72s slot derives exactly one block, so the single built block is also the
565+
// last block of the checkpoint - the only block affected by waitForBuildDeadlineOnFinalBlock.
566+
const singleBlockTimetable = () =>
567+
makeProposerTimetable({
568+
l1Constants,
569+
p2pPropagationTime: P2P_PROPAGATION_TIME,
570+
checkpointProposalPrepareTime: CHECKPOINT_ASSEMBLE_TIME,
571+
blockDurationMs: 30000,
572+
});
573+
574+
it('seals the final block as soon as txs are available by default', async () => {
575+
const { blocks, txs } = await createTestBlocksAndTxs(1);
576+
mockP2pWithTxs(txs);
577+
checkpointBuilder.seedBlocks(blocks, [[txs[0]]]);
578+
checkpointBuilder.setExecutionDurations([1]);
579+
validatorClient.collectAttestations.mockResolvedValue(getAttestations(blocks[0]));
580+
581+
setTimeInSlot(0.5);
582+
583+
const job = createJob();
584+
job.setTimetable(singleBlockTimetable());
585+
586+
await job.execute();
587+
588+
expect(checkpointBuilder.buildBlockCalls.length).toBe(1);
589+
// A tx is available from the start (0.5s), so the block is built immediately rather than waiting.
590+
expect(checkpointBuilder.recordedBuildTimes[0].startTime).toBeLessThan(2);
591+
});
592+
593+
it('waits until the build deadline before sealing the final block when waitForBuildDeadlineOnFinalBlock is set', async () => {
594+
const { blocks, txs } = await createTestBlocksAndTxs(1);
595+
mockP2pWithTxs(txs);
596+
checkpointBuilder.seedBlocks(blocks, [[txs[0]]]);
597+
checkpointBuilder.setExecutionDurations([1]);
598+
validatorClient.collectAttestations.mockResolvedValue(getAttestations(blocks[0]));
599+
600+
setTimeInSlot(0.5);
601+
602+
const job = createJob();
603+
job.setTimetable(singleBlockTimetable());
604+
job.updateConfig({ waitForBuildDeadlineOnFinalBlock: true });
605+
606+
await job.execute();
607+
608+
expect(checkpointBuilder.buildBlockCalls.length).toBe(1);
609+
// Even though the tx is available from the start, the final block now waits until its build deadline
610+
// (minus min_block_duration) before sealing, so a late burst of txs would still be picked up.
611+
const deadline = checkpointBuilder.buildBlockCalls[0].opts.deadline!;
612+
const startBuildingDeadline =
613+
deadline.getTime() / 1000 - getSlotStartTime(slotNumber) - job.getTimetable().minBlockDuration;
614+
expect(checkpointBuilder.recordedBuildTimes[0].startTime).toBeGreaterThanOrEqual(startBuildingDeadline - 0.01);
615+
});
616+
564617
it('builds maximum blocks when given enough time', async () => {
565618
const { blocks, txs } = await createTestBlocksAndTxs(EXPECTED_MAX_BLOCKS);
566619
mockP2pWithTxs(txs);

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,7 @@ export class CheckpointProposalJob implements Traceable {
906906
blockTimestamp: timestamp,
907907
// Create an empty block if we haven't already and this is the last one
908908
forceCreate: timingInfo.isLastBlock && blocksBuilt === 0 && this.config.buildCheckpointIfEmpty,
909+
isLastBlock: timingInfo.isLastBlock,
909910
buildDeadline: new Date(timingInfo.deadline * 1000),
910911
blockNumber,
911912
indexWithinCheckpoint,
@@ -1030,6 +1031,7 @@ export class CheckpointProposalJob implements Traceable {
10301031
checkpointBuilder: CheckpointBuilder,
10311032
opts: {
10321033
forceCreate?: boolean;
1034+
isLastBlock: boolean;
10331035
blockTimestamp: bigint;
10341036
blockNumber: BlockNumber;
10351037
indexWithinCheckpoint: IndexWithinCheckpoint;
@@ -1238,11 +1240,12 @@ export class CheckpointProposalJob implements Traceable {
12381240
@trackSpan('CheckpointProposalJob.waitForMinTxs')
12391241
private async waitForMinTxs(opts: {
12401242
forceCreate?: boolean;
1243+
isLastBlock: boolean;
12411244
blockNumber: BlockNumber;
12421245
indexWithinCheckpoint: IndexWithinCheckpoint;
12431246
buildDeadline: Date | undefined;
12441247
}): Promise<{ canStartBuilding: boolean; availableTxs: number; minTxs: number }> {
1245-
const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate } = opts;
1248+
const { indexWithinCheckpoint, blockNumber, buildDeadline, forceCreate, isLastBlock } = opts;
12461249

12471250
// We only allow a block with 0 txs in the first block of the checkpoint
12481251
const minTxs = indexWithinCheckpoint > 0 && this.config.minTxsPerBlock === 0 ? 1 : this.config.minTxsPerBlock;
@@ -1252,19 +1255,33 @@ export class CheckpointProposalJob implements Traceable {
12521255
? new Date(buildDeadline.getTime() - this.timetable.minBlockDuration * 1000)
12531256
: undefined;
12541257

1258+
// When enabled, the final block of a checkpoint keeps collecting txs until its build deadline rather than
1259+
// sealing as soon as minTxs are available. This lets a burst of txs submitted late in the slot all land in
1260+
// the final block instead of spilling into the next checkpoint. Opt-in (test only); production seals early.
1261+
const waitForBuildDeadline = isLastBlock && !!this.config.waitForBuildDeadlineOnFinalBlock;
1262+
12551263
let availableTxs = await this.p2pClient.getPendingTxCount();
12561264

1257-
while (!forceCreate && availableTxs < minTxs) {
1258-
// If we're past deadline, or we have no deadline, give up
1265+
while (!forceCreate) {
12591266
const now = this.dateProvider.nowAsDate();
1260-
if (startBuildingDeadline === undefined || now >= startBuildingDeadline) {
1267+
const pastDeadline = startBuildingDeadline === undefined || now >= startBuildingDeadline;
1268+
const haveMinTxs = availableTxs >= minTxs;
1269+
1270+
// Earlier blocks (and the final block by default) seal as soon as they reach minTxs. With
1271+
// waitForBuildDeadlineOnFinalBlock set, the final block instead waits until its build deadline.
1272+
const doneWaiting = waitForBuildDeadline ? pastDeadline && haveMinTxs : haveMinTxs;
1273+
if (doneWaiting) {
1274+
break;
1275+
}
1276+
// Out of time without minTxs: give up.
1277+
if (pastDeadline) {
12611278
return { canStartBuilding: false, availableTxs, minTxs };
12621279
}
12631280

12641281
// Wait a bit before checking again
12651282
this.setState(SequencerState.WAITING_FOR_TXS);
12661283
this.log.verbose(
1267-
`Waiting for enough txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs} but need ${minTxs})`,
1284+
`Waiting for txs to build block ${blockNumber} at index ${indexWithinCheckpoint} in slot ${this.targetSlot} (have ${availableTxs}, need ${minTxs}${waitForBuildDeadline ? ', final block collecting until build deadline' : ''})`,
12681285
{ blockNumber, slot: this.targetSlot, indexWithinCheckpoint },
12691286
);
12701287
await this.waitForTxsPollingInterval();

yarn-project/stdlib/src/interfaces/configs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ export interface SequencerConfig {
108108
expectedBlockProposalsPerSlot?: number;
109109
/** Have sequencer build and publish an empty checkpoint if there are no txs */
110110
buildCheckpointIfEmpty?: boolean;
111+
/**
112+
* On the final block of a checkpoint, wait until the block build deadline before sealing rather than
113+
* sealing as soon as `minTxsPerBlock` are available. This lets a burst of txs submitted late in the slot
114+
* all land in the final block instead of spilling into the next checkpoint (for testing only).
115+
*/
116+
waitForBuildDeadlineOnFinalBlock?: boolean;
111117
/** Skip pushing proposed blocks to archiver (default: false) */
112118
skipPushProposedBlocksToArchiver?: boolean;
113119
/** Minimum number of blocks required for a checkpoint proposal (test only, defaults to undefined = no minimum) */
@@ -171,6 +177,7 @@ export const SequencerConfigSchema = zodFor<SequencerConfig>()(
171177
checkpointProposalSyncGraceSeconds: z.number().nonnegative().optional(),
172178
expectedBlockProposalsPerSlot: z.number().nonnegative().optional(),
173179
buildCheckpointIfEmpty: z.boolean().optional(),
180+
waitForBuildDeadlineOnFinalBlock: z.boolean().optional(),
174181
skipPushProposedBlocksToArchiver: z.boolean().optional(),
175182
minBlocksForCheckpoint: z.number().positive().optional(),
176183
skipPublishingCheckpointsPercent: z.number().gte(0).lte(100).optional(),

0 commit comments

Comments
 (0)