Skip to content

Commit 30c2f6e

Browse files
authored
refactor(pxe): append only NoteStore and PrivateEventStore (#23785)
Refactors mainly NoteStore so that discovered nullifiers are modeled as append-only rows instead of mutating stored notes. Note the store isn't 100% append-only: adding scopes to a note still causes a modification. I deemed this less cumbersome than introducing more indexes just to track scopes. Includes some minor touches to PrivateEventStore mostly to align style, since it was already mostly in the shape we need it to. Context: this is preliminary work for the "time-travelling pxe db" project. We need reorg semantics to be consistent between stores, dealing with mutation on reorgs makes the whole model harder to reason about. You'll see a longer than expected commit history: this is because I originally set out to change the way we handle reorgs much more fundamentally, by making the stores tolerate multiple versions of notes and events as long as they are recorded as having been originated from different blocks (which could happen in reorg scenarios). Implementing said approach made me realize we don't win much from the additional complexity: in order to filter out "orphaned" data and eventually being able to reap it, we would need to keep track of height->hash mappings from genesis. At that point, the delete-on-chain-prune original approach seemed more sensible, which ostensibly reduced the scope of this change to what I describe above.
1 parent 2081226 commit 30c2f6e

17 files changed

Lines changed: 961 additions & 928 deletions

File tree

yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts

Lines changed: 196 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@ import { Fr } from '@aztec/foundation/curves/bn254';
44
import type { AztecAsyncKVStore } from '@aztec/kv-store';
55
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
66
import { L2TipsKVStore } from '@aztec/kv-store/stores';
7+
import { EventSelector } from '@aztec/stdlib/abi';
8+
import { AztecAddress } from '@aztec/stdlib/aztec-address';
79
import {
810
type BlockData,
911
BlockHash,
1012
GENESIS_BLOCK_HEADER_HASH,
1113
GENESIS_CHECKPOINT_HEADER_HASH,
1214
L2Block,
15+
type L2BlockId,
1316
type L2BlockStream,
17+
makeL2BlockId,
18+
makeL2CheckpointId,
1419
} from '@aztec/stdlib/block';
1520
import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
16-
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
21+
import type { AztecNode, BlockResponse } from '@aztec/stdlib/interfaces/client';
22+
import { NoteDao, NoteStatus } from '@aztec/stdlib/note';
23+
import { TxHash } from '@aztec/stdlib/tx';
1724

18-
import { jest } from '@jest/globals';
1925
import { type MockProxy, mock } from 'jest-mock-extended';
2026

2127
import type { BlockSynchronizerConfig } from '../config/index.js';
@@ -25,6 +31,12 @@ import { NoteStore } from '../storage/note_store/note_store.js';
2531
import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js';
2632
import { BlockSynchronizer } from './block_synchronizer.js';
2733

34+
// `AztecNode.getBlock` is generic over its include-options; `Parameters`/`ReturnType` collapse that
35+
// type parameter to its `BlockIncludeOptions` constraint, yielding a concrete signature the mock can satisfy.
36+
type NodeGetBlockMock = jest.MockedFunction<
37+
(...args: Parameters<AztecNode['getBlock']>) => ReturnType<AztecNode['getBlock']>
38+
>;
39+
2840
describe('BlockSynchronizer', () => {
2941
let synchronizer: BlockSynchronizer;
3042
let store: AztecAsyncKVStore;
@@ -33,6 +45,7 @@ describe('BlockSynchronizer', () => {
3345
let noteStore: NoteStore;
3446
let privateEventStore: PrivateEventStore;
3547
let aztecNode: MockProxy<AztecNode>;
48+
let getBlock: NodeGetBlockMock;
3649
let blockStream: MockProxy<L2BlockStream>;
3750
let contractSyncService: MockProxy<ContractSyncService>;
3851

@@ -55,10 +68,44 @@ describe('BlockSynchronizer', () => {
5568
);
5669
};
5770

71+
// Builds the BlockResponse the node returns for a block (handlers read its header to update the anchor).
72+
const blockResponse = async (block: L2Block): Promise<BlockResponse> => ({
73+
header: block.header,
74+
archive: block.archive,
75+
hash: await block.hash(),
76+
checkpointNumber: block.checkpointNumber,
77+
indexWithinCheckpoint: block.indexWithinCheckpoint,
78+
number: block.number,
79+
});
80+
81+
// Builds a note DAO anchored to the given block id (caller adds it to the store and commits).
82+
const noteAt = (contract: AztecAddress, block: L2BlockId): Promise<NoteDao> =>
83+
NoteDao.random({ contractAddress: contract, l2BlockNumber: block.number, l2BlockHash: block.hash });
84+
85+
// Stores one private event anchored to the given block id under the 'event-job' (caller commits).
86+
const storeEvent = (contract: AztecAddress, scope: AztecAddress, eventId: Fr, block: L2BlockId) =>
87+
privateEventStore.storePrivateEventLog(
88+
EventSelector.random(),
89+
Fr.random(),
90+
[Fr.random()],
91+
eventId,
92+
{
93+
contractAddress: contract,
94+
scope,
95+
txHash: TxHash.random(),
96+
l2BlockNumber: block.number,
97+
l2BlockHash: BlockHash.fromString(block.hash),
98+
txIndexInBlock: 0,
99+
eventIndexInTx: 0,
100+
},
101+
'event-job',
102+
);
103+
58104
beforeEach(async () => {
59105
store = await openTmpStore('test');
60106
blockStream = mock<L2BlockStream>();
61107
aztecNode = mock<AztecNode>();
108+
getBlock = aztecNode.getBlock as NodeGetBlockMock;
62109
tipsStore = new L2TipsKVStore(store, 'pxe', GENESIS_BLOCK_HEADER_HASH);
63110
anchorBlockStore = new AnchorBlockStore(store);
64111
noteStore = new NoteStore(store);
@@ -75,66 +122,26 @@ describe('BlockSynchronizer', () => {
75122
expect(obtainedHeader.equals(block.header)).toBe(true);
76123
});
77124

78-
it('removes notes from db on a reorg', async () => {
79-
const rollback = jest.spyOn(noteStore, 'rollback').mockImplementation(() => Promise.resolve());
80-
const block3Hash = Fr.fromString('0x3');
81-
aztecNode.getBlock.mockImplementation(async (block: any) => {
82-
if (block instanceof BlockHash && block.equals(block3Hash)) {
83-
const b = await L2Block.random(BlockNumber(3));
84-
return {
85-
header: b.header,
86-
archive: b.archive,
87-
hash: await b.hash(),
88-
checkpointNumber: b.checkpointNumber,
89-
indexWithinCheckpoint: b.indexWithinCheckpoint,
90-
number: b.number,
91-
} as any;
92-
}
93-
return undefined;
94-
});
95-
96-
await synchronizer.handleBlockStreamEvent({
97-
type: 'blocks-added',
98-
blocks: await timesParallel(5, i => L2Block.random(BlockNumber(i))),
99-
});
100-
await synchronizer.handleBlockStreamEvent({
101-
type: 'chain-pruned',
102-
block: { number: BlockNumber(3), hash: block3Hash.toString() },
103-
checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() },
104-
});
105-
106-
expect(rollback).toHaveBeenCalledWith(3, 4);
107-
});
108-
109-
it('removes private events from db on a reorg', async () => {
110-
const rollback = jest.spyOn(privateEventStore, 'rollback').mockImplementation(() => Promise.resolve());
111-
const block3Hash = Fr.fromString('0x3');
112-
aztecNode.getBlock.mockImplementation(async (block: any) => {
113-
if (block instanceof BlockHash && block.equals(block3Hash)) {
114-
const b = await L2Block.random(BlockNumber(3));
115-
return {
116-
header: b.header,
117-
archive: b.archive,
118-
hash: await b.hash(),
119-
checkpointNumber: b.checkpointNumber,
120-
indexWithinCheckpoint: b.indexWithinCheckpoint,
121-
number: b.number,
122-
} as any;
123-
}
124-
return undefined;
125-
});
125+
it('updates anchor block on a reorg', async () => {
126+
const reorgBlock = await L2Block.random(BlockNumber(3));
127+
const reorgResponse = await blockResponse(reorgBlock);
128+
getBlock.mockImplementation(param =>
129+
Promise.resolve(param instanceof BlockHash && param.equals(reorgResponse.hash) ? reorgResponse : undefined),
130+
);
126131

127132
await synchronizer.handleBlockStreamEvent({
128133
type: 'blocks-added',
129134
blocks: await timesParallel(5, i => L2Block.random(BlockNumber(i))),
130135
});
131136
await synchronizer.handleBlockStreamEvent({
132137
type: 'chain-pruned',
133-
block: { number: BlockNumber(3), hash: block3Hash.toString() },
134-
checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() },
138+
block: makeL2BlockId(reorgBlock.number, reorgResponse.hash.toString()),
139+
checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()),
135140
});
136141

137-
expect(rollback).toHaveBeenCalledWith(3, 4);
142+
// The anchor block should be updated to the reorg block header.
143+
const obtainedHeader = await anchorBlockStore.getBlockHeader();
144+
expect(obtainedHeader.equals(reorgBlock.header)).toBe(true);
138145
});
139146

140147
describe('stop', () => {
@@ -182,6 +189,139 @@ describe('BlockSynchronizer', () => {
182189
});
183190
});
184191

192+
describe('delete-on-prune', () => {
193+
it('chain-pruned deletes rows anchored above the fork and keeps rows at or below it', async () => {
194+
const contract = await AztecAddress.random();
195+
const scope = await AztecAddress.random();
196+
197+
// Block 3 is the fork point (a real block the node still serves); 4 and 5 are on the abandoned fork.
198+
const forkBlock = await L2Block.random(BlockNumber(3));
199+
const block3 = makeL2BlockId(forkBlock.number, (await forkBlock.hash()).toString());
200+
const block4 = makeL2BlockId(BlockNumber(4), Fr.random().toString());
201+
const block5 = makeL2BlockId(BlockNumber(5), Fr.random().toString());
202+
203+
// Seed a note at each block, anchored to that block's id.
204+
const noteAt3 = await noteAt(contract, block3);
205+
const noteAt4 = await noteAt(contract, block4);
206+
const noteAt5 = await noteAt(contract, block5);
207+
await noteStore.addNotes([noteAt3, noteAt4, noteAt5], scope, 'note-job');
208+
await noteStore.commit('note-job');
209+
210+
// Seed an event at each block.
211+
const eventIdAt3 = Fr.random();
212+
const eventIdAt4 = Fr.random();
213+
const eventIdAt5 = Fr.random();
214+
await storeEvent(contract, scope, eventIdAt3, block3);
215+
await storeEvent(contract, scope, eventIdAt4, block4);
216+
await storeEvent(contract, scope, eventIdAt5, block5);
217+
await privateEventStore.commit('event-job');
218+
219+
// Set the anchor to block 5 so the prune guard passes.
220+
const anchorBlock5 = await L2Block.random(BlockNumber(5));
221+
await anchorBlockStore.setHeader(anchorBlock5.header);
222+
223+
// The node serves the fork-point block; it becomes the new anchor after the prune.
224+
const forkResponse = await blockResponse(forkBlock);
225+
getBlock.mockImplementation(param =>
226+
Promise.resolve(param instanceof BlockHash && param.equals(forkResponse.hash) ? forkResponse : undefined),
227+
);
228+
229+
// Prune back to block 3 (orphaning blocks 4 and 5).
230+
await synchronizer.handleBlockStreamEvent({
231+
type: 'chain-pruned',
232+
block: block3,
233+
checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()),
234+
});
235+
236+
// Rows at blocks 4 and 5 must be gone.
237+
expect(await noteStore.nullifiersOfNotesAtBlock(4)).toHaveLength(0);
238+
expect(await noteStore.nullifiersOfNotesAtBlock(5)).toHaveLength(0);
239+
expect(await privateEventStore.eventIdsAtBlock(4)).toHaveLength(0);
240+
expect(await privateEventStore.eventIdsAtBlock(5)).toHaveLength(0);
241+
242+
// Rows at block 3 (the fork point, not an orphan) must survive.
243+
expect(await noteStore.nullifiersOfNotesAtBlock(3)).toEqual([noteAt3.siloedNullifier.toString()]);
244+
expect(await privateEventStore.eventIdsAtBlock(3)).toEqual([eventIdAt3.toString()]);
245+
});
246+
247+
it('chain-finalized does not delete any rows', async () => {
248+
const contract = await AztecAddress.random();
249+
const scope = await AztecAddress.random();
250+
251+
// Canonical rows at two heights: one below the finalized block, one at it.
252+
const block8 = makeL2BlockId(BlockNumber(8), Fr.random().toString());
253+
const block9 = makeL2BlockId(BlockNumber(9), Fr.random().toString());
254+
const note8 = await noteAt(contract, block8);
255+
const note9 = await noteAt(contract, block9);
256+
await noteStore.addNotes([note8, note9], scope, 'note-job');
257+
await noteStore.commit('note-job');
258+
259+
const eventId8 = Fr.random();
260+
const eventId9 = Fr.random();
261+
await storeEvent(contract, scope, eventId8, block8);
262+
await storeEvent(contract, scope, eventId9, block9);
263+
await privateEventStore.commit('event-job');
264+
265+
await synchronizer.handleBlockStreamEvent({
266+
type: 'chain-finalized',
267+
block: block9,
268+
});
269+
270+
// Finalization is a no-op for storage under delete-on-prune, every row at and below the tip survives.
271+
expect(await noteStore.nullifiersOfNotesAtBlock(8)).toEqual([note8.siloedNullifier.toString()]);
272+
expect(await noteStore.nullifiersOfNotesAtBlock(9)).toEqual([note9.siloedNullifier.toString()]);
273+
expect(await privateEventStore.eventIdsAtBlock(8)).toEqual([eventId8.toString()]);
274+
expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventId9.toString()]);
275+
});
276+
277+
it('notes below the fork survive and remain queryable after a prune', async () => {
278+
const contract = await AztecAddress.random();
279+
const scope = await AztecAddress.random();
280+
281+
// Block 1 is the fork point (a real block the node still serves); 2 and 3 are on the abandoned fork.
282+
const forkBlock = await L2Block.random(BlockNumber(1));
283+
const block1 = makeL2BlockId(forkBlock.number, (await forkBlock.hash()).toString());
284+
const block2 = makeL2BlockId(BlockNumber(2), Fr.random().toString());
285+
const block3 = makeL2BlockId(BlockNumber(3), Fr.random().toString());
286+
287+
const noteAt1 = await noteAt(contract, block1);
288+
const noteAt2 = await noteAt(contract, block2);
289+
const noteAt3 = await noteAt(contract, block3);
290+
await noteStore.addNotes([noteAt1, noteAt2, noteAt3], scope, 'note-job');
291+
await noteStore.commit('note-job');
292+
293+
// Anchor at block 3.
294+
const anchorBlock3 = await L2Block.random(BlockNumber(3));
295+
await anchorBlockStore.setHeader(anchorBlock3.header);
296+
297+
// The node serves the fork-point block; it becomes the new anchor after the prune.
298+
const forkResponse = await blockResponse(forkBlock);
299+
getBlock.mockImplementation(param =>
300+
Promise.resolve(param instanceof BlockHash && param.equals(forkResponse.hash) ? forkResponse : undefined),
301+
);
302+
303+
// Prune back to block 1 (orphaning blocks 2 and 3).
304+
await synchronizer.handleBlockStreamEvent({
305+
type: 'chain-pruned',
306+
block: block1,
307+
checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()),
308+
});
309+
310+
// Blocks 2 and 3 deleted.
311+
expect(await noteStore.nullifiersOfNotesAtBlock(2)).toHaveLength(0);
312+
expect(await noteStore.nullifiersOfNotesAtBlock(3)).toHaveLength(0);
313+
314+
// Block 1 note still present and visible via getNotes.
315+
expect(await noteStore.nullifiersOfNotesAtBlock(1)).toEqual([noteAt1.siloedNullifier.toString()]);
316+
const found = await noteStore.getNotes(
317+
{ contractAddress: contract, scopes: [scope], status: NoteStatus.ACTIVE },
318+
'read-job',
319+
);
320+
expect(found).toHaveLength(1);
321+
expect(found[0].siloedNullifier.equals(noteAt1.siloedNullifier)).toBe(true);
322+
});
323+
});
324+
185325
describe('syncChainTip config', () => {
186326
it('updates anchor on blocks-added when syncChainTip is proposed (default)', async () => {
187327
synchronizer = createSynchronizer({ syncChainTip: 'proposed' });
@@ -263,7 +403,7 @@ describe('BlockSynchronizer', () => {
263403

264404
// Mock node to return block
265405
const provenBlock = await L2Block.random(BlockNumber(5));
266-
aztecNode.getBlock.mockResolvedValue({ header: provenBlock.header } as any);
406+
getBlock.mockResolvedValue(await blockResponse(provenBlock));
267407

268408
await synchronizer.handleBlockStreamEvent({
269409
type: 'chain-proven',
@@ -283,7 +423,7 @@ describe('BlockSynchronizer', () => {
283423

284424
// Mock node to return block
285425
const finalizedBlock = await L2Block.random(BlockNumber(10));
286-
aztecNode.getBlock.mockResolvedValue({ header: finalizedBlock.header } as any);
426+
getBlock.mockResolvedValue(await blockResponse(finalizedBlock));
287427

288428
await synchronizer.handleBlockStreamEvent({
289429
type: 'chain-finalized',
@@ -301,17 +441,13 @@ describe('BlockSynchronizer', () => {
301441
const anchorBlock = await L2Block.random(BlockNumber(2));
302442
await anchorBlockStore.setHeader(anchorBlock.header);
303443

304-
const rollback = jest.spyOn(noteStore, 'rollback').mockImplementation(() => Promise.resolve());
305-
306444
// Prune to block 3 (above anchor) - should be ignored
307445
await synchronizer.handleBlockStreamEvent({
308446
type: 'chain-pruned',
309447
block: { number: BlockNumber(3), hash: '0x3' },
310-
checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() },
448+
checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()),
311449
});
312450

313-
expect(rollback).not.toHaveBeenCalled();
314-
315451
// Anchor should be unchanged
316452
const obtainedHeader = await anchorBlockStore.getBlockHeader();
317453
expect(obtainedHeader.equals(anchorBlock.header)).toBe(true);

yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import type { BlockHeader } from '@aztec/stdlib/tx';
99

1010
import type { BlockSynchronizerConfig } from '../config/index.js';
1111
import type { ContractSyncService } from '../contract_sync/contract_sync_service.js';
12-
import type { AnchorBlockStore } from '../storage/anchor_block_store/anchor_block_store.js';
13-
import type { NoteStore } from '../storage/note_store/note_store.js';
12+
import type { AnchorBlockStore } from '../storage/anchor_block_store/index.js';
13+
import type { NoteStore } from '../storage/note_store/index.js';
1414
import type { PrivateEventStore } from '../storage/private_event_store/private_event_store.js';
1515
import { blockStreamSourceFromAztecNode } from './block_stream_source.js';
1616

@@ -113,7 +113,7 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler {
113113
return;
114114
}
115115

116-
this.log.warn(`Pruning data after block ${event.block.number} due to reorg`);
116+
this.log.warn(`Pruning data after block ${event.block.number} due to reorg`, { pruneBlock: event.block });
117117

118118
// Note that the following is not necessarily the anchor block that will be used in the transaction - if
119119
// the chain has already moved past the reorg, we'll also see blocks-added events that will push the anchor
@@ -129,8 +129,8 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler {
129129

130130
// Operations are wrapped in a single transaction to ensure atomicity.
131131
await this.store.transactionAsync(async () => {
132-
await this.noteStore.rollback(event.block.number, currentAnchorBlockNumber);
133-
await this.privateEventStore.rollback(event.block.number, currentAnchorBlockNumber);
132+
await this.noteStore.rollback(event.block.number);
133+
await this.privateEventStore.rollback(event.block.number);
134134
await this.updateAnchorBlockHeader(newAnchorBlockHeader);
135135
});
136136
break;

0 commit comments

Comments
 (0)