| ... | ... | @@ -47,6 +47,12 @@ export interface SqliteClientConfig { |
| 47 | 47 | readonly readerWorker?: boolean | undefined; |
| 48 | 48 | /** How long a reader worker may take to open the database before reads use the writer. Default 10 seconds. */ |
| 49 | 49 | readonly readerStartupTimeout?: Duration.Input | undefined; |
| 50 | /** |
| 51 | * Run WAL checkpoints on a read-write connection in a worker thread, so |
| 52 | * copying pages into the database and syncing it never block the event loop. |
| 53 | * Ignored for in-memory databases. |
| 54 | */ |
| 55 | readonly checkpointWorker?: boolean | undefined; |
| 50 | 56 | } |
| 51 | 57 | |
| 52 | 58 | /** |
| ... | ... | @@ -477,12 +483,115 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { |
| 477 | 483 | }); |
| 478 | 484 | }); |
| 479 | 485 | |
| 486 | // SQLite's default wal_autocheckpoint, in WAL frames (pages). |
| 487 | const CHECKPOINT_FRAMES = 1000; |
| 488 | // While the worker runs, the writer still checkpoints a WAL this long. Writes |
| 489 | // with no pause leave the worker behind, and the WAL can only restart once |
| 490 | // every frame is checkpointed, so this keeps the file from growing forever. |
| 491 | const WRITER_BACKSTOP_FRAMES = 10 * CHECKPOINT_FRAMES; |
| 492 | // How long writes must pause before the worker checkpoints the rest of a long WAL. |
| 493 | const CHECKPOINT_IDLE_MS = 100; |
| 494 | |
| 495 | // Each message asks the worker to checkpoint if CHECKPOINT_FRAMES frames are |
| 496 | // waiting, and it replies with the same message when done. Once writes pause, it also checkpoints the |
| 497 | // frames that arrived during its last checkpoint, so the writer can restart the |
| 498 | // WAL. NOOP only counts frames. SQLite before 3.51 reads NOOP as PASSIVE and |
| 499 | // would checkpoint on every request, so the worker refuses to start there and |
| 500 | // the writer keeps checkpointing. |
| 501 | const checkpointWorkerSource = ` |
| 502 | const { parentPort, workerData } = require("node:worker_threads"); |
| 503 | const { DatabaseSync } = require("node:sqlite"); |
| 504 | const db = new DatabaseSync(workerData.filename); |
| 505 | const version = db.prepare("SELECT sqlite_version() AS version").get().version; |
| 506 | const [major, minor] = version.split(".").map(Number); |
| 507 | if (major < 3 || (major === 3 && minor < 51)) throw new Error("SQLite " + version + " has no NOOP checkpoint"); |
| 508 | // macOS fsync() does not flush the drive cache; F_FULLFSYNC does. Other platforms ignore this. |
| 509 | db.exec("PRAGMA checkpoint_fullfsync = ON;"); |
| 510 | const frames = db.prepare("PRAGMA wal_checkpoint(NOOP)"); |
| 511 | const checkpoint = db.prepare("PRAGMA wal_checkpoint(PASSIVE)"); |
| 512 | const finish = () => { |
| 513 | const { log, checkpointed } = frames.get(); |
| 514 | if (log >= workerData.frames && checkpointed < log) checkpoint.get(); |
| 515 | }; |
| 516 | let idle; |
| 517 | parentPort.on("message", (request) => { |
| 518 | const { log, checkpointed } = frames.get(); |
| 519 | if (log - checkpointed >= workerData.frames) checkpoint.get(); |
| 520 | clearTimeout(idle); |
| 521 | idle = setTimeout(finish, workerData.idleMs); |
| 522 | parentPort.postMessage(request); |
| 523 | }); |
| 524 | `; |
| 525 | |
| 526 | /** |
| 527 | * Moves the writer's WAL checkpoints to a worker. Call the returned function |
| 528 | * after each statement on the writer. While the worker runs, the writer only |
| 529 | * checkpoints at WRITER_BACKSTOP_FRAMES. If the worker fails or exits, the |
| 530 | * writer goes back to SQLite's default. |
| 531 | */ |
| 532 | const startCheckpointWorker = Effect.fnUntraced(function* ( |
| 533 | filename: string, |
| 534 | writer: NodeSqlite.DatabaseSync, |
| 535 | ) { |
| 536 | let worker: NodeWorkerThreads.Worker; |
| 537 | try { |
| 538 | worker = new NodeWorkerThreads.Worker(checkpointWorkerSource, { |
| 539 | eval: true, |
| 540 | workerData: { filename, frames: CHECKPOINT_FRAMES, idleMs: CHECKPOINT_IDLE_MS }, |
| 541 | }); |
| 542 | } catch { |
| 543 | // No worker threads here: the writer keeps checkpointing. |
| 544 | return undefined; |
| 545 | } |
| 546 | writer.exec(`PRAGMA wal_autocheckpoint = ${WRITER_BACKSTOP_FRAMES};`); |
| 547 | let stopped = false; |
| 548 | let checking = false; |
| 549 | let checkAgain = false; |
| 550 | const handBack = () => { |
| 551 | if (stopped) return; |
| 552 | stopped = true; |
| 553 | try { |
| 554 | writer.exec(`PRAGMA wal_autocheckpoint = ${CHECKPOINT_FRAMES};`); |
| 555 | } catch { |
| 556 | // The writer is already closed. |
| 557 | } |
| 558 | }; |
| 559 | worker.on("error", handBack); |
| 560 | worker.on("exit", handBack); |
| 561 | yield* Effect.addFinalizer(() => Effect.promise(() => worker.terminate())); |
| 562 | |
| 563 | // At most one request is in flight. A statement that runs during it asks for |
| 564 | // one more check, so the frames of a last commit before idle still get one. |
| 565 | const check = () => { |
| 566 | if (stopped) return; |
| 567 | if (checking) { |
| 568 | checkAgain = true; |
| 569 | return; |
| 570 | } |
| 571 | checking = true; |
| 572 | // oxlint-disable-next-line unicorn/require-post-message-target-origin -- Node workers do not accept a target origin. |
| 573 | worker.postMessage(null); |
| 574 | }; |
| 575 | worker.on("message", () => { |
| 576 | checking = false; |
| 577 | if (checkAgain) { |
| 578 | checkAgain = false; |
| 579 | check(); |
| 580 | } |
| 581 | }); |
| 582 | // After the listeners: adding a "message" listener refs the worker again. |
| 583 | // A checkpoint in progress must not keep the process alive. |
| 584 | worker.unref(); |
| 585 | return check; |
| 586 | }); |
| 587 | |
| 480 | 588 | const make = Effect.fn("makeWithDatabase")(function* ( |
| 481 | 589 | options: SqliteClientConfig, |
| 482 | 590 | ): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> { |
| 483 | 591 | yield* checkNodeSqliteCompat(); |
| 484 | 592 | |
| 485 | 593 | const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); |
| 594 | const isFileDatabase = options.filename !== ":memory:" && options.filename !== ""; |
| 486 | 595 | const transformRows = options.transformResultNames |
| 487 | 596 | ? Statement.defaultTransforms(options.transformResultNames).array |
| 488 | 597 | : undefined; |
| ... | ... | @@ -510,6 +619,11 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 510 | 619 | }), |
| 511 | 620 | }).pipe(Effect.orDie), |
| 512 | 621 | ); |
| 622 | // Started after the close finalizer, so the worker stops before the writer closes. |
| 623 | const checkpoint = |
| 624 | options.checkpointWorker && isFileDatabase |
| 625 | ? yield* startCheckpointWorker(options.filename, db) |
| 626 | : undefined; |
| 513 | 627 | |
| 514 | 628 | const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>(); |
| 515 | 629 | const hasRows = (statement: NodeSqlite.StatementSync): boolean => { |
| ... | ... | @@ -550,9 +664,12 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 550 | 664 | try { |
| 551 | 665 | statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); |
| 552 | 666 | if (hasRows(statement)) { |
| 553 | | return Effect.succeed(statement.all(...(params as any))); |
| 667 | const rows = statement.all(...(params as any)); |
| 668 | checkpoint?.(); |
| 669 | return Effect.succeed(rows); |
| 554 | 670 | } |
| 555 | 671 | const result = statement.run(...(params as any)); |
| 672 | checkpoint?.(); |
| 556 | 673 | return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []); |
| 557 | 674 | } catch (cause) { |
| 558 | 675 | return Effect.fail( |
| ... | ... | @@ -581,11 +698,14 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 581 | 698 | if (hasRows(statement)) { |
| 582 | 699 | statement.setReturnArrays(true); |
| 583 | 700 | // Safe to cast to array after we've setReturnArrays(true) |
| 584 | | return statement.all(...(params as any)) as unknown as ReadonlyArray< |
| 701 | const rows = statement.all(...(params as any)) as unknown as ReadonlyArray< |
| 585 | 702 | ReadonlyArray<unknown> |
| 586 | 703 | >; |
| 704 | checkpoint?.(); |
| 705 | return rows; |
| 587 | 706 | } |
| 588 | 707 | statement.run(...(params as any)); |
| 708 | checkpoint?.(); |
| 589 | 709 | return []; |
| 590 | 710 | }, |
| 591 | 711 | catch: (cause) => |
| ... | ... | @@ -646,9 +766,7 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 646 | 766 | const semaphore = yield* Semaphore.make(1); |
| 647 | 767 | const connection = yield* makeConnection; |
| 648 | 768 | const acquireReader = |
| 649 | | options.readerWorker && options.filename !== ":memory:" && options.filename !== "" |
| 650 | | ? yield* makeReader(options) |
| 651 | | : undefined; |
| 769 | options.readerWorker && isFileDatabase ? yield* makeReader(options) : undefined; |
| 652 | 770 | const readerSemaphore = yield* Semaphore.make(1); |
| 653 | 771 | |
| 654 | 772 | const writer = { lock: semaphore, connection }; |