Skip to content

Commit 4735e42

Browse files
committed
fix(archiver): move L2 tips cache refresh out of write transactions
The ArchiverDataStoreUpdater used to call `l2TipsCache.refresh()` inside the `db.transactionAsync()` callback for every writer path. Two issues: 1. Mid-tx visibility. `refresh()` reassigns its internal #tipsPromise synchronously, which was observable to other callers before LMDB had actually committed. A concurrent reader calling `getL2Tips()` after the reassignment but before commit picks up a promise loaded against the in-flight tx state, while a sibling read on `#proposedCheckpoints` directly outside the tx still sees pre-commit state — split-snapshot reads in the sequencer's `checkSync()`. 2. No rollback on tx abort. If the LMDB transaction threw or aborted, the cache had already been replaced with a promise loaded against in-flight writes that would never commit. Future readers would see a cache reflecting rolled-back state. Refresh now runs after the writer transaction has fully committed, so it loads from the committed store and is never replaced when the writer aborts. This does not close the JS-side race window completely — there is still a small "tips lag store" window between LMDB commit returning and `refresh()` finishing its `loadFromStore`. The sequencer's `checkSync()` consistency checks (sequencer-client/src/sequencer/sequencer.ts ~L700) already handle that residual window by detecting the mismatch and returning undefined; those checks are intentionally left in place.
1 parent 354bf11 commit 4735e42

3 files changed

Lines changed: 40 additions & 11 deletions

File tree

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address';
77
import { L2Block } from '@aztec/stdlib/block';
88
import { ContractClassLog, PrivateLog } from '@aztec/stdlib/logs';
99
import '@aztec/stdlib/testing/jest';
10+
import { BlockHeader } from '@aztec/stdlib/tx';
1011

12+
import { jest } from '@jest/globals';
1113
import { readFileSync } from 'fs';
1214
import { dirname, resolve } from 'path';
1315
import { fileURLToPath } from 'url';
1416

1517
import { type ArchiverDataStores, createArchiverDataStores } from '../store/data_stores.js';
18+
import { L2TipsCache } from '../store/l2_tips_cache.js';
1619
import { makeCheckpoint, makePublishedCheckpoint } from '../test/mock_structs.js';
1720
import { ArchiverDataStoreUpdater } from './data_store_updater.js';
1821

@@ -215,4 +218,29 @@ describe('ArchiverDataStoreUpdater', () => {
215218
expect(publicLogsAfter.logs.length).toBe(0);
216219
});
217220
});
221+
222+
describe('l2 tips cache refresh', () => {
223+
it('does not refresh the cache when the writer transaction aborts', async () => {
224+
const initialBlockHash = await BlockHeader.empty().hash();
225+
const tipsCache = new L2TipsCache(store.blocks, initialBlockHash);
226+
const updaterWithCache = new ArchiverDataStoreUpdater(store, tipsCache);
227+
228+
const tipsBefore = await tipsCache.getL2Tips();
229+
230+
const block = await L2Block.random(BlockNumber(1), {
231+
checkpointNumber: CheckpointNumber(1),
232+
indexWithinCheckpoint: IndexWithinCheckpoint(0),
233+
});
234+
235+
const failure = new Error('forced failure inside writer transaction');
236+
const addProposedBlockSpy = jest.spyOn(store.blocks, 'addProposedBlock').mockRejectedValueOnce(failure);
237+
238+
await expect(updaterWithCache.addProposedBlock(block)).rejects.toBe(failure);
239+
240+
const tipsAfter = await tipsCache.getL2Tips();
241+
expect(tipsAfter).toEqual(tipsBefore);
242+
243+
addProposedBlockSpy.mockRestore();
244+
});
245+
});
218246
});

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ export class ArchiverDataStoreUpdater {
7474
this.addContractDataToDb(block),
7575
]);
7676

77-
await this.l2TipsCache?.refresh();
7877
return opResults.every(Boolean);
7978
});
79+
await this.l2TipsCache?.refresh();
8080
return result;
8181
}
8282

@@ -144,18 +144,17 @@ export class ArchiverDataStoreUpdater {
144144
: undefined,
145145
]);
146146

147-
await this.l2TipsCache?.refresh();
148147
return { prunedBlocks, lastAlreadyInsertedBlockNumber };
149148
});
149+
await this.l2TipsCache?.refresh();
150150
return result;
151151
}
152152

153153
public async addProposedCheckpoint(proposedCheckpoint: ProposedCheckpointInput) {
154154
const result = await this.stores.db.transactionAsync(async () => {
155155
await this.stores.blocks.addProposedCheckpoint(proposedCheckpoint);
156-
await this.l2TipsCache?.refresh();
157156
});
158-
157+
await this.l2TipsCache?.refresh();
159158
return result;
160159
}
161160

@@ -256,9 +255,9 @@ export class ArchiverDataStoreUpdater {
256255
// Clear all pending proposed checkpoints since their blocks have been pruned
257256
await this.stores.blocks.deleteProposedCheckpoints();
258257

259-
await this.l2TipsCache?.refresh();
260258
return result;
261259
});
260+
await this.l2TipsCache?.refresh();
262261
return result;
263262
}
264263

@@ -289,7 +288,7 @@ export class ArchiverDataStoreUpdater {
289288
* @returns True if the operation is successful.
290289
*/
291290
public async removeCheckpointsAfter(checkpointNumber: CheckpointNumber): Promise<boolean> {
292-
return await this.stores.db.transactionAsync(async () => {
291+
const result = await this.stores.db.transactionAsync(async () => {
293292
const { blocksRemoved = [] } = await this.stores.blocks.removeCheckpointsAfter(checkpointNumber);
294293

295294
const opResults = await Promise.all([
@@ -300,9 +299,10 @@ export class ArchiverDataStoreUpdater {
300299
this.stores.logs.deleteLogs(blocksRemoved),
301300
]);
302301

303-
await this.l2TipsCache?.refresh();
304302
return opResults.every(Boolean);
305303
});
304+
await this.l2TipsCache?.refresh();
305+
return result;
306306
}
307307

308308
/**
@@ -312,8 +312,8 @@ export class ArchiverDataStoreUpdater {
312312
public async setProvenCheckpointNumber(checkpointNumber: CheckpointNumber): Promise<void> {
313313
await this.stores.db.transactionAsync(async () => {
314314
await this.stores.blocks.setProvenCheckpointNumber(checkpointNumber);
315-
await this.l2TipsCache?.refresh();
316315
});
316+
await this.l2TipsCache?.refresh();
317317
}
318318

319319
/**
@@ -323,8 +323,8 @@ export class ArchiverDataStoreUpdater {
323323
public async setFinalizedCheckpointNumber(checkpointNumber: CheckpointNumber): Promise<void> {
324324
await this.stores.db.transactionAsync(async () => {
325325
await this.stores.blocks.setFinalizedCheckpointNumber(checkpointNumber);
326-
await this.l2TipsCache?.refresh();
327326
});
327+
await this.l2TipsCache?.refresh();
328328
}
329329

330330
/** Extracts and stores contract data from a single block. */

yarn-project/archiver/src/store/l2_tips_cache.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import type { BlockStore } from './block_store.js';
1313
/**
1414
* In-memory cache for L2 chain tips (proposed, checkpointed, proven, finalized).
1515
* Populated from the BlockStore on first access, then kept up-to-date by the ArchiverDataStoreUpdater.
16-
* Refresh calls should happen within the store transaction that mutates block data to ensure consistency.
16+
* Refresh calls should happen *after* the store transaction that mutates block data has committed,
17+
* so the cache loads from committed state and is never replaced if the writer aborts.
1718
*/
1819
export class L2TipsCache {
1920
#tipsPromise: Promise<L2Tips> | undefined;
@@ -34,7 +35,7 @@ export class L2TipsCache {
3435
return (this.#tipsPromise ??= this.loadFromStore());
3536
}
3637

37-
/** Reloads the L2 tips from the block store. Should be called within the store transaction that mutates data. */
38+
/** Reloads the L2 tips from the block store. Should be called after the writer transaction has committed. */
3839
public async refresh(): Promise<void> {
3940
this.#tipsPromise = this.loadFromStore();
4041
await this.#tipsPromise;

0 commit comments

Comments
 (0)