Skip to content

Commit d6d80f9

Browse files
committed
fix: archiver prune condition
1 parent 516de43 commit d6d80f9

3 files changed

Lines changed: 115 additions & 35 deletions

File tree

yarn-project/archiver/src/archiver-sync.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { ProposedCheckpointInput } from '@aztec/stdlib/checkpoint';
2020
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
2121
import { computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging';
2222
import { CheckpointHeader } from '@aztec/stdlib/rollup';
23+
import { mockCheckpointAndMessages } from '@aztec/stdlib/testing';
2324
import { BlockHeader } from '@aztec/stdlib/tx';
2425
import { getTelemetryClient } from '@aztec/telemetry-client';
2526

@@ -2172,6 +2173,7 @@ describe('Archiver Sync', () => {
21722173

21732174
// Wall-clock time (seconds) at which the orphan tip becomes prunable: start(orphanSlot) + grace.
21742175
const pruneDeadline = () => now + Number(orphanSlot) * l1Constants.slotDuration + graceSeconds;
2176+
const pruneDeadlineForSlot = (slot: SlotNumber) => now + Number(slot) * l1Constants.slotDuration + graceSeconds;
21752177

21762178
// Syncs checkpoint 1 (slot 0), then writes uncheckpointed blocks for slot 1 (checkpoint 2) straight
21772179
// into the store as a block-only tip with no matching proposed checkpoint. L1 is held at slot 1 so
@@ -2268,5 +2270,37 @@ describe('Archiver Sync', () => {
22682270
expect(await archiver.getBlockNumber()).toEqual(lastProvisional);
22692271
expect(await archiverStore.blocks.getLastProposedCheckpoint()).toBeDefined();
22702272
}, 15_000);
2273+
2274+
it('prunes only the orphan suffix after a covered pending checkpoint', async () => {
2275+
const { lastBlockInCp1, provisionalBlocks: checkpointTwoBlocks } = await setupOrphanTip();
2276+
2277+
await archiver.addProposedCheckpoint(makeProposedCheckpoint(lastBlockInCp1, checkpointTwoBlocks.length));
2278+
2279+
const orphanSuffixSlot = SlotNumber(orphanSlot + 1);
2280+
const { checkpoint: orphanSuffixCheckpoint } = await mockCheckpointAndMessages(CheckpointNumber(3), {
2281+
startBlockNumber: BlockNumber(checkpointTwoBlocks.at(-1)!.number + 1),
2282+
numBlocks: 1,
2283+
previousArchive: checkpointTwoBlocks.at(-1)!.archive,
2284+
slotNumber: orphanSuffixSlot,
2285+
});
2286+
const orphanSuffixBlocks = orphanSuffixCheckpoint.blocks;
2287+
for (const block of orphanSuffixBlocks) {
2288+
await archiverStore.blocks.addProposedBlock(block);
2289+
}
2290+
2291+
dateProvider.setTime((pruneDeadlineForSlot(orphanSuffixSlot) + 1) * 1000);
2292+
await archiver.syncImmediate();
2293+
2294+
expect(pruneSpy).toHaveBeenCalledWith(
2295+
expect.objectContaining({
2296+
type: L2BlockSourceEvents.L2PruneUncheckpointed,
2297+
slotNumber: orphanSuffixSlot,
2298+
blocks: expect.arrayContaining(orphanSuffixBlocks.map(b => expect.objectContaining({ number: b.number }))),
2299+
}),
2300+
);
2301+
expect(await archiver.getBlockNumber()).toEqual(checkpointTwoBlocks.at(-1)!.number);
2302+
expect(await archiverStore.blocks.getProposedCheckpointByNumber(CheckpointNumber(2))).toBeDefined();
2303+
expect(await archiverStore.blocks.getProposedCheckpointByNumber(CheckpointNumber(3))).toBeUndefined();
2304+
}, 15_000);
22712305
});
22722306
});

yarn-project/archiver/src/archiver.ts

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@ import { RunningPromise, makeLoggingErrorHandler } from '@aztec/foundation/runni
1414
import { DateProvider, elapsed } from '@aztec/foundation/timer';
1515
import {
1616
type ArchiverEmitter,
17+
type BlockData,
1718
type BlockHash,
1819
L2Block,
1920
type L2BlockSink,
2021
L2BlockSourceEvents,
2122
type L2Tips,
2223
type ValidateCheckpointResult,
2324
} from '@aztec/stdlib/block';
24-
import { type ProposedCheckpointInput, PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
25+
import {
26+
type ProposedCheckpointData,
27+
type ProposedCheckpointInput,
28+
PublishedCheckpoint,
29+
} from '@aztec/stdlib/checkpoint';
2530
import {
2631
type L1RollupConstants,
2732
getEpochAtSlot,
@@ -346,12 +351,12 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
346351
private async sync() {
347352
// Process any queued blocks first, before doing L1 sync
348353
await this.processInboundQueue();
354+
// Now perform L1 sync
355+
await this.syncFromL1();
349356
// Prune orphan proposed blocks (block-only tips with no matching proposed checkpoint) on wall-clock
350357
// time. Runs after the queue is drained so freshly-arrived proposed checkpoints are seen first, and
351-
// before L1 sync so it fires even when L1 has not advanced.
358+
// after L1 sync so a canonical checkpoint that already landed on L1 is promoted before local rollback.
352359
await this.pruneOrphanProposedBlocks();
353-
// Now perform L1 sync
354-
await this.syncFromL1();
355360
}
356361

357362
/**
@@ -364,9 +369,8 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
364369
* should have arrived. This runs on wall-clock time (not L1 block advancement) so it fires during
365370
* quiet L1 periods, and is the liveness counterpart to the sequencer's checkSync guard.
366371
*
367-
* Only the first uncheckpointed block is inspected: if its checkpoint is backed by a proposed
368-
* checkpoint, the tip is legitimate and left for promotion (or for the L1-sync prune to clear if it
369-
* later goes stale); if not, every uncheckpointed block chains off the orphan and is pruned.
372+
* The uncheckpointed suffix is scanned in order. Blocks covered by proposed checkpoints are left in
373+
* place; the first block not covered by a proposed checkpoint starts the orphan suffix to prune.
370374
*/
371375
private async pruneOrphanProposedBlocks(): Promise<void> {
372376
const [lastCheckpointedBlockNumber, lastProposedBlockNumber] = await Promise.all([
@@ -379,39 +383,30 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
379383
return;
380384
}
381385

382-
const firstUncheckpointedBlockNumber = BlockNumber(lastCheckpointedBlockNumber + 1);
383-
const firstUncheckpointedBlockData = await this.stores.blocks.getBlockData({
384-
number: firstUncheckpointedBlockNumber,
386+
const uncheckpointedBlockData = await this.stores.blocks.getBlocksData({
387+
from: BlockNumber(lastCheckpointedBlockNumber + 1),
388+
limit: lastProposedBlockNumber - lastCheckpointedBlockNumber,
385389
});
386-
if (firstUncheckpointedBlockData === undefined) {
390+
if (uncheckpointedBlockData.length === 0) {
387391
return;
388392
}
389393

390-
const blockCheckpointNumber = firstUncheckpointedBlockData.checkpointNumber;
391-
const blockSlot = firstUncheckpointedBlockData.header.getSlot();
392-
393-
// A proposed checkpoint covering this block's checkpoint means the tip is not an orphan.
394-
const proposedCheckpoint = await this.stores.blocks.getProposedCheckpointByNumber(blockCheckpointNumber);
395-
if (proposedCheckpoint !== undefined) {
394+
const candidate = await this.findOrphanProposedBlockPruneCandidate(
395+
uncheckpointedBlockData,
396+
lastCheckpointedBlockNumber,
397+
);
398+
if (candidate === undefined) {
396399
return;
397400
}
398401

399-
// The proposed checkpoint should have landed by the start of the slot after the block's build slot
400-
// (build slot = blockSlot - pipeliningOffset). Wait a grace period beyond that to tolerate propagation.
401-
const pipeliningOffset = this.epochCache.pipeliningOffset();
402-
const deadlineSlot = SlotNumber(Number(blockSlot) - pipeliningOffset + 1);
403-
const pruneAfter =
404-
getTimestampForSlot(deadlineSlot, this.l1Constants) + BigInt(this.config.orphanProposedBlockPruneGraceSeconds);
405-
const now = BigInt(this.dateProvider.nowInSeconds());
406-
if (now < pruneAfter) {
407-
return;
408-
}
402+
const { blockCheckpointNumber, blockSlot, deadlineSlot, pruneAfter, now, pipeliningOffset, pruneAfterBlockNumber } =
403+
candidate;
409404

410405
this.log.warn(
411-
`Pruning orphan blocks after block ${lastCheckpointedBlockNumber}: block at slot ${blockSlot} belongs to ` +
406+
`Pruning orphan blocks after block ${pruneAfterBlockNumber}: block at slot ${blockSlot} belongs to ` +
412407
`checkpoint ${blockCheckpointNumber} which has no matching proposed checkpoint`,
413408
{
414-
firstUncheckpointedBlockHeader: firstUncheckpointedBlockData.header.toInspect(),
409+
firstUncheckpointedBlockHeader: candidate.blockData.header.toInspect(),
415410
blockCheckpointNumber,
416411
blockSlot,
417412
pipeliningOffset,
@@ -421,7 +416,7 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
421416
},
422417
);
423418

424-
const prunedBlocks = await this.updater.removeUncheckpointedBlocksAfter(lastCheckpointedBlockNumber);
419+
const prunedBlocks = await this.updater.removeUncheckpointedBlocksAfter(pruneAfterBlockNumber);
425420
if (prunedBlocks.length > 0) {
426421
this.events.emit(L2BlockSourceEvents.L2PruneUncheckpointed, {
427422
type: L2BlockSourceEvents.L2PruneUncheckpointed,
@@ -431,6 +426,59 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra
431426
}
432427
}
433428

429+
private async findOrphanProposedBlockPruneCandidate(
430+
uncheckpointedBlockData: BlockData[],
431+
lastCheckpointedBlockNumber: BlockNumber,
432+
) {
433+
const proposedCheckpoints = new Map<number, ProposedCheckpointData | undefined>();
434+
let pruneAfterBlockNumber = lastCheckpointedBlockNumber;
435+
436+
for (const blockData of uncheckpointedBlockData) {
437+
const blockNumber = blockData.header.getBlockNumber();
438+
const blockCheckpointNumber = blockData.checkpointNumber;
439+
const checkpointKey = Number(blockCheckpointNumber);
440+
let proposedCheckpoint = proposedCheckpoints.get(checkpointKey);
441+
if (!proposedCheckpoints.has(checkpointKey)) {
442+
proposedCheckpoint = await this.stores.blocks.getProposedCheckpointByNumber(blockCheckpointNumber);
443+
proposedCheckpoints.set(checkpointKey, proposedCheckpoint);
444+
}
445+
446+
if (
447+
proposedCheckpoint !== undefined &&
448+
blockNumber >= proposedCheckpoint.startBlock &&
449+
blockNumber < proposedCheckpoint.startBlock + proposedCheckpoint.blockCount
450+
) {
451+
pruneAfterBlockNumber = blockNumber;
452+
continue;
453+
}
454+
455+
// The proposed checkpoint should have landed by the start of the slot after the block's build slot
456+
// (build slot = blockSlot - pipeliningOffset). Wait a grace period beyond that to tolerate propagation.
457+
const blockSlot = blockData.header.getSlot();
458+
const pipeliningOffset = this.epochCache.pipeliningOffset();
459+
const deadlineSlot = SlotNumber(Number(blockSlot) - pipeliningOffset + 1);
460+
const pruneAfter =
461+
getTimestampForSlot(deadlineSlot, this.l1Constants) + BigInt(this.config.orphanProposedBlockPruneGraceSeconds);
462+
const now = BigInt(this.dateProvider.nowInSeconds());
463+
if (now < pruneAfter) {
464+
return undefined;
465+
}
466+
467+
return {
468+
blockData,
469+
blockCheckpointNumber,
470+
blockSlot,
471+
deadlineSlot,
472+
pruneAfter,
473+
now,
474+
pipeliningOffset,
475+
pruneAfterBlockNumber,
476+
};
477+
}
478+
479+
return undefined;
480+
}
481+
434482
private async syncFromL1() {
435483
// Delegate to the L1 synchronizer
436484
await this.synchronizer.syncFromL1(this.initialSyncComplete);

yarn-project/archiver/src/modules/data_store_updater.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,10 @@ export class ArchiverDataStoreUpdater {
262262
);
263263
}
264264

265-
const result = await this.removeBlocksAfter(blockNumber);
265+
const prunedBlocks = await this.removeBlocksAfter(blockNumber);
266+
await this.evictProposedCheckpointsForPrunedBlocks(prunedBlocks);
266267

267-
// Clear all pending proposed checkpoints since their blocks have been pruned
268-
await this.stores.blocks.deleteProposedCheckpoints();
269-
270-
return result;
268+
return prunedBlocks;
271269
});
272270
await this.l2TipsCache?.refresh();
273271
return result;

0 commit comments

Comments
 (0)