@rivetkit/sqlite-vfs 2.1.5 → 2.1.6-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pool.ts ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * SQLite VFS Pool - shares WASM SQLite instances across actors to reduce
3
+ * memory overhead. Instead of one WASM module per actor, multiple actors
4
+ * share a single instance, with short file names routing to separate KV
5
+ * namespaces.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { SqliteVfs } from "./vfs";
11
+ import type { ISqliteVfs, IDatabase } from "./vfs";
12
+ import type { KvVfsOptions } from "./types";
13
+
14
+ export interface SqliteVfsPoolConfig {
15
+ actorsPerInstance: number;
16
+ idleDestroyMs?: number;
17
+ }
18
+
19
+ /**
20
+ * Internal state for a single WASM SQLite instance shared by multiple actors.
21
+ */
22
+ interface PoolInstance {
23
+ vfs: SqliteVfs;
24
+ /** Actor IDs currently assigned to this instance. */
25
+ actors: Set<string>;
26
+ /** Monotonically increasing counter for generating short file names. */
27
+ shortNameCounter: number;
28
+ /** Maps actorId to the short name assigned within this instance. */
29
+ actorShortNames: Map<string, string>;
30
+ /** Short names released by actors that closed successfully, available for reuse. */
31
+ availableShortNames: Set<string>;
32
+ /** Short names that failed to close cleanly. Not reused until instance is destroyed. */
33
+ poisonedShortNames: Set<string>;
34
+ /** Number of in-flight operations (e.g. open calls) on this instance. */
35
+ opsInFlight: number;
36
+ /** Handle for the idle destruction timer, or null if not scheduled. */
37
+ idleTimer: ReturnType<typeof setTimeout> | null;
38
+ /** True once destruction has started. Prevents double-destroy. */
39
+ destroying: boolean;
40
+ }
41
+
42
+ /**
43
+ * Manages a pool of SqliteVfs instances, assigning actors to instances using
44
+ * bin-packing to maximize density. The WASM module is compiled once and
45
+ * reused across all instances.
46
+ */
47
+ export class SqliteVfsPool {
48
+ readonly #config: SqliteVfsPoolConfig;
49
+ #modulePromise: Promise<WebAssembly.Module> | null = null;
50
+ readonly #instances: Set<PoolInstance> = new Set();
51
+ readonly #actorToInstance: Map<string, PoolInstance> = new Map();
52
+ readonly #actorToHandle: Map<string, PooledSqliteHandle> = new Map();
53
+ #shuttingDown = false;
54
+
55
+ constructor(config: SqliteVfsPoolConfig) {
56
+ if (
57
+ !Number.isInteger(config.actorsPerInstance) ||
58
+ config.actorsPerInstance < 1
59
+ ) {
60
+ throw new Error(
61
+ `actorsPerInstance must be a positive integer, got ${config.actorsPerInstance}`,
62
+ );
63
+ }
64
+ this.#config = config;
65
+ }
66
+
67
+ /**
68
+ * Compile the WASM module once and cache the promise. Subsequent calls
69
+ * return the same promise, avoiding redundant compilation.
70
+ */
71
+ #getModule(): Promise<WebAssembly.Module> {
72
+ if (!this.#modulePromise) {
73
+ this.#modulePromise = (async () => {
74
+ const require = createRequire(import.meta.url);
75
+ const wasmPath = require.resolve(
76
+ "@rivetkit/sqlite/dist/wa-sqlite-async.wasm",
77
+ );
78
+ const wasmBinary = readFileSync(wasmPath);
79
+ return WebAssembly.compile(wasmBinary);
80
+ })();
81
+ // Clear the cached promise on rejection so subsequent calls retry
82
+ // compilation instead of returning the same rejected promise forever.
83
+ this.#modulePromise.catch(() => {
84
+ this.#modulePromise = null;
85
+ });
86
+ }
87
+ return this.#modulePromise;
88
+ }
89
+
90
+ /** Number of live WASM instances in the pool. */
91
+ get instanceCount(): number {
92
+ return this.#instances.size;
93
+ }
94
+
95
+ /** Number of actors currently assigned to pool instances. */
96
+ get actorCount(): number {
97
+ return this.#actorToInstance.size;
98
+ }
99
+
100
+ /**
101
+ * Acquire a pooled VFS handle for the given actor. Returns a
102
+ * PooledSqliteHandle with sticky assignment. If the actor is already
103
+ * assigned, the existing handle is returned.
104
+ *
105
+ * Bin-packing: picks the instance with the most actors that still has
106
+ * capacity. If all instances are full, creates a new one using the
107
+ * cached WASM module.
108
+ */
109
+ async acquire(actorId: string): Promise<PooledSqliteHandle> {
110
+ if (this.#shuttingDown) {
111
+ throw new Error("SqliteVfsPool is shutting down");
112
+ }
113
+
114
+ // Sticky assignment: return existing handle.
115
+ const existingHandle = this.#actorToHandle.get(actorId);
116
+ if (existingHandle) {
117
+ return existingHandle;
118
+ }
119
+
120
+ // Bin-packing: pick instance with most actors that still has capacity.
121
+ // Skip instances that are being destroyed.
122
+ let bestInstance: PoolInstance | null = null;
123
+ let bestCount = -1;
124
+ for (const instance of this.#instances) {
125
+ if (instance.destroying) continue;
126
+ const count = instance.actors.size;
127
+ if (count < this.#config.actorsPerInstance && count > bestCount) {
128
+ bestInstance = instance;
129
+ bestCount = count;
130
+ }
131
+ }
132
+
133
+ // If all instances are full, compile the module and re-check capacity.
134
+ // Multiple concurrent acquire() calls may all reach this point. After
135
+ // awaiting the module, re-scan for capacity that another caller may
136
+ // have created during the await, to avoid creating duplicate instances.
137
+ if (!bestInstance) {
138
+ const wasmModule = await this.#getModule();
139
+ if (this.#shuttingDown) {
140
+ throw new Error("SqliteVfsPool is shutting down");
141
+ }
142
+
143
+ // Re-check sticky assignment: another concurrent acquire() for the
144
+ // same actorId may have completed during the await.
145
+ const existingHandleAfterAwait = this.#actorToHandle.get(actorId);
146
+ if (existingHandleAfterAwait) {
147
+ return existingHandleAfterAwait;
148
+ }
149
+
150
+ // Re-scan for an instance with available capacity that was created
151
+ // by another concurrent acquire() during the module compilation.
152
+ for (const instance of this.#instances) {
153
+ if (instance.destroying) continue;
154
+ const count = instance.actors.size;
155
+ if (count < this.#config.actorsPerInstance && count > bestCount) {
156
+ bestInstance = instance;
157
+ bestCount = count;
158
+ }
159
+ }
160
+
161
+ if (!bestInstance) {
162
+ const vfs = new SqliteVfs(wasmModule);
163
+ bestInstance = {
164
+ vfs,
165
+ actors: new Set(),
166
+ shortNameCounter: 0,
167
+ actorShortNames: new Map(),
168
+ availableShortNames: new Set(),
169
+ poisonedShortNames: new Set(),
170
+ opsInFlight: 0,
171
+ idleTimer: null,
172
+ destroying: false,
173
+ };
174
+ this.#instances.add(bestInstance);
175
+ }
176
+ }
177
+
178
+ // Cancel idle timer synchronously since this instance is getting a
179
+ // new actor and should not be destroyed.
180
+ this.#cancelIdleTimer(bestInstance);
181
+
182
+ // Assign actor to instance with a short file name. Prefer recycled
183
+ // names from the available set before generating a new one.
184
+ let shortName: string;
185
+ const recycled = bestInstance.availableShortNames.values().next();
186
+ if (!recycled.done) {
187
+ shortName = recycled.value;
188
+ bestInstance.availableShortNames.delete(shortName);
189
+ } else {
190
+ shortName = String(bestInstance.shortNameCounter++);
191
+ }
192
+ bestInstance.actors.add(actorId);
193
+ bestInstance.actorShortNames.set(actorId, shortName);
194
+ this.#actorToInstance.set(actorId, bestInstance);
195
+
196
+ const handle = new PooledSqliteHandle(
197
+ shortName,
198
+ actorId,
199
+ this,
200
+ );
201
+ this.#actorToHandle.set(actorId, handle);
202
+
203
+ return handle;
204
+ }
205
+
206
+ /**
207
+ * Release an actor's assignment from the pool. Force-closes all database
208
+ * handles for the actor, recycles or poisons the short name, and
209
+ * decrements the instance refcount.
210
+ */
211
+ async release(actorId: string): Promise<void> {
212
+ const instance = this.#actorToInstance.get(actorId);
213
+ if (!instance) {
214
+ return;
215
+ }
216
+
217
+ const shortName = instance.actorShortNames.get(actorId);
218
+ if (shortName === undefined) {
219
+ return;
220
+ }
221
+
222
+ // Force-close all Database handles for this actor's short name.
223
+ const { allSucceeded } =
224
+ await instance.vfs.forceCloseByFileName(shortName);
225
+
226
+ if (allSucceeded) {
227
+ instance.availableShortNames.add(shortName);
228
+ } else {
229
+ instance.poisonedShortNames.add(shortName);
230
+ }
231
+
232
+ // Remove actor from instance tracking.
233
+ instance.actors.delete(actorId);
234
+ instance.actorShortNames.delete(actorId);
235
+ this.#actorToInstance.delete(actorId);
236
+ this.#actorToHandle.delete(actorId);
237
+
238
+ // Start idle timer if instance has no actors and no in-flight ops.
239
+ // Skip if shutting down to avoid leaking timers after shutdown
240
+ // completes.
241
+ if (instance.actors.size === 0 && instance.opsInFlight === 0 && !this.#shuttingDown) {
242
+ this.#startIdleTimer(instance);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Track an in-flight operation on an instance. Increments opsInFlight
248
+ * before running fn, decrements after using try/finally to prevent
249
+ * drift from exceptions. If the decrement brings opsInFlight to 0
250
+ * with refcount also 0, starts the idle timer.
251
+ */
252
+ async #trackOp<T>(
253
+ instance: PoolInstance,
254
+ fn: () => Promise<T>,
255
+ ): Promise<T> {
256
+ instance.opsInFlight++;
257
+ try {
258
+ return await fn();
259
+ } finally {
260
+ instance.opsInFlight--;
261
+ if (
262
+ instance.actors.size === 0 &&
263
+ instance.opsInFlight === 0 &&
264
+ !instance.destroying &&
265
+ !this.#shuttingDown
266
+ ) {
267
+ this.#startIdleTimer(instance);
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Open a database on behalf of an actor, tracked as an in-flight
274
+ * operation. Used by PooledSqliteHandle to avoid exposing PoolInstance.
275
+ */
276
+ async openForActor(
277
+ actorId: string,
278
+ shortName: string,
279
+ options: KvVfsOptions,
280
+ ): Promise<IDatabase> {
281
+ const instance = this.#actorToInstance.get(actorId);
282
+ if (!instance) {
283
+ throw new Error(`Actor ${actorId} is not assigned to any pool instance`);
284
+ }
285
+ return this.#trackOp(instance, () =>
286
+ instance.vfs.open(shortName, options),
287
+ );
288
+ }
289
+
290
+ /**
291
+ * Track an in-flight database operation for the given actor. Resolves the
292
+ * actor's pool instance and wraps the operation with opsInFlight tracking.
293
+ * If the actor has already been released, the operation runs without
294
+ * tracking since the instance may already be destroyed.
295
+ */
296
+ async trackOpForActor<T>(
297
+ actorId: string,
298
+ fn: () => Promise<T>,
299
+ ): Promise<T> {
300
+ const instance = this.#actorToInstance.get(actorId);
301
+ if (!instance) {
302
+ return fn();
303
+ }
304
+ return this.#trackOp(instance, fn);
305
+ }
306
+
307
+ #startIdleTimer(instance: PoolInstance): void {
308
+ if (instance.idleTimer || instance.destroying) return;
309
+ const idleDestroyMs = this.#config.idleDestroyMs ?? 30_000;
310
+ instance.idleTimer = setTimeout(() => {
311
+ instance.idleTimer = null;
312
+ // Check opsInFlight in addition to actors.size. With tracked
313
+ // database operations (TrackedDatabase), opsInFlight can be >0
314
+ // while actors.size is 0 if the last operation is still in-flight
315
+ // after release. The #trackOp finally block will re-start the
316
+ // idle timer when ops drain to 0.
317
+ if (
318
+ instance.actors.size === 0 &&
319
+ instance.opsInFlight === 0 &&
320
+ !instance.destroying
321
+ ) {
322
+ this.#destroyInstance(instance);
323
+ }
324
+ }, idleDestroyMs);
325
+ }
326
+
327
+ #cancelIdleTimer(instance: PoolInstance): void {
328
+ if (instance.idleTimer) {
329
+ clearTimeout(instance.idleTimer);
330
+ instance.idleTimer = null;
331
+ }
332
+ }
333
+
334
+ async #destroyInstance(instance: PoolInstance): Promise<void> {
335
+ instance.destroying = true;
336
+ this.#cancelIdleTimer(instance);
337
+ // Remove from pool map first so no new actors can be assigned.
338
+ this.#instances.delete(instance);
339
+ try {
340
+ await instance.vfs.forceCloseAll();
341
+ await instance.vfs.destroy();
342
+ } catch (error) {
343
+ console.warn("SqliteVfsPool: failed to destroy instance", error);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Graceful shutdown. Rejects new acquire() calls, cancels idle timers,
349
+ * force-closes all databases, destroys all VFS instances, and clears pool
350
+ * state.
351
+ */
352
+ async shutdown(): Promise<void> {
353
+ this.#shuttingDown = true;
354
+
355
+ // Snapshot instances to array since we mutate the set during iteration.
356
+ const instances = [...this.#instances];
357
+
358
+ for (const instance of instances) {
359
+ this.#cancelIdleTimer(instance);
360
+ this.#instances.delete(instance);
361
+
362
+ // Check for in-flight operations (e.g. a concurrent release() call
363
+ // mid-forceCloseByFileName). Database.close() is idempotent
364
+ // (US-019), so concurrent close from shutdown + release is safe,
365
+ // but log a warning for observability.
366
+ if (instance.opsInFlight > 0) {
367
+ console.warn(
368
+ `SqliteVfsPool: shutting down instance with ${instance.opsInFlight} in-flight operation(s). ` +
369
+ "Concurrent close is safe due to Database.close() idempotency.",
370
+ );
371
+ }
372
+
373
+ try {
374
+ await instance.vfs.forceCloseAll();
375
+ await instance.vfs.destroy();
376
+ } catch (error) {
377
+ console.warn("SqliteVfsPool: failed to destroy instance during shutdown", error);
378
+ }
379
+ }
380
+
381
+ this.#actorToInstance.clear();
382
+ this.#actorToHandle.clear();
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Wraps a Database with opsInFlight tracking so the pool's idle timer
388
+ * does not destroy instances while database operations are in-flight.
389
+ * The unwrapped Database remains in SqliteVfs's #openDatabases set
390
+ * for force-close purposes.
391
+ */
392
+ class TrackedDatabase implements IDatabase {
393
+ readonly #inner: IDatabase;
394
+ readonly #pool: SqliteVfsPool;
395
+ readonly #actorId: string;
396
+
397
+ constructor(inner: IDatabase, pool: SqliteVfsPool, actorId: string) {
398
+ this.#inner = inner;
399
+ this.#pool = pool;
400
+ this.#actorId = actorId;
401
+ }
402
+
403
+ async exec(
404
+ ...args: Parameters<IDatabase["exec"]>
405
+ ): ReturnType<IDatabase["exec"]> {
406
+ return this.#pool.trackOpForActor(this.#actorId, () =>
407
+ this.#inner.exec(...args),
408
+ );
409
+ }
410
+
411
+ async run(
412
+ ...args: Parameters<IDatabase["run"]>
413
+ ): ReturnType<IDatabase["run"]> {
414
+ return this.#pool.trackOpForActor(this.#actorId, () =>
415
+ this.#inner.run(...args),
416
+ );
417
+ }
418
+
419
+ async query(
420
+ ...args: Parameters<IDatabase["query"]>
421
+ ): ReturnType<IDatabase["query"]> {
422
+ return this.#pool.trackOpForActor(this.#actorId, () =>
423
+ this.#inner.query(...args),
424
+ );
425
+ }
426
+
427
+ async close(): ReturnType<IDatabase["close"]> {
428
+ return this.#pool.trackOpForActor(this.#actorId, () =>
429
+ this.#inner.close(),
430
+ );
431
+ }
432
+
433
+ get fileName(): string {
434
+ return this.#inner.fileName;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * A pooled VFS handle for a single actor. Implements ISqliteVfs so callers
440
+ * can use it interchangeably with a standalone SqliteVfs. The short name
441
+ * assigned by the pool is used as the VFS file path, while the caller's
442
+ * KvVfsOptions routes data to the correct KV namespace.
443
+ */
444
+ export class PooledSqliteHandle implements ISqliteVfs {
445
+ readonly #shortName: string;
446
+ readonly #actorId: string;
447
+ readonly #pool: SqliteVfsPool;
448
+ #released = false;
449
+
450
+ constructor(
451
+ shortName: string,
452
+ actorId: string,
453
+ pool: SqliteVfsPool,
454
+ ) {
455
+ this.#shortName = shortName;
456
+ this.#actorId = actorId;
457
+ this.#pool = pool;
458
+ }
459
+
460
+ /**
461
+ * Open a database on the shared instance. Uses the pool-assigned short
462
+ * name as the VFS file path, with the caller's KvVfsOptions for KV
463
+ * routing. The open call itself is tracked as an in-flight operation,
464
+ * and the returned Database is wrapped so that exec(), run(), query(),
465
+ * and close() are also tracked via opsInFlight.
466
+ */
467
+ async open(_fileName: string, options: KvVfsOptions): Promise<IDatabase> {
468
+ if (this.#released) {
469
+ throw new Error("PooledSqliteHandle has been released");
470
+ }
471
+ const db = await this.#pool.openForActor(
472
+ this.#actorId,
473
+ this.#shortName,
474
+ options,
475
+ );
476
+ return new TrackedDatabase(
477
+ db,
478
+ this.#pool,
479
+ this.#actorId,
480
+ );
481
+ }
482
+
483
+ /**
484
+ * Release this actor's assignment back to the pool. Idempotent: calling
485
+ * destroy() more than once is a no-op, preventing double-release from
486
+ * decrementing the instance refcount below actual.
487
+ */
488
+ async destroy(): Promise<void> {
489
+ if (this.#released) {
490
+ return;
491
+ }
492
+ this.#released = true;
493
+ await this.#pool.release(this.#actorId);
494
+ }
495
+ }