Skip to content

Commit 5fef44c

Browse files
committed
Cache statements in @powersync/web
1 parent 9f62b33 commit 5fef44c

6 files changed

Lines changed: 175 additions & 7 deletions

File tree

packages/web/src/db/adapters/options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export interface WebSpecificOpenOptions {
8888
* A worker will be initialized if none is provided
8989
*/
9090
workerPort?: MessagePort | undefined;
91+
92+
/**
93+
* If set to a value greater than zero, the worker will cache prepared statements to avoid preparing them every time
94+
* a query runs.
95+
*/
96+
preparedStatementsCache?: number;
9197
}
9298

9399
export interface ResolvedWebSQLOpenOptions extends SQLOpenOptions, WebSpecificOpenOptions {}

packages/web/src/db/adapters/wa-sqlite/RawSqliteConnection.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Factory as WaSqliteFactory, SQLITE_ROW } from '@journeyapps/wa-sqlite';
33
import { loadModuleAndVfs, WASQLiteVFS } from './vfs.js';
44
import { TemporaryStorageOption } from '../options.js';
55
import { RawQueryResult, SqliteValue } from '@powersync/common';
6+
import { PreparedStatementCache } from './StatementCache.js';
67

78
export interface RawWebResult extends Required<RawQueryResult> {
89
autocommit: boolean;
@@ -23,6 +24,10 @@ export interface RawWaSqliteDatabaseOptions {
2324
encryptionKey: string | undefined;
2425
temporaryStorage: TemporaryStorageOption;
2526
cacheSizeKb: number;
27+
/**
28+
* The amount of prepared statements to cache, or 0 to disable caching.
29+
*/
30+
preparedStatementsCache: number;
2631
}
2732

2833
/**
@@ -33,12 +38,18 @@ export interface RawWaSqliteDatabaseOptions {
3338
*/
3439
export class RawSqliteConnection {
3540
private _sqliteAPI: SQLiteAPI | null = null;
41+
private sqlite3_stmt_isexplain!: (stmt: number) => 0 | 1 | 2;
42+
3643
/**
3744
* The `sqlite3*` connection pointer.
3845
*/
3946
private db: number = 0;
47+
private statementCache: PreparedStatementCache | null;
4048

41-
constructor(readonly options: RawWaSqliteDatabaseOptions) {}
49+
constructor(readonly options: RawWaSqliteDatabaseOptions) {
50+
this.statementCache =
51+
options.preparedStatementsCache > 0 ? new PreparedStatementCache(options.preparedStatementsCache) : null;
52+
}
4253

4354
get isOpen(): boolean {
4455
return this.db != 0;
@@ -62,6 +73,7 @@ export class RawSqliteConnection {
6273

6374
private async openSQLiteAPI(): Promise<SQLiteAPI> {
6475
const { module, vfs } = await loadModuleAndVfs(this.options);
76+
this.sqlite3_stmt_isexplain = module.cwrap('sqlite3_stmt_isexplain', 'int', ['int']);
6577
const sqlite3 = WaSqliteFactory(module);
6678
sqlite3.vfs_register(vfs, true);
6779
/**
@@ -141,7 +153,7 @@ export class RawSqliteConnection {
141153
async executeRaw(sql: string, bindings?: any[]): Promise<RawResultSet[]> {
142154
const results = [];
143155
const api = this.requireSqlite();
144-
for await (const stmt of api.statements(this.db, sql)) {
156+
for await (const stmt of this.cachedStatements(api, sql)) {
145157
let columns;
146158

147159
const rs = await this.stepThroughStatement(api, stmt, bindings ?? [], columns);
@@ -192,8 +204,56 @@ export class RawSqliteConnection {
192204

193205
async close() {
194206
if (this.isOpen) {
195-
await this.requireSqlite().close(this.db);
207+
const api = this.requireSqlite();
208+
209+
if (this.statementCache) {
210+
for (const stmt of this.statementCache.drain()) {
211+
await api.finalize(stmt);
212+
}
213+
}
214+
215+
await api.close(this.db);
196216
this.db = 0;
197217
}
198218
}
219+
220+
async *cachedStatements(api: SQLiteAPI, sql: string): AsyncIterable<number> {
221+
{
222+
const existing = this.statementCache?.lookup(sql);
223+
if (existing != null) {
224+
yield existing;
225+
return;
226+
}
227+
}
228+
229+
const inner = api.statements(this.db, sql, { unscoped: true });
230+
const preparedStatements: number[] = [];
231+
232+
try {
233+
for await (const stmt of inner) {
234+
preparedStatements.push(stmt);
235+
yield stmt;
236+
}
237+
} finally {
238+
// We can only cache statements if the sql text corresponds to a single statement, otherwise it's not clear what
239+
// portion of the original sql text to use as a key.
240+
if (preparedStatements.length == 1 && this.statementCache) {
241+
const stmt = preparedStatements[0];
242+
// Don't cache EXPLAIN statements, their result becomes invalid after schema changes.
243+
if (this.sqlite3_stmt_isexplain(stmt) == 0) {
244+
const evicted = this.statementCache.addStatement(sql, stmt);
245+
if (evicted != null) {
246+
await api.finalize(evicted);
247+
}
248+
249+
return;
250+
}
251+
}
252+
253+
// We're not caching statements, so finalize them.
254+
for (const stmt of preparedStatements) {
255+
await api.finalize(stmt);
256+
}
257+
}
258+
}
199259
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export class PreparedStatementCache {
2+
readonly #size: number;
3+
// Note that Map preserves insertion order, which allows using it as an LRU
4+
// cache (with the first element being the first element to evict).
5+
readonly #statements = new Map<string, number>();
6+
7+
constructor(size: number) {
8+
this.#size = size;
9+
}
10+
11+
/**
12+
* Attempts to look up the cached sql statement, if it's currently cached.
13+
*/
14+
lookup(sql: string): number | null {
15+
const foundStatement = this.#statements.get(sql);
16+
if (foundStatement != null) {
17+
// Insert again to mark it as used.
18+
this.#statements.set(sql, foundStatement);
19+
return foundStatement;
20+
}
21+
22+
return null;
23+
}
24+
25+
/**
26+
* Adds a new statement into the cache.
27+
*
28+
* If that exceeds the target size of the statement cache, returns an old statement to evict.
29+
* The caller is responsible for freeing that statement.
30+
*/
31+
addStatement(sql: string, statement: number): number | null {
32+
this.#statements.set(sql, statement);
33+
34+
if (this.#statements.size > this.#size) {
35+
for (const [k, v] of this.#statements.entries()) {
36+
this.#statements.delete(k);
37+
return v;
38+
}
39+
}
40+
41+
return null;
42+
}
43+
44+
drain(): number[] {
45+
const values = [...this.#statements.values()];
46+
this.#statements.clear();
47+
return values;
48+
}
49+
}

packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,16 @@ export class WASQLiteOpenFactory implements SQLOpenFactory {
6060
}
6161

6262
async openConnection(): Promise<PoolConnection> {
63-
const { enableMultiTabs, useWebWorker, vfs, dbFilename, encryptionKey, temporaryStorage, cacheSizeKb } =
64-
this.options;
63+
const {
64+
enableMultiTabs,
65+
useWebWorker,
66+
vfs,
67+
dbFilename,
68+
encryptionKey,
69+
temporaryStorage,
70+
cacheSizeKb,
71+
preparedStatementsCache
72+
} = this.options;
6573

6674
if (!enableMultiTabs) {
6775
this.logger.log({ level: LogLevels.warn, message: 'Multiple tabs are not enabled in this browser' });
@@ -78,7 +86,9 @@ export class WASQLiteOpenFactory implements SQLOpenFactory {
7886
vfs,
7987
encryptionKey,
8088
temporaryStorage,
81-
cacheSizeKb
89+
cacheSizeKb,
90+
// TODO: Enable prepared statement cache by default?
91+
preparedStatementsCache: preparedStatementsCache ?? 0
8292
};
8393
}
8494

packages/web/tests/open.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ describe('Open Methods', { sequential: true }, () => {
7272
cacheSizeKb: 0,
7373
encryptionKey: undefined,
7474
filename: '',
75-
readonly: false
75+
readonly: false,
76+
preparedStatementsCache: 0
7677
};
7778
const connection = await server.openConnectionLocally(logger, options);
7879
const client = new DatabaseClient(
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect, test } from 'vitest';
2+
import { PreparedStatementCache } from '../../../src/db/adapters/wa-sqlite/StatementCache.js';
3+
import { generateTestDb } from '../../utils/testDb.js';
4+
import { Schema } from '@powersync/common';
5+
6+
test('statement cache', () => {
7+
const cache = new PreparedStatementCache(10);
8+
for (let i = 0; i < 10; i++) {
9+
cache.addStatement(`SELECT ${i}`, i);
10+
}
11+
12+
for (let i = 0; i < 10; i++) {
13+
expect(cache.lookup(`SELECT ${i}`)).toStrictEqual(i);
14+
}
15+
16+
expect(cache.addStatement('SELECT 10', 10)).toStrictEqual(0);
17+
expect(cache.lookup('SELECT 0')).toBeNull();
18+
expect(cache.lookup('SELECT 1')).not.toBeNull();
19+
});
20+
21+
test('does not cache explain statements', async () => {
22+
const db = generateTestDb({
23+
schema: new Schema({}),
24+
database: {
25+
dbFilename: `${crypto.randomUUID()}.db`,
26+
preparedStatementsCache: 16
27+
}
28+
});
29+
30+
await db.execute('create table test(id integer primary key, description text)');
31+
await db.execute('create index i1 on test(description)');
32+
const { detail: firstPlan } = await db.get<{ detail: string }>(
33+
'explain query plan select * from test where description = ?'
34+
);
35+
expect(firstPlan).toContain('USING COVERING INDEX i1');
36+
37+
await db.execute('drop index i1');
38+
const { detail: secondPlan } = await db.get<{ detail: string }>(
39+
'explain query plan select * from test where description = ?'
40+
);
41+
expect(secondPlan).not.toContain('USING COVERING INDEX i1');
42+
});

0 commit comments

Comments
 (0)