Skip to content

Commit d8bb52d

Browse files
authored
refactor!: clear IndexedDB store on data schema version change (#20007)
Applies a similar approach to database version management on IndexedDB as the one we have in LMDB: if either database schema version or the rollup contract address change, the DB will be cleared on startup. This is probably a too aggressive solution and we should think of a gentler data migration scheme so we can evolve the way we store data in PXE without forcing a start from scratch scenario, but not having DB version management detection at all is worse because it results in tricky to diagnose bugs like the one in the screenshot. Also, bumping PXE_DATABASE_SCHEMA so we have a clear baseline for database schema control moving forward. We've been changing the shape of the stores without paying attention to this, which we should do moving forward. Choosing to mark this as `refactor!` (with `!`) since this will cause every PXE DB to be cleared on first use of this version. If that's a problem please let me know. @spalladino since this touches `kv-store` I'm asking for a review from you as well. <img width="417" height="948" alt="Screenshot 2026-01-26 at 10 12 37 AM" src="https://github.com/user-attachments/assets/6727516b-f496-4a99-83e9-81b03fd569d4" />
2 parents f7b24c5 + 13cc3c3 commit d8bb52d

File tree

18 files changed

+345
-120
lines changed

18 files changed

+345
-120
lines changed

boxes/boxes/vanilla/scripts/deploy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { type AztecNode } from '@aztec/aztec.js/node';
1414
import { SPONSORED_FPC_SALT } from '@aztec/constants';
1515
import { createStore } from '@aztec/kv-store/lmdb';
1616
import { SponsoredFPCContractArtifact } from '@aztec/noir-contracts.js/SponsoredFPC';
17-
import { getPXEConfig } from '@aztec/pxe/server';
17+
import { getPXEConfig, PXE_DATA_SCHEMA_VERSION } from '@aztec/pxe/server';
1818
import { getDefaultInitializer } from '@aztec/stdlib/abi';
1919
import { TestWallet } from '@aztec/test-wallet/server';
2020
import fs from 'fs';
@@ -34,7 +34,7 @@ async function setupWallet(aztecNode: AztecNode) {
3434
const store = await createStore('pxe', {
3535
dataDirectory: PXE_STORE_DIR,
3636
dataStoreMapSizeKb: 1e6,
37-
});
37+
}, PXE_DATA_SCHEMA_VERSION);
3838

3939
const config = getPXEConfig();
4040
config.dataDirectory = 'pxe';

playground/src/wallet/embedded_wallet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export class EmbeddedWallet extends BaseWallet {
8585
const walletDBStore = await createStore(
8686
`wallet-${rollupAddress}`,
8787
{ dataDirectory: 'wallet', dataStoreMapSizeKb: 2e10 },
88+
undefined,
8889
walletLogger,
8990
);
9091
const db = WalletDB.init(walletDBStore, walletLogger.info);

yarn-project/kv-store/src/indexeddb/index.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { type Logger, createLogger } from '@aztec/foundation/log';
22

33
import type { DataStoreConfig } from '../config.js';
4-
import { initStoreForRollup } from '../utils.js';
4+
import { initStoreForRollupAndSchemaVersion } from '../utils.js';
55
import { AztecIndexedDBStore } from './store.js';
66

77
export { AztecIndexedDBStore } from './store.js';
88

9-
export async function createStore(name: string, config: DataStoreConfig, log: Logger = createLogger('kv-store')) {
9+
export async function createStore(
10+
name: string,
11+
config: DataStoreConfig,
12+
schemaVersion: number | undefined = undefined,
13+
log: Logger = createLogger('kv-store'),
14+
) {
1015
let { dataDirectory } = config;
1116
if (typeof dataDirectory !== 'undefined') {
1217
dataDirectory = `${dataDirectory}/${name}`;
@@ -18,10 +23,7 @@ export async function createStore(name: string, config: DataStoreConfig, log: Lo
1823
: `Creating ${name} ephemeral data store with map size ${config.dataStoreMapSizeKb} KB`,
1924
);
2025
const store = await AztecIndexedDBStore.open(createLogger('kv-store:indexeddb'), dataDirectory ?? '', false);
21-
if (config.l1Contracts?.rollupAddress) {
22-
return initStoreForRollup(store, config.l1Contracts.rollupAddress, log);
23-
}
24-
return store;
26+
return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.l1Contracts?.rollupAddress, log);
2527
}
2628

2729
export function openTmpStore(ephemeral: boolean = false): Promise<AztecIndexedDBStore> {
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { EthAddress } from '@aztec/foundation/eth-address';
2+
import { DatabaseVersion } from '@aztec/stdlib/database-version/version';
3+
4+
import { expect } from 'chai';
5+
6+
import { mockLogger } from '../interfaces/utils.js';
7+
import { initStoreForRollupAndSchemaVersion } from '../utils.js';
8+
import { AztecIndexedDBStore } from './store.js';
9+
10+
describe('IndexedDB Version Management', () => {
11+
let store: AztecIndexedDBStore;
12+
const schemaVersion = 42;
13+
let rollupAddress: EthAddress;
14+
15+
beforeEach(async () => {
16+
rollupAddress = EthAddress.random();
17+
// Create a fresh ephemeral store for each test
18+
store = await AztecIndexedDBStore.open(mockLogger, undefined, true);
19+
});
20+
21+
afterEach(async () => {
22+
await store.delete();
23+
});
24+
25+
describe('initStoreForRollup', () => {
26+
describe('fresh store (no existing data)', () => {
27+
it('stores version on first run', async () => {
28+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
29+
30+
const versionSingleton = store.openSingleton<string>('dbVersion');
31+
const stored = await versionSingleton.getAsync();
32+
33+
const storedVersion = DatabaseVersion.fromBuffer(Buffer.from(stored!, 'utf-8'));
34+
expect(storedVersion.schemaVersion).to.equal(schemaVersion);
35+
expect(storedVersion.rollupAddress.toString()).to.equal(rollupAddress.toString());
36+
});
37+
});
38+
39+
describe('store with matching version', () => {
40+
it('does not clear store when version matches', async () => {
41+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
42+
43+
const testMap = store.openMap<string, string>('test');
44+
await testMap.set('key', 'value');
45+
46+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
47+
48+
// Data should still exist
49+
expect(await testMap.getAsync('key')).to.equal('value');
50+
});
51+
});
52+
53+
describe('store with different rollup address', () => {
54+
it('clears store when rollup address changes', async () => {
55+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
56+
57+
const testMap = store.openMap<string, string>('test');
58+
await testMap.set('key', 'value');
59+
60+
const newRollupAddress = EthAddress.random();
61+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, newRollupAddress, mockLogger);
62+
63+
expect(await testMap.getAsync('key')).to.be.undefined;
64+
});
65+
});
66+
67+
describe('store with different schema version', () => {
68+
it('clears store when schema version increases', async () => {
69+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
70+
71+
const testMap = store.openMap<string, string>('test');
72+
await testMap.set('key', 'value');
73+
74+
await initStoreForRollupAndSchemaVersion(store, schemaVersion + 1, rollupAddress, mockLogger);
75+
76+
expect(await testMap.getAsync('key')).to.be.undefined;
77+
});
78+
79+
it('clears store when schema version decreases', async () => {
80+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
81+
82+
const testMap = store.openMap<string, string>('test');
83+
await testMap.set('key', 'value');
84+
85+
await initStoreForRollupAndSchemaVersion(store, schemaVersion - 1, rollupAddress, mockLogger);
86+
87+
expect(await testMap.getAsync('key')).to.be.undefined;
88+
});
89+
});
90+
91+
describe('store with malformed version data', () => {
92+
it('clears store when version is malformed JSON', async () => {
93+
const versionSingleton = store.openSingleton<string>('dbVersion');
94+
await versionSingleton.set('not-valid-json');
95+
96+
const testMap = store.openMap<string, string>('test');
97+
await testMap.set('key', 'value');
98+
99+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
100+
101+
expect(await testMap.getAsync('key')).to.be.undefined;
102+
});
103+
104+
it('clears store when version has wrong structure', async () => {
105+
const versionSingleton = store.openSingleton<string>('dbVersion');
106+
await versionSingleton.set(JSON.stringify({ foo: 'bar' }));
107+
108+
const testMap = store.openMap<string, string>('test');
109+
await testMap.set('key', 'value');
110+
111+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
112+
113+
expect(await testMap.getAsync('key')).to.be.undefined;
114+
});
115+
});
116+
117+
describe('migration from old store format (pre-version management)', () => {
118+
it('clears store that has old rollupAddress format but no dbVersion', async () => {
119+
// Simulate old store format: has rollupAddress singleton but no dbVersion
120+
const oldRollupSingleton = store.openSingleton<string>('rollupAddress');
121+
await oldRollupSingleton.set(rollupAddress.toString());
122+
123+
const testMap = store.openMap<string, string>('test');
124+
await testMap.set('key', 'value');
125+
126+
// Init with new version management should clear the old data
127+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
128+
129+
expect(await testMap.getAsync('key')).to.be.undefined;
130+
131+
const versionSingleton = store.openSingleton<string>('dbVersion');
132+
const stored = await versionSingleton.getAsync();
133+
const storedVersion = DatabaseVersion.fromBuffer(Buffer.from(stored!, 'utf-8'));
134+
expect(storedVersion.schemaVersion).to.equal(schemaVersion);
135+
expect(storedVersion.rollupAddress.toString()).to.equal(rollupAddress.toString());
136+
});
137+
138+
it('clears store with old format even if rollup address matches', async () => {
139+
// Old format with same rollup address
140+
const oldRollupSingleton = store.openSingleton<string>('rollupAddress');
141+
await oldRollupSingleton.set(rollupAddress.toString());
142+
143+
const testMap = store.openMap<string, string>('test');
144+
await testMap.set('key', 'value');
145+
146+
await initStoreForRollupAndSchemaVersion(store, schemaVersion, rollupAddress, mockLogger);
147+
148+
expect(await testMap.getAsync('key')).to.be.undefined;
149+
});
150+
});
151+
});
152+
});

yarn-project/kv-store/src/lmdb-v2/factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EthAddress } from '@aztec/foundation/eth-address';
22
import { type LoggerBindings, createLogger } from '@aztec/foundation/log';
3-
import { DatabaseVersionManager } from '@aztec/stdlib/database-version';
3+
import { DatabaseVersionManager } from '@aztec/stdlib/database-version/manager';
44

55
import { mkdir, mkdtemp, rm } from 'fs/promises';
66
import { tmpdir } from 'os';

yarn-project/kv-store/src/lmdb/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import { type Logger, createLogger } from '@aztec/foundation/log';
33
import { join } from 'path';
44

55
import type { DataStoreConfig } from '../config.js';
6-
import { initStoreForRollup } from '../utils.js';
6+
import { initStoreForRollupAndSchemaVersion } from '../utils.js';
77
import { AztecLmdbStore } from './store.js';
88

99
export { AztecLmdbStore } from './store.js';
1010

11-
export function createStore(name: string, config: DataStoreConfig, log: Logger = createLogger('kv-store')) {
11+
export function createStore(
12+
name: string,
13+
config: DataStoreConfig,
14+
schemaVersion: number | undefined = undefined,
15+
log: Logger = createLogger('kv-store'),
16+
) {
1217
let { dataDirectory } = config;
1318
if (typeof dataDirectory !== 'undefined') {
1419
dataDirectory = join(dataDirectory, name);
@@ -22,7 +27,7 @@ export function createStore(name: string, config: DataStoreConfig, log: Logger =
2227

2328
const store = AztecLmdbStore.open(dataDirectory, config.dataStoreMapSizeKb, false);
2429
if (config.l1Contracts?.rollupAddress) {
25-
return initStoreForRollup(store, config.l1Contracts.rollupAddress, log);
30+
return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.l1Contracts.rollupAddress, log);
2631
}
2732
return store;
2833
}

yarn-project/kv-store/src/utils.ts

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,88 @@
1-
import type { EthAddress } from '@aztec/foundation/eth-address';
1+
import { EthAddress } from '@aztec/foundation/eth-address';
22
import type { Logger } from '@aztec/foundation/log';
3+
import { DatabaseVersion } from '@aztec/stdlib/database-version/version';
34

45
import type { AztecAsyncSingleton, AztecSingleton } from './interfaces/singleton.js';
56
import type { AztecAsyncKVStore, AztecKVStore } from './interfaces/store.js';
67
import { isSyncStore } from './interfaces/utils.js';
78

89
/**
9-
* Clears the store if the rollup address does not match the one stored in the database.
10-
* This is to prevent data from being accidentally shared between different rollup instances.
10+
* Clears the store if the schema version or rollup address does not match the one stored in the database.
11+
* Also clears if migrating from an older store format that didn't track schema version.
12+
* This is to prevent data from being accidentally mixed up between different rollup instances or schema versions.
1113
* @param store - The store to check
14+
* @param targetSchemaVersion - The current schema version
1215
* @param rollupAddress - The ETH address of the rollup contract
13-
* @returns A promise that resolves when the store is cleared, or rejects if the rollup address does not match
16+
* @param log - Optional logger
17+
* @returns The store (cleared if necessary)
1418
*/
15-
export async function initStoreForRollup<T extends AztecKVStore | AztecAsyncKVStore>(
19+
export async function initStoreForRollupAndSchemaVersion<T extends AztecKVStore | AztecAsyncKVStore>(
1620
store: T,
17-
rollupAddress: EthAddress,
21+
schemaVersion: number | undefined,
22+
rollupAddress: EthAddress | undefined,
1823
log?: Logger,
1924
): Promise<T> {
20-
if (!rollupAddress) {
21-
throw new Error('Rollup address is required');
22-
}
23-
const rollupAddressValue = store.openSingleton<ReturnType<EthAddress['toString']>>('rollupAddress');
24-
const rollupAddressString = rollupAddress.toString();
25-
const storedRollupAddressString = isSyncStore(store)
26-
? (rollupAddressValue as AztecSingleton<ReturnType<EthAddress['toString']>>).get()
27-
: await (rollupAddressValue as AztecAsyncSingleton<ReturnType<EthAddress['toString']>>).getAsync();
28-
29-
if (typeof storedRollupAddressString !== 'undefined' && storedRollupAddressString !== rollupAddressString) {
30-
log?.warn(`Rollup address mismatch. Clearing entire database...`, {
31-
expected: rollupAddressString,
32-
found: storedRollupAddressString,
33-
});
25+
const targetSchemaVersion = schemaVersion ?? 0;
26+
const targetRollupAddress = rollupAddress ?? EthAddress.ZERO;
27+
const targetDatabaseVersion = new DatabaseVersion(targetSchemaVersion, targetRollupAddress);
28+
29+
// DB version: database schema version + rollup address combined)
30+
const dbVersion = store.openSingleton<string>('dbVersion');
31+
32+
const storedDatabaseVersion = isSyncStore(store)
33+
? (dbVersion as AztecSingleton<string>).get()
34+
: await (dbVersion as AztecAsyncSingleton<string>).getAsync();
3435

36+
if (
37+
doesStoreNeedToBeCleared(
38+
targetDatabaseVersion,
39+
storedDatabaseVersion,
40+
targetSchemaVersion,
41+
targetRollupAddress,
42+
log,
43+
)
44+
) {
3545
await store.clear();
3646
}
3747

38-
await rollupAddressValue.set(rollupAddressString);
48+
await dbVersion.set(targetDatabaseVersion.toBuffer().toString('utf-8'));
49+
3950
return store;
4051
}
52+
53+
function doesStoreNeedToBeCleared(
54+
targetDatabaseVersion: DatabaseVersion,
55+
storedDatabaseVersion: string | undefined,
56+
targetSchemaVersion: number,
57+
targetRollupAddress: EthAddress,
58+
log?: Logger,
59+
) {
60+
if (storedDatabaseVersion) {
61+
try {
62+
const storedVersion = DatabaseVersion.fromBuffer(Buffer.from(storedDatabaseVersion, 'utf-8'));
63+
const cmp = storedVersion.cmp(targetDatabaseVersion);
64+
65+
if (cmp === undefined) {
66+
log?.warn('Rollup address changed, clearing database', {
67+
stored: storedVersion.rollupAddress.toString(),
68+
current: targetRollupAddress.toString(),
69+
});
70+
return true;
71+
}
72+
73+
if (cmp !== 0) {
74+
log?.warn('Schema version changed, clearing database', {
75+
stored: storedVersion.schemaVersion,
76+
current: targetSchemaVersion,
77+
});
78+
79+
return true;
80+
}
81+
} catch (err) {
82+
log?.warn('Failed to parse stored version, clearing database', { err });
83+
return true;
84+
}
85+
}
86+
87+
return false;
88+
}

yarn-project/node-lib/src/actions/snapshot-sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Logger } from '@aztec/foundation/log';
88
import type { DataStoreConfig } from '@aztec/kv-store/config';
99
import { P2P_STORE_NAME } from '@aztec/p2p';
1010
import type { ChainConfig } from '@aztec/stdlib/config';
11-
import { DatabaseVersionManager } from '@aztec/stdlib/database-version';
11+
import { DatabaseVersionManager } from '@aztec/stdlib/database-version/manager';
1212
import { type ReadOnlyFileStore, createReadOnlyFileStore } from '@aztec/stdlib/file-store';
1313
import {
1414
type SnapshotMetadata,

yarn-project/pxe/src/entrypoints/client/bundle/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client';
88

99
import type { PXEConfig } from '../../../config/index.js';
1010
import { PXE } from '../../../pxe.js';
11+
import { PXE_DATA_SCHEMA_VERSION } from '../../../storage/metadata.js';
1112
import type { PXECreationOptions } from '../../pxe_creation_options.js';
1213

1314
/**
@@ -36,7 +37,8 @@ export async function createPXE(
3637

3738
const storeLogger = loggers.store ?? createLogger('pxe:data:idb', { actor });
3839

39-
const store = options.store ?? (await createStore('pxe_data', configWithContracts, storeLogger));
40+
const store =
41+
options.store ?? (await createStore('pxe_data', configWithContracts, PXE_DATA_SCHEMA_VERSION, storeLogger));
4042

4143
const simulator = options.simulator ?? new WASMSimulator();
4244
const proverLogger = loggers.prover ?? createLogger('pxe:bb:wasm:bundle', { actor });

0 commit comments

Comments
 (0)