Skip to content

Commit 386f120

Browse files
authored
feat: weblock controlled opfs pool (#24740)
Prevents multi-tab concurrency issues in SQLite store creation by guarding it with weblocks. Closes F-829
1 parent 7b2dbb0 commit 386f120

8 files changed

Lines changed: 197 additions & 35 deletions

File tree

yarn-project/kv-store/src/sqlite-opfs/errors.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ export class SqliteCorruptionError extends Error {
5959
}
6060
}
6161

62+
/** Error thrown when another browser context already owns a store's OPFS pool. */
63+
export class SqlitePoolBusyError extends Error {
64+
constructor(public readonly poolDirectory: string) {
65+
super(`SQLite-OPFS pool "${poolDirectory}" is already in use by another store instance`);
66+
this.name = 'SqlitePoolBusyError';
67+
}
68+
}
69+
70+
/** Error thrown when the browser context does not expose the Web Locks API required by the OPFS SAH pool. */
71+
export class SqliteWebLocksUnavailableError extends Error {
72+
constructor() {
73+
super('SQLite-OPFS requires the Web Locks API, but it is unavailable in this browser context');
74+
this.name = 'SqliteWebLocksUnavailableError';
75+
}
76+
}
77+
6278
/**
6379
* Strings/codes raised by SQLite when a database image is corrupt. Kept disjoint
6480
* from {@link SQLITE3MC_DECRYPT_ERROR_PATTERNS}: "file is not a database"

yarn-project/kv-store/src/sqlite-opfs/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { createLogger } from '@aztec/foundation/log';
33
import { AztecSQLiteOPFSStore } from './store.js';
44

55
export { AztecSQLiteOPFSStore } from './store.js';
6-
export { SqliteCorruptionError, SqliteEncryptionError } from './errors.js';
6+
export {
7+
SqliteCorruptionError,
8+
SqliteEncryptionError,
9+
SqlitePoolBusyError,
10+
SqliteWebLocksUnavailableError,
11+
} from './errors.js';
712
export type { SqliteEncryptionErrorCode } from './errors.js';
8-
export { OPFS_POOL_DIR_PREFIX, deleteStore, listStores, storePoolDirectory } from './manage.js';
13+
export { OPFS_POOL_DIR_PREFIX, deletePoolDirectory, deleteStore, listStores, storePoolDirectory } from './manage.js';
914

1015
export function openTmpStore(ephemeral: boolean = false): Promise<AztecSQLiteOPFSStore> {
1116
return AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), undefined, ephemeral);

yarn-project/kv-store/src/sqlite-opfs/manage.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { normalizePoolDirectory, withPoolLock } from './pool_lock.js';
2+
13
/** Prefix for the per-store OPFS SAH pool directories owned by this package. */
24
export const OPFS_POOL_DIR_PREFIX = '.aztec-kv-';
35

@@ -28,9 +30,17 @@ export async function listStores(): Promise<string[]> {
2830

2931
/**
3032
* Permanently deletes a store by effective name (as returned by {@link listStores}). The store must be closed:
31-
* an open store's SAH pool holds locks on the directory and the removal will reject.
33+
* an open store holds the directory's Web Lock and the removal will reject with `SqlitePoolBusyError`.
3234
*/
3335
export async function deleteStore(effectiveName: string): Promise<void> {
34-
const root = await navigator.storage.getDirectory();
35-
await root.removeEntry(storePoolDirectory(effectiveName), { recursive: true });
36+
await deletePoolDirectory(storePoolDirectory(effectiveName));
37+
}
38+
39+
/** Permanently deletes one OPFS SAH-pool directory after exclusively locking it. */
40+
export async function deletePoolDirectory(poolDirectory: string): Promise<void> {
41+
poolDirectory = normalizePoolDirectory(poolDirectory);
42+
await withPoolLock(poolDirectory, async () => {
43+
const root = await navigator.storage.getDirectory();
44+
await root.removeEntry(poolDirectory, { recursive: true });
45+
});
3646
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { SqlitePoolBusyError, SqliteWebLocksUnavailableError } from './errors.js';
2+
3+
export const DEFAULT_SAH_POOL_DIRECTORY = '.aztec-kv';
4+
5+
const WEB_LOCK_PREFIX = 'aztec.sqlite-opfs.pool:';
6+
7+
export interface PoolLockLease {
8+
release(): Promise<void>;
9+
}
10+
11+
export function normalizePoolDirectory(poolDirectory?: string): string {
12+
const path = poolDirectory
13+
?.split('/')
14+
.filter(segment => segment.length > 0)
15+
.join('/');
16+
return path || DEFAULT_SAH_POOL_DIRECTORY;
17+
}
18+
19+
export async function acquirePoolLock(poolDirectory?: string): Promise<PoolLockLease> {
20+
if (!navigator.locks) {
21+
throw new SqliteWebLocksUnavailableError();
22+
}
23+
const normalizedDirectory = normalizePoolDirectory(poolDirectory);
24+
const acquired = Promise.withResolvers<Lock | null>();
25+
const hold = Promise.withResolvers<void>();
26+
const request = navigator.locks.request(
27+
`${WEB_LOCK_PREFIX}${normalizedDirectory}`,
28+
{ mode: 'exclusive', ifAvailable: true },
29+
async lock => {
30+
acquired.resolve(lock);
31+
if (lock) {
32+
await hold.promise;
33+
}
34+
},
35+
);
36+
request.catch(acquired.reject);
37+
38+
if (!(await acquired.promise)) {
39+
await request;
40+
throw new SqlitePoolBusyError(normalizedDirectory);
41+
}
42+
43+
let released = false;
44+
return {
45+
release: async () => {
46+
if (!released) {
47+
released = true;
48+
hold.resolve();
49+
}
50+
await request;
51+
},
52+
};
53+
}
54+
55+
export async function withPoolLock<T>(poolDirectory: string, callback: () => Promise<T>): Promise<T> {
56+
const lease = await acquirePoolLock(poolDirectory);
57+
try {
58+
return await callback();
59+
} finally {
60+
await lease.release();
61+
}
62+
}

yarn-project/kv-store/src/sqlite-opfs/store.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SqliteCorruptionError, SqliteEncryptionError, isCorruptionMessage } fro
1414
import { SQLiteOPFSAztecMap } from './map.js';
1515
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
1616
import { SQLiteOPFSAztecMultiMap } from './multi_map.js';
17+
import { type PoolLockLease, acquirePoolLock, normalizePoolDirectory } from './pool_lock.js';
1718
import { SQLiteOPFSAztecSet } from './set.js';
1819
import { SQLiteOPFSAztecSingleton } from './singleton.js';
1920

@@ -36,12 +37,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
3637
#nextId = 0;
3738
#inTx = false;
3839
#closed = false;
40+
#workerFailed = false;
3941

4042
private constructor(
4143
worker: Worker,
4244
name: string,
4345
log: Logger,
4446
public readonly isEphemeral: boolean,
47+
private readonly poolLock?: PoolLockLease,
4548
) {
4649
this.#worker = worker;
4750
this.#name = name;
@@ -57,6 +60,7 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
5760
handler.resolve(ev.data);
5861
};
5962
this.#worker.onerror = ev => {
63+
this.#workerFailed = true;
6064
this.#log.error(`SQLite worker crashed: ${ev.message}`);
6165
this.#rejectPending(`SQLite worker crashed: ${ev.message}`);
6266
};
@@ -70,6 +74,10 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
7074
* required when multiple stores coexist in the same tab, because the SAH Pool holds
7175
* an exclusive lock on its directory.
7276
*
77+
* Persistent stores hold an origin-wide Web Lock for the pool directory until close
78+
* or delete. If another store instance already owns it, open fails immediately with
79+
* `SqlitePoolBusyError`.
80+
*
7381
* Pass `encryptionKey` (exactly 32 bytes) to enable at-rest encryption via sqlite3mc's
7482
* ChaCha20 page cipher. The key buffer is **transferred** to the worker — its
7583
* ArrayBuffer detaches on the caller side after `postMessage`. This is intentional:
@@ -102,22 +110,26 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
102110
log.debug(
103111
`Opening SQLite-OPFS ${ephemeral ? 'ephemeral ' : ''}${encryptionKey ? 'encrypted ' : ''}database ${dbName}`,
104112
);
105-
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
106-
const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral);
107-
// Transfer (not clone) the key buffer to the worker so we don't leave a
108-
// second copy on the main thread. Caveat: this detaches the caller's
109-
// encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
110-
const transfer = encryptionKey ? [encryptionKey.buffer as ArrayBuffer] : undefined;
113+
const effectivePoolDirectory = ephemeral ? undefined : normalizePoolDirectory(poolDirectory);
114+
const poolLock = effectivePoolDirectory ? await acquirePoolLock(effectivePoolDirectory) : undefined;
115+
let worker: Worker | undefined;
111116
try {
117+
worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
118+
const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral, poolLock);
119+
// Transfer (not clone) the key buffer to the worker so we don't leave a
120+
// second copy on the main thread. Caveat: this detaches the caller's
121+
// encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
122+
const transfer = encryptionKey ? [encryptionKey.buffer as ArrayBuffer] : undefined;
112123
await store.#sendRequest(
113-
{ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory, encryptionKey },
124+
{ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory: effectivePoolDirectory, encryptionKey },
114125
transfer,
115126
);
127+
return store;
116128
} catch (err) {
117-
worker.terminate();
129+
worker?.terminate();
130+
await poolLock?.release();
118131
throw err;
119132
}
120-
return store;
121133
}
122134

123135
openMap<K extends Key, V extends Value>(name: string): AztecAsyncMap<K, V> {
@@ -179,12 +191,16 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
179191
return;
180192
}
181193
this.#closed = true;
182-
await this.#txQueue.end();
183-
await this.#sendRequest({ type: 'deleteDb', id: this.#allocId(), dbName: this.#name }).catch(err =>
184-
this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`),
185-
);
186-
this.#worker.terminate();
187-
this.#rejectPending('SQLite store deleted');
194+
try {
195+
await this.#txQueue.end();
196+
await this.#sendRequest({ type: 'deleteDb', id: this.#allocId(), dbName: this.#name }).catch(err =>
197+
this.#log.warn(`SQLite deleteDb failed: ${err instanceof Error ? err.message : err}`),
198+
);
199+
} finally {
200+
this.#worker.terminate();
201+
this.#rejectPending('SQLite store deleted');
202+
await this.poolLock?.release();
203+
}
188204
}
189205

190206
/**
@@ -204,10 +220,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
204220
return;
205221
}
206222
this.#closed = true;
207-
await this.#txQueue.end();
208-
await this.#sendRequest({ type: 'close', id: this.#allocId() }).catch(() => {});
209-
this.#worker.terminate();
210-
this.#rejectPending('SQLite store closed');
223+
try {
224+
await this.#txQueue.end();
225+
await this.#sendRequest({ type: 'close', id: this.#allocId() }).catch(() => {});
226+
} finally {
227+
this.#worker.terminate();
228+
this.#rejectPending('SQLite store closed');
229+
await this.poolLock?.release();
230+
}
211231
}
212232

213233
backupTo(_dstPath: string, _compact?: boolean): Promise<void> {
@@ -268,6 +288,9 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
268288
}
269289

270290
#sendRequest(req: WorkerRequest, transfer?: Transferable[]): Promise<WorkerResponse> {
291+
if (this.#workerFailed) {
292+
return Promise.reject(new Error('SQLite worker has crashed'));
293+
}
271294
return new Promise<WorkerResponse>((resolve, reject) => {
272295
this.#pending.set(req.id, {
273296
resolve: resp => {

yarn-project/kv-store/src/sqlite-opfs/store_management.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mockLogger } from '../interfaces/utils.js';
2-
import { AztecSQLiteOPFSStore } from './index.js';
2+
import { AztecSQLiteOPFSStore, SqlitePoolBusyError, SqliteWebLocksUnavailableError } from './index.js';
33
import { deleteStore, listStores, storePoolDirectory } from './manage.js';
4+
import { acquirePoolLock } from './pool_lock.js';
45

56
const openByName = (name: string) => AztecSQLiteOPFSStore.open(mockLogger, name, false, storePoolDirectory(name));
67

@@ -31,6 +32,36 @@ describe('sqlite-opfs store management', () => {
3132
await deleteStore('mech_concurrent_b');
3233
});
3334

35+
it('allows only one concurrent opener for a fresh store', async () => {
36+
const name = 'mech_same_store';
37+
const results = await Promise.allSettled([openByName(name), openByName(name)]);
38+
const opened = results.filter(result => result.status === 'fulfilled').map(result => result.value);
39+
const rejected = results.filter(result => result.status === 'rejected').map(result => result.reason);
40+
41+
expect(opened).toHaveLength(1);
42+
expect(rejected).toHaveLength(1);
43+
expect(rejected[0]).toBeInstanceOf(SqlitePoolBusyError);
44+
45+
await opened[0].close();
46+
const reopened = await openByName(name);
47+
await reopened.close();
48+
await deleteStore(name);
49+
});
50+
51+
it('fails clearly when Web Locks are unavailable', async () => {
52+
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'locks');
53+
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined });
54+
try {
55+
await expect(acquirePoolLock('mech_no_web_locks')).rejects.toThrow(SqliteWebLocksUnavailableError);
56+
} finally {
57+
if (descriptor) {
58+
Object.defineProperty(navigator, 'locks', descriptor);
59+
} else {
60+
delete (navigator as unknown as { locks?: LockManager }).locks;
61+
}
62+
}
63+
});
64+
3465
it('lists created stores and deletes them', async () => {
3566
const store = await openByName('mech_managed');
3667
await store.openSingleton<string>('k').set('v');
@@ -61,7 +92,7 @@ describe('sqlite-opfs store management', () => {
6192

6293
it('refuses to delete a store that is currently open', async () => {
6394
const store = await openByName('mech_locked');
64-
await expect(deleteStore('mech_locked')).rejects.toThrow();
95+
await expect(deleteStore('mech_locked')).rejects.toThrow(SqlitePoolBusyError);
6596
await store.close();
6697
await deleteStore('mech_locked');
6798
});

yarn-project/kv-store/src/sqlite-opfs/worker.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import sqlite3InitModule, { type Database, type SAHPoolUtil, type Sqlite3Static
33

44
import { SqliteEncryptionError, type SqliteEncryptionErrorCode, isDecryptFailureMessage } from './errors.js';
55
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
6+
import { DEFAULT_SAH_POOL_DIRECTORY } from './pool_lock.js';
67

78
const SCHEMA_SQL = `
89
CREATE TABLE IF NOT EXISTS data (
@@ -19,7 +20,6 @@ const SCHEMA_SQL = `
1920
CREATE UNIQUE INDEX IF NOT EXISTS idx_container_key_hash ON data(container, key, hash);
2021
`;
2122

22-
const DEFAULT_SAH_POOL_DIRECTORY = '.aztec-kv';
2323
const SAH_POOL_VFS_NAME = 'aztec-kv-opfs';
2424
const MC_SAH_POOL_VFS_NAME = `multipleciphers-${SAH_POOL_VFS_NAME}`;
2525

@@ -91,10 +91,13 @@ async function handleInit(
9191
}
9292

9393
function handleClose(): void {
94-
db?.close();
95-
db = undefined;
96-
dbPath = undefined;
97-
releasePool();
94+
try {
95+
db?.close();
96+
} finally {
97+
db = undefined;
98+
dbPath = undefined;
99+
releasePool();
100+
}
98101
}
99102

100103
/**
@@ -215,7 +218,15 @@ function respond(msg: WorkerResponse): void {
215218
}
216219
} catch (err) {
217220
const message = err instanceof Error ? err.message : String(err);
218-
respond({ type: 'err', id: req.id, message, encryptionCode: detectEncryptionCode(req, err, message) });
221+
const encryptionCode = detectEncryptionCode(req, err, message);
222+
if (req.type === 'init') {
223+
try {
224+
handleClose();
225+
} catch {
226+
// The main thread terminates this worker after a failed init, which releases any remaining OPFS handles.
227+
}
228+
}
229+
respond({ type: 'err', id: req.id, message, encryptionCode });
219230
}
220231
};
221232

yarn-project/wallets/src/embedded/store_encryption.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
* surfaces, so callers don't leak the SAH Pool's OPFS lock.
99
*/
1010
import type { Logger } from '@aztec/foundation/log';
11-
import { AztecSQLiteOPFSStore, SqliteCorruptionError, SqliteEncryptionError } from '@aztec/kv-store/sqlite-opfs';
11+
import {
12+
AztecSQLiteOPFSStore,
13+
SqliteCorruptionError,
14+
SqliteEncryptionError,
15+
deletePoolDirectory,
16+
} from '@aztec/kv-store/sqlite-opfs';
1217

1318
/** Which of the embedded wallet's two stores failed to open. */
1419
export type EmbeddedStoreName = 'pxe' | 'wallet';
@@ -66,8 +71,7 @@ const defaultWipeStore: WipeSqliteStoreFn = async poolDirectory => {
6671
if (!poolDirectory) {
6772
return;
6873
}
69-
const root = await navigator.storage.getDirectory();
70-
await root.removeEntry(poolDirectory, { recursive: true }).catch(() => {
74+
await deletePoolDirectory(poolDirectory).catch(() => {
7175
// Already gone / never created — nothing to wipe.
7276
});
7377
};

0 commit comments

Comments
 (0)