| ... | @@ -47,6 +47,12 @@ export interface SqliteClientConfig { | ... | @@ -47,6 +47,12 @@ export interface SqliteClientConfig { |
| 47 | readonly readerWorker?: boolean | undefined; | 47 | readonly readerWorker?: boolean | undefined; |
| 48 | /** How long a reader worker may take to open the database before reads use the writer. Default 10 seconds. */ | 48 | /** How long a reader worker may take to open the database before reads use the writer. Default 10 seconds. */ |
| 49 | readonly readerStartupTimeout?: Duration.Input | undefined; | 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,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 | const make = Effect.fn("makeWithDatabase")(function* ( | 588 | const make = Effect.fn("makeWithDatabase")(function* ( |
| 481 | options: SqliteClientConfig, | 589 | options: SqliteClientConfig, |
| 482 | ): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> { | 590 | ): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> { |
| 483 | yield* checkNodeSqliteCompat(); | 591 | yield* checkNodeSqliteCompat(); |
| 484 | | 592 | |
| 485 | const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); | 593 | const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); |
| | 594 | const isFileDatabase = options.filename !== ":memory:" && options.filename !== ""; |
| 486 | const transformRows = options.transformResultNames | 595 | const transformRows = options.transformResultNames |
| 487 | ? Statement.defaultTransforms(options.transformResultNames).array | 596 | ? Statement.defaultTransforms(options.transformResultNames).array |
| 488 | : undefined; | 597 | : undefined; |
| ... | @@ -510,6 +619,11 @@ const make = Effect.fn("makeWithDatabase")(function* ( | ... | @@ -510,6 +619,11 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 510 | }), | 619 | }), |
| 511 | }).pipe(Effect.orDie), | 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 | const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>(); | 628 | const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>(); |
| 515 | const hasRows = (statement: NodeSqlite.StatementSync): boolean => { | 629 | const hasRows = (statement: NodeSqlite.StatementSync): boolean => { |
| ... | @@ -550,9 +664,12 @@ const make = Effect.fn("makeWithDatabase")(function* ( | ... | @@ -550,9 +664,12 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 550 | try { | 664 | try { |
| 551 | statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); | 665 | statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); |
| 552 | if (hasRows(statement)) { | 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 | const result = statement.run(...(params as any)); | 671 | const result = statement.run(...(params as any)); |
| | 672 | checkpoint?.(); |
| 556 | return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []); | 673 | return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []); |
| 557 | } catch (cause) { | 674 | } catch (cause) { |
| 558 | return Effect.fail( | 675 | return Effect.fail( |
| ... | @@ -581,11 +698,14 @@ const make = Effect.fn("makeWithDatabase")(function* ( | ... | @@ -581,11 +698,14 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 581 | if (hasRows(statement)) { | 698 | if (hasRows(statement)) { |
| 582 | statement.setReturnArrays(true); | 699 | statement.setReturnArrays(true); |
| 583 | // Safe to cast to array after we've setReturnArrays(true) | 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 | ReadonlyArray<unknown> | 702 | ReadonlyArray<unknown> |
| 586 | >; | 703 | >; |
| | 704 | checkpoint?.(); |
| | 705 | return rows; |
| 587 | } | 706 | } |
| 588 | statement.run(...(params as any)); | 707 | statement.run(...(params as any)); |
| | 708 | checkpoint?.(); |
| 589 | return []; | 709 | return []; |
| 590 | }, | 710 | }, |
| 591 | catch: (cause) => | 711 | catch: (cause) => |
| ... | @@ -646,9 +766,7 @@ const make = Effect.fn("makeWithDatabase")(function* ( | ... | @@ -646,9 +766,7 @@ const make = Effect.fn("makeWithDatabase")(function* ( |
| 646 | const semaphore = yield* Semaphore.make(1); | 766 | const semaphore = yield* Semaphore.make(1); |
| 647 | const connection = yield* makeConnection; | 767 | const connection = yield* makeConnection; |
| 648 | const acquireReader = | 768 | const acquireReader = |
| 649 | options.readerWorker && options.filename !== ":memory:" && options.filename !== "" | 769 | options.readerWorker && isFileDatabase ? yield* makeReader(options) : undefined; |
| 650 | ? yield* makeReader(options) | | |
| 651 | : undefined; | | |
| 652 | const readerSemaphore = yield* Semaphore.make(1); | 770 | const readerSemaphore = yield* Semaphore.make(1); |
| 653 | | 771 | |
| 654 | const writer = { lock: semaphore, connection }; | 772 | const writer = { lock: semaphore, connection }; |