diff --git a/CHANGELOG.md b/CHANGELOG.md index e242c06..b1f37c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +- `S3ReplicaClient.openLtxFile()` reads a range with a single `GetObject`, without + the `HeadObject` it sent first, which halves the requests of a paged restore. - The first `sync()` after a restore into the replica's own path (as `ensureExists()` does) uploads only the changes made since the restore, instead of a snapshot of the whole database. The restore writes the first WAL of the restored database: one commit frame holding page 1 unchanged, under @@ -34,6 +36,18 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- `Replica.restorePaged()` restores the replica's own database page by page, with + `@platformatic/sqlite-js-stream-paged-vfs` (a new optional peer dependency). + It resolves once the database can be opened through the VFS; queries from + other threads read the pages they need on demand, with read-ahead, while the + others are filled in the background (`result.filled`). Replication goes on + meanwhile: `sync()` and `snapshot()` read missing pages first, and retention + keeps the LTX files the restore reads. A restore stopped by `close()` or a + crash leaves the database incomplete: `localPos()` is 0, `sync()` and + `snapshot()` throw `ErrIncompleteDatabase`, and `ensureExists()` restores it + again. It restores fully below `minSize` bytes, or when the VFS cannot be used. + New exports: `PagedRestoreOptions`, `PagedRestoreResult`, + `ErrIncompleteDatabase`, `ErrPagedRestoreClosed`, `ErrPagedVfsUnavailable`. - `@platformatic/sqlite-js-stream-paged-vfs`, a new package: a SQLite VFS for `node:sqlite` that restores a database page by page on demand. Queries run while the database downloads; a read of a missing page waits until the diff --git a/README.md b/README.md index 6310df4..47e5b53 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ Importing the addon registers the `s3` URL scheme with **`@platformatic/sqlite-js-stream-paged-vfs`** is a SQLite VFS for `node:sqlite` which reads the pages of a database on demand, so that an application can query a large database while it is still being restored. It is a native library, with -prebuilt binaries for macOS and Linux. See its +prebuilt binaries for macOS and Linux. `replica.restorePaged()` uses it when it is +installed (see [Restore page by page](#restore-page-by-page)). See its [README](packages/sqlite-js-stream-paged-vfs/README.md). ```bash @@ -148,6 +149,47 @@ closing (which deletes it), makes the next `sync()` fall back to a snapshot. With `wal_autocheckpoint` disabled and checkpoints run through `replica.checkpoint()` (see below), syncs stay incremental from the restore on. +### Restore page by page + +A full restore downloads the whole database before it can be opened. With +`@platformatic/sqlite-js-stream-paged-vfs` installed, `restorePaged()` restores +the replica's own database page by page instead: it resolves once the database +can be opened, and queries read the pages they need on demand while the others +are downloaded in the background. + +```ts +import { databaseUri } from '@platformatic/sqlite-js-stream-paged-vfs'; + +const result = await replica.restorePaged({ minSize: 64 * 1024 * 1024 }); +// In a worker thread, not this one: +const db = new DatabaseSync(databaseUri('/data/app.db')); + +await result.filled; // every page is local +``` + +- The database is created with every page missing. A query that reads a missing + page waits while it is read from the replica, with the next pages of the same + LTX file (`readAhead`, 32 by default). Page 1 and the schema are read before + `restorePaged()` resolves. +- Open the database with the URI from `databaseUri()`, from **another thread** + than the one that called `restorePaged()`: that thread serves the pages, so a + query from it would wait for itself (such reads fail instead). Every + connection that writes the database must use the VFS. +- Replication goes on meanwhile. `sync()` and `snapshot()` read the pages they + need first, and retention keeps the LTX files the restore still reads. +- `result.filled` resolves once every page is local. `replica.close()`, or + restoring the database again, stops the restore: the database stays + incomplete. So does a crash. An incomplete database has a `localPos()` of 0, + and `sync()`/`snapshot()` throw `ErrIncompleteDatabase` until it is restored + again (`ensureExists()` does). +- It restores fully, like `restore()`, when the LTX files to download add up + to less than `minSize` bytes, or when the paged VFS cannot be used + (`result.unavailable` holds the reason: the package is not installed, has no + binary for the platform, or Node.js runs with `--permission`). + `result.paged` tells which. +- `onMiss(pgno, pages, durationMs)` is called for each page a query waited for, + for metrics. + ### Manage many databases ```ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae0c1c9..18bdaf2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -159,6 +159,49 @@ by an earlier restore is ignored. Because restore never materializes the full merged LTX (or the whole DB) in memory, peak memory is bounded by one page buffer plus the (small) page index, regardless of the database size. +### Replica.restorePaged() + +`restorePaged()` (`src/paged-restore.ts`) makes the replica's own database usable before it is +downloaded, with the SQLite VFS of `@platformatic/sqlite-js-stream-paged-vfs`, an optional peer +dependency imported only here. The VFS keeps, for each registered database, a bitmap of the pages +present in a sparse file; a read of a missing page asks the thread that registered the database for +it and waits, and it never fills a page SQLite wrote meanwhile (see that package's README). + +1. `CalcRestorePlan` as for a full restore. When its files add up to less than `minSize`, or the + VFS cannot be loaded, it restores fully instead. +2. The headers of the first and last files give the page size and the final page count. The page + index of every file (read concurrently) gives, for each page, the file holding its newest + version and the offset and size of its frame there, kept in typed arrays. +3. The marker `-litestream/paged` is written, the database file and its `-wal`/`-shm` are + removed, and the database is registered with the VFS, which creates it sparse at its final size. +4. Page 1 and the schema table (a b-tree rooted at page 1, with the overflow pages of its records) + are filled, walking the b-tree from page 1. The replica's read connection parses the schema and + runs on the thread that serves the pages, where a read of a missing page fails rather than + waits: these are the only pages of the database file it reads. +5. The restore writes the first WAL and the restore record, as a full restore does, and resolves. + +From then on: + +- **Requests.** The VFS asks for a page; it is read with the next frames of the same file, up to + `readAhead`, in one ranged read, decoded and filled. A page the background fill is reading is + read again in this smaller read, so that a query never waits for a large one. A failed read is + retried twice, then the VFS fails the waiting reads with `SQLITE_IOERR_READ`. +- **Background fill.** Passes over the missing pages in page order, grouping them by file into + ranged reads of at most 4 MiB (spanning frames of superseded pages up to 256 KiB), four at a + time. Failed reads are retried in the next pass, with a backoff. Pages no file has are filled + with zeros, as a full restore leaves them. Once the VFS reports every page present, the marker is + removed and `filled` resolves. +- **Replication.** `LocalDatabase` reads database pages from the file only after the page loader + made them present: a page being read waits for that read, a missing one is read with read-ahead. + The replica's read connection opens the database through the VFS. `deleteFiles()` keeps the + files of the plan until the restore is done, whatever the retention policy. +- **Stopping.** `close()`, or another restore, closes the VFS registration: reads of missing pages + fail, and `filled` rejects with `ErrPagedRestoreClosed`. The marker stays, as after a crash: a + replica without a paged restore in progress sees an incomplete database, reports a local + position of 0, and refuses to `sync()` or `snapshot()` it (`ErrIncompleteDatabase`), so that its + holes are never uploaded as zeros. A full restore removes the marker once the database is + complete. + ### Replica.follow() `follow()` is litestream's `restore -f` continuous mode. It first restores the latest backup to diff --git a/package.json b/package.json index f380144..faa1bb3 100644 --- a/package.json +++ b/package.json @@ -50,14 +50,23 @@ "golden:go": "cd tools/golden && go run .", "pretest:all": "pnpm build", "test:all": "pnpm test && pnpm -r --filter '!@platformatic/sqlite-js-stream-paged-vfs' test", - "test:paged-vfs": "pnpm --filter @platformatic/sqlite-js-stream-paged-vfs build:native && pnpm --filter @platformatic/sqlite-js-stream-paged-vfs test", + "test:paged-vfs": "pnpm --filter @platformatic/sqlite-js-stream-paged-vfs build:native && pnpm --filter @platformatic/sqlite-js-stream-paged-vfs build && pnpm --filter @platformatic/sqlite-js-stream-paged-vfs test && node --test test/integration/paged-restore.test.ts", "typecheck:all": "pnpm typecheck", "bench": "node bench/bench-sync-restore.ts" }, "dependencies": { "lz4js": "^0.2.0" }, + "peerDependencies": { + "@platformatic/sqlite-js-stream-paged-vfs": "workspace:^" + }, + "peerDependenciesMeta": { + "@platformatic/sqlite-js-stream-paged-vfs": { + "optional": true + } + }, "devDependencies": { + "@platformatic/sqlite-js-stream-paged-vfs": "workspace:*", "@types/node": "^26.0.0", "eslint": "^9.39.5", "neostandard": "^0.14.0", diff --git a/packages/backend-s3/src/replica-client.ts b/packages/backend-s3/src/replica-client.ts index 7e39afb..0c57f41 100644 --- a/packages/backend-s3/src/replica-client.ts +++ b/packages/backend-s3/src/replica-client.ts @@ -557,19 +557,10 @@ export class S3ReplicaClient implements ReplicaClient { } } - // Need the object size to build a range and to handle offset >= size. - let head; - try { - head = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); - } catch (err) { - throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err); - } - const len = Number(head.ContentLength ?? 0); - if (opts.offset >= len) return Readable.from([]); - - const end = opts.size > 0 ? Math.min(opts.offset + opts.size - 1, len - 1) : len - 1; - if (end < opts.offset) return Readable.from([]); - + // A range may end past the end of the object, and an open range reads to + // its end, so no HeadObject round-trip is needed: a range starting past the + // end fails with 416, and reads nothing. + const end = opts.size > 0 ? String(opts.offset + opts.size - 1) : ''; try { const res = await this.client.send( new GetObjectCommand({ @@ -580,6 +571,7 @@ export class S3ReplicaClient implements ReplicaClient { ); return res.Body as Readable; } catch (err) { + if (isInvalidRange(err)) return Readable.from([]); throw new LTXError('open', key, opts.level, opts.minTXID, opts.maxTXID, err); } } @@ -997,3 +989,9 @@ export function newReplicaClientFromURL( } return new S3ReplicaClient(opts); } + +/** Whether a GetObject failed because its range starts past the end of the object. */ +function isInvalidRange(err: unknown): boolean { + const e = err as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } }; + return e?.name === 'InvalidRange' || e?.Code === 'InvalidRange' || e?.$metadata?.httpStatusCode === 416; +} diff --git a/packages/backend-s3/test/helpers/memory-s3.ts b/packages/backend-s3/test/helpers/memory-s3.ts index 8ebe2f1..60274ee 100644 --- a/packages/backend-s3/test/helpers/memory-s3.ts +++ b/packages/backend-s3/test/helpers/memory-s3.ts @@ -198,7 +198,9 @@ export class MemoryS3 { const m = /^bytes=(\d+)-(\d*)$/.exec(cmd.input.Range); if (m) { const start = Number(m[1]); - const end = m[2] === '' ? obj.body.length - 1 : Number(m[2]); + // Like S3: a range starting past the end fails, one ending past it is cut + if (start >= obj.body.length) throw invalidRange(cmd.input.Range); + const end = m[2] === '' ? obj.body.length - 1 : Math.min(Number(m[2]), obj.body.length - 1); body = obj.body.subarray(start, end + 1); } } @@ -273,6 +275,13 @@ function notFound(key: string): Error { return err; } +function invalidRange(range: string): Error { + const err = new Error(`The requested range is not satisfiable: ${range}`); + (err as any).name = 'InvalidRange'; + (err as any).$metadata = { httpStatusCode: 416 }; + return err; +} + function noSuchUpload(uploadId: string): Error { const err = new Error(`No Such Upload: ${uploadId}`); (err as any).name = 'NoSuchUpload'; diff --git a/packages/backend-s3/test/s3.test.ts b/packages/backend-s3/test/s3.test.ts index 2882cb0..4e0fb1f 100644 --- a/packages/backend-s3/test/s3.test.ts +++ b/packages/backend-s3/test/s3.test.ts @@ -16,6 +16,7 @@ import { S3ReplicaClient, type S3ReplicaClientOptions, } from '../src/index.ts'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; import { MemoryS3 } from './helpers/memory-s3.ts'; import { createWALDB } from './helpers/db.ts'; @@ -205,6 +206,29 @@ test('openLtxFile returns empty stream when offset >= size', async () => { assert.equal(buf.length, 0); }); +test('openLtxFile reads a range with one request, without HeadObject', async () => { + const { client, s3 } = makeS3({ bucket: 'b' }); + const { body } = makeLtxBody(5); + s3.objects.set('b/ltx/0/0000000000000001-0000000000000001.ltx', { + body: Buffer.from(body), + metadata: {}, + lastModified: new Date(), + }); + const sent: unknown[] = []; + s3.onSend = (command) => { + sent.push(command); + }; + const read = async (offset: number, size: number): Promise => + [...new Uint8Array(await collect(await client.openLtxFile({ level: 0, minTXID: 1n, maxTXID: 1n, offset, size })))]; + + // Open ranges read to the end, and ranges ending past it are cut + assert.deepEqual(await read(102, 0), [2, 3, 4]); + assert.deepEqual(await read(103, 100), [3, 4]); + assert.deepEqual(await read(105, 0), []); + assert.equal(sent.length, 3); + assert.ok(sent.every((command) => command instanceof GetObjectCommand)); +}); + test('openLtxFile throws for a missing object', async () => { const { client } = makeS3({ bucket: 'b' }); await assert.rejects( diff --git a/packages/sqlite-js-stream-paged-vfs/README.md b/packages/sqlite-js-stream-paged-vfs/README.md index 79544ae..55cc7de 100644 --- a/packages/sqlite-js-stream-paged-vfs/README.md +++ b/packages/sqlite-js-stream-paged-vfs/README.md @@ -9,7 +9,10 @@ the other pages in the background. Once every page is present, the database is a regular SQLite database and reads are local only. It lets an application open a large database restored from a replica right away, -instead of after downloading all of it. +instead of after downloading all of it. To restore a database replicated with +[`@platformatic/sqlite-js-stream`](https://github.com/platformatic/sqlite-js-stream#restore-page-by-page), +call `replica.restorePaged()`: it uses this package to fetch each page from the +replica when it is read, and fills the others in the background. > Status: experimental. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe529c1..2f52a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: specifier: ^0.2.0 version: 0.2.0 devDependencies: + '@platformatic/sqlite-js-stream-paged-vfs': + specifier: workspace:* + version: link:packages/sqlite-js-stream-paged-vfs '@types/node': specifier: ^26.0.0 version: 26.6.6 diff --git a/src/db.ts b/src/db.ts index 06d8383..cc80433 100644 --- a/src/db.ts +++ b/src/db.ts @@ -43,7 +43,7 @@ import { } from './wal/index.ts'; import { SnapshotLevel } from './replica-client.ts'; import { mkdirAll } from './util/fs.ts'; -import { DatabaseError } from './errors.ts'; +import { DatabaseError, ErrIncompleteDatabase } from './errors.ts'; /** * ReaderConnection is the minimal SQLite connection used to pin a WAL read @@ -63,6 +63,12 @@ export interface ReaderConnection { */ export type OpenReader = (path: string) => ReaderConnection; +/** + * PageLoader makes a page present in the database file, while a paged restore + * fills it. It returns undefined when the page is already present. + */ +export type PageLoader = (pgno: number) => Promise | undefined; + /** * CheckpointConnection is the minimal read-write SQLite connection used by * {@link runCheckpoint} to run `PRAGMA wal_checkpoint`. A @@ -283,6 +289,7 @@ export class LocalDatabase { readonly pageSize: number; private conn: ReaderConnection | null = null; private openReader: OpenReader; + private pageLoader: PageLoader | null = null; private constructor(path: string, metaPath: string, pageSize: number, openReader: OpenReader) { this.path = path; @@ -389,6 +396,47 @@ export class LocalDatabase { } } + /** + * PagedPath returns the path of the file which exists while a paged restore + * of the database has not filled every page. + */ + pagedPath(): string { + return nodePath.join(this.metaPath, 'paged'); + } + + /** Reports whether a paged restore of the database did not fill every page. */ + async incomplete(): Promise { + try { + await stat(this.pagedPath()); + return true; + } catch { + return false; + } + } + + /** + * SetPageLoader sets the function which makes a page present in the + * database file before it is read from it, while a paged restore fills the + * database, or removes it. Without it, an incomplete database is never read. + */ + setPageLoader(loader: PageLoader | null): void { + this.pageLoader = loader; + } + + /** Throws if the database is incomplete and no page loader can fill it. */ + private async assertComplete(): Promise { + if (this.pageLoader === null && (await this.incomplete())) { + throw new ErrIncompleteDatabase(this.path); + } + } + + /** Reads page `pgno` from the database file into `page`. */ + private async readDbPage(handle: FileHandle, page: Uint8Array, pgno: number): Promise { + const load = this.pageLoader?.(pgno); + if (load !== undefined) await load; + await readExact(handle, page, (pgno - 1) * page.length); + } + /** LTXDir returns the local LTX root directory. */ ltxDir(): string { return nodePath.join(this.metaPath, 'ltx'); @@ -500,6 +548,7 @@ export class LocalDatabase { /** Sync builds a level-0 LTX file and writes it to the local cache. */ async sync(): Promise { + await this.assertComplete(); const pos = await this.pos(); const txID = pos + 1n; @@ -676,7 +725,7 @@ export class LocalDatabase { // Growth pages (not in pageMap) come directly from the database file. if (!pageMap.has(pgno)) { const page = new Uint8Array(pageSize); - await readExact(dbHandle, page, (pgno - 1) * pageSize); + await this.readDbPage(dbHandle, page, pgno); enc.encodePage({ pgno, flags: 0 }, page); continue; } @@ -723,6 +772,7 @@ export class LocalDatabase { * a snapshot would cover no data (empty database). */ async snapshot(): Promise { + await this.assertComplete(); const pos = await this.pos(); const maxTXID = pos === 0n ? 1n : pos; @@ -819,7 +869,7 @@ export class LocalDatabase { return new Uint8Array(walBuf.subarray(start, start + pageSize)); } const page = new Uint8Array(pageSize); - await readExact(dbHandle, page, (pgno - 1) * pageSize); + await this.readDbPage(dbHandle, page, pgno); return page; } diff --git a/src/errors.ts b/src/errors.ts index 0ed3e27..2c1d829 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -111,3 +111,43 @@ export class ErrReplicaAhead extends SqliteJsStreamError { this.remoteTXID = remoteTXID; } } + +/** + * The paged VFS cannot be used (`ERR_PAGED_VFS_UNAVAILABLE`): + * `@platformatic/sqlite-js-stream-paged-vfs` is not installed, has no native + * library for this platform, or cannot load it (for example under the + * permission model of Node.js, which forbids SQLite extensions). The cause is + * in `cause`. + */ +export class ErrPagedVfsUnavailable extends SqliteJsStreamError { + constructor(message: string, options?: { cause?: unknown }) { + super('ERR_PAGED_VFS_UNAVAILABLE', message); + this.name = 'ErrPagedVfsUnavailable'; + if (options && 'cause' in options) this.cause = options.cause; + } +} + +/** + * The local database is incomplete (`ERR_INCOMPLETE_DATABASE`): a paged + * restore of it did not finish, so some of its pages are missing from the + * file. Raised by `sync()` and `snapshot()` instead of uploading them as + * zeros. Restore the database again. + */ +export class ErrIncompleteDatabase extends SqliteJsStreamError { + constructor(path: string) { + super('ERR_INCOMPLETE_DATABASE', `the database ${path} is incomplete: a paged restore of it did not finish`); + this.name = 'ErrIncompleteDatabase'; + } +} + +/** + * A paged restore was stopped before every page was filled + * (`ERR_PAGED_RESTORE_CLOSED`), because its replica was closed or restored + * again. The database stays incomplete until it is restored again. + */ +export class ErrPagedRestoreClosed extends SqliteJsStreamError { + constructor(path: string) { + super('ERR_PAGED_RESTORE_CLOSED', `the paged restore of ${path} was closed before the database was complete`); + this.name = 'ErrPagedRestoreClosed'; + } +} diff --git a/src/ltx/page.ts b/src/ltx/page.ts index c7de039..c7ffbaf 100644 --- a/src/ltx/page.ts +++ b/src/ltx/page.ts @@ -49,9 +49,11 @@ export interface DecodedPage { * Note: the uncompressed byte length is unknown here; LTX pages are at most * `MaxPageSize`, so we decompress into a 64KiB buffer and slice to the bytes * written. Callers that know the page size should use `DecodePage` in the - * decoder for the size-accurate path. + * decoder for the size-accurate path, or pass `out`, a buffer of the page + * size, to decode into it: `data` is then a view of `out`, and a page that + * does not fit fails. */ -export function decodePageData(b: Uint8Array): DecodedPage { +export function decodePageData(b: Uint8Array, out?: Uint8Array): DecodedPage { const header = unmarshalPageHeader(b); if (header.pgno === 0 && header.flags === 0) { return { header, data: new Uint8Array(0) }; @@ -68,11 +70,14 @@ export function decodePageData(b: Uint8Array): DecodedPage { throw new LTXFormatError(`buffer too small for data: need ${offset + dataSize}, have ${b.length}`); } const compressed = b.subarray(offset, offset + dataSize); - const buf = new Uint8Array(MaxPageSize); + const buf = out ?? new Uint8Array(MaxPageSize); const n = decompressBlockInto(compressed, buf); if (n <= 0) { throw new LTXFormatError('decompress block failed'); } + if (n > buf.length) { + throw new LTXFormatError(`page data of ${n} bytes does not fit in ${buf.length}`); + } return { header, data: buf.subarray(0, n) }; } diff --git a/src/paged-restore.ts b/src/paged-restore.ts new file mode 100644 index 0000000..aee58f0 --- /dev/null +++ b/src/paged-restore.ts @@ -0,0 +1,559 @@ +/** + * PagedRestore restores a database page by page, on demand, with the SQLite + * VFS of `@platformatic/sqlite-js-stream-paged-vfs` (an optional peer + * dependency, loaded only here). + * + * The database file is created sparse, at its final size, with every page + * missing, and registered with the VFS. Connections that open it through the + * VFS query it right away: a read of a missing page asks this class for it, + * and waits until it is filled. Meanwhile every page is filled in the + * background, until the database is complete and the VFS steps aside. + * + * Each page is filled with its newest version in the restore plan. The page + * indexes of the plan's files say which file holds it, at which offset, so a + * page is one ranged read away. A request reads the page with the next ones of + * the same file (read-ahead); the background fill reads large ranges, a few at + * a time. The VFS never fills a page SQLite wrote meanwhile, so the order of + * the reads does not matter. + * + * Every page the replica's own connection reads while the database is + * incomplete must be present: it runs on the thread that serves the requests, + * where a read of a missing page fails instead of waiting. That connection + * reads only the schema, which is filled before the restore resolves. The + * replica reads the other pages from the file, after {@link PagedRestore.load}. + */ + +import { open, rm, writeFile } from 'node:fs/promises'; + +import { ByteReader, decodePageData, parsePageIndex, type FileInfo } from './ltx/index.ts'; +import { fetchLtxHeader, fetchPageIndexData, type ReplicaClient } from './replica-client.ts'; +import { readAll } from './util/stream.ts'; +import type { Logger } from './util/logger.ts'; +import { + DatabaseError, + ErrPagedRestoreClosed, + ErrPagedVfsUnavailable, + LTXFormatError, + PageSizeMismatchError, +} from './errors.ts'; + +/** The database API of `@platformatic/sqlite-js-stream-paged-vfs`. */ +export interface PagedVfsDatabase { + readonly missing: number; + readonly complete: boolean; + fill(pgno: number, page: Uint8Array): boolean; + fail(pgno: number): void; + close(): void; +} + +/** The module API of `@platformatic/sqlite-js-stream-paged-vfs`. */ +export interface PagedVfs { + load(): void; + databaseUri(path: string): string; + register( + options: { path: string; pageSize: number; pageCount: number; timeout?: number }, + onRequest: (pgno: number) => void, + ): PagedVfsDatabase; +} + +const PagedVfsPackage = '@platformatic/sqlite-js-stream-paged-vfs'; + +/** + * LoadPagedVfs imports the paged VFS and registers it in the SQLite of + * `node:sqlite`, or throws {@link ErrPagedVfsUnavailable}. + */ +export async function loadPagedVfs(): Promise { + let vfs: PagedVfs; + + try { + vfs = (await import(PagedVfsPackage)) as PagedVfs; + } catch (err) { + throw new ErrPagedVfsUnavailable(`${PagedVfsPackage} is not installed`, { cause: err }); + } + + try { + vfs.load(); + } catch (err) { + throw new ErrPagedVfsUnavailable(`cannot load the paged VFS: ${(err as Error).message}`, { cause: err }); + } + + return vfs; +} + +export interface PagedRestoreStartOptions { + vfs: PagedVfs; + client: ReplicaClient; + /** The restore plan, from {@link calcRestorePlan}. */ + plan: FileInfo[]; + /** The database path. Any file there is replaced. */ + path: string; + /** A file which exists while the database is incomplete. */ + marker: string; + logger: Logger; + /** Pages read with each requested page. Defaults to 32. */ + readAhead?: number; + /** How long a read waits for a missing page, in milliseconds. Defaults to the VFS's 30 seconds. */ + timeout?: number; + /** Called when a page requested by a read is filled. */ + onMiss?: (pgno: number, pages: number, durationMs: number) => void; +} + +/** A ranged read of some pages of one file of the plan. */ +interface Run { + file: number; + start: number; + end: number; + pages: number[]; +} + +// The background fill reads ranges of at most this many bytes, possibly over +// frames of pages superseded by later files when they are at most maxGap long. +const BackgroundRunBytes = 4 * 1024 * 1024; +const BackgroundGap = 256 * 1024; +const BackgroundConcurrency = 4; +const IndexConcurrency = 8; +const MissAttempts = 3; +const MaxRetryDelay = 30_000; + +export class PagedRestore { + readonly path: string; + readonly pageSize: number; + readonly pageCount: number; + /** Resolves once every page is present; rejects with {@link ErrPagedRestoreClosed} if closed before. */ + readonly filled: Promise; + + private readonly client: ReplicaClient; + private readonly plan: FileInfo[]; + private readonly marker: string; + private readonly logger: Logger; + private readonly readAhead: number; + private readonly onMiss: PagedRestoreStartOptions['onMiss']; + // For each page: the index in the plan of the file with its newest version + // (-1 if none has it), and the offset and size of its frame there. + private readonly fileOf: Int32Array; + private readonly offsetOf: Float64Array; + private readonly sizeOf: Uint32Array; + // Pages known to be present: filled here, or found present by the VFS. + private readonly present: Uint8Array; + // Reads in progress, by page. + private readonly pending = new Map>(); + // Pages requested by reads, being read. + private readonly requested = new Set(); + private db: PagedVfsDatabase | null = null; + private done = false; + private closed = false; + private resolveFilled!: () => void; + private rejectFilled!: (err: Error) => void; + + private constructor(opts: PagedRestoreStartOptions, pageSize: number, pageCount: number) { + this.path = opts.path; + this.pageSize = pageSize; + this.pageCount = pageCount; + this.client = opts.client; + this.plan = opts.plan; + this.marker = opts.marker; + this.logger = opts.logger; + this.readAhead = Math.max(1, opts.readAhead ?? 32); + this.onMiss = opts.onMiss; + this.fileOf = new Int32Array(pageCount + 1).fill(-1); + this.offsetOf = new Float64Array(pageCount + 1); + this.sizeOf = new Uint32Array(pageCount + 1); + this.present = new Uint8Array(pageCount + 1); + this.filled = new Promise((resolve, reject) => { + this.resolveFilled = resolve; + this.rejectFilled = reject; + }); + // Callers may never wait for it + this.filled.catch(() => {}); + } + + /** + * Start creates the database file with every page missing, registers it + * with the VFS, and fills page 1 and the pages of the schema. Requests for + * pages are served from then on; call {@link fill} to fill the others. + */ + static async start(opts: PagedRestoreStartOptions): Promise { + const { client, plan } = opts; + const [first, last] = await Promise.all([fetchLtxHeader(client, plan[0]), fetchLtxHeader(client, plan[plan.length - 1])]); + if (first.pageSize !== last.pageSize) { + throw new PageSizeMismatchError(`mismatched page sizes: ${first.pageSize} != ${last.pageSize}`); + } + + const restore = new PagedRestore(opts, first.pageSize, last.commit); + await restore.readPageIndexes(); + + // Created first, so that it outlives anything left of the database by a crash + await writeFile(opts.marker, `${plan[plan.length - 1].maxTXID}\n`); + for (const suffix of ['', '-wal', '-shm', '-wal.tmp']) { + await rm(`${opts.path}${suffix}`, { force: true }); + } + + restore.db = opts.vfs.register( + { path: opts.path, pageSize: restore.pageSize, pageCount: restore.pageCount, timeout: opts.timeout }, + (pgno) => restore.request(pgno), + ); + + try { + await restore.loadSchema(); + } catch (err) { + restore.close(); + throw err; + } + return restore; + } + + /** Missing pages, according to the VFS. */ + get missing(): number { + return this.done || this.db === null ? 0 : this.db.missing; + } + + /** Whether the restore is done: every page is present, or it was closed. */ + get finished(): boolean { + return this.done || this.closed; + } + + /** Whether `info` is a file the restore reads, until it is done. */ + reads(info: FileInfo): boolean { + if (this.finished) return false; + return this.plan.some((f) => f.level === info.level && f.minTXID === info.minTXID && f.maxTXID === info.maxTXID); + } + + /** + * Load makes a page present in the database file, before reading it from + * the file directly. Returns undefined if it is already present. + */ + load(pgno: number): Promise | undefined { + if (this.finished || pgno < 1 || pgno > this.pageCount || this.present[pgno] === 1) return undefined; + return this.pending.get(pgno) ?? this.readFor(pgno, false); + } + + /** + * Fill fills every missing page in the background, retrying failed reads, + * until the database is complete or the restore is closed. If the database + * cannot be completed, it closes the restore with the error. + */ + async fill(): Promise { + let delay = 1000; + + try { + while (!this.finished) { + let failed = false; + const runs = this.backgroundRuns(); + const worker = async (): Promise => { + for (let next = runs.next(); !next.done && !this.finished; next = runs.next()) { + try { + await this.track(next.value.pages, this.read(next.value)); + } catch (err) { + if (this.finished) return; + failed = true; + this.logger.warn('cannot read pages for a paged restore', { path: this.path, err }); + } + } + }; + await Promise.all(Array.from({ length: BackgroundConcurrency }, worker)); + + // The pass skipped the pages being read for a request + await Promise.allSettled([...this.pending.values()]); + this.finishIfComplete(); + if (this.finished) return; + + if (failed) { + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, MaxRetryDelay); + } else if (this.present.indexOf(0, 1) === -1) { + throw new DatabaseError(`a paged restore of ${this.path} filled every page, yet ${this.missing} are missing`); + } + } + } catch (err) { + this.close(err as Error); + } + } + + /** + * Close stops the restore, and rejects {@link filled} with `err`, or with + * {@link ErrPagedRestoreClosed}. The database stays incomplete, and reads of + * its missing pages fail. + */ + close(err?: Error): void { + if (this.finished) return; + this.closed = true; + this.db?.close(); + this.pending.clear(); + this.rejectFilled(err ?? new ErrPagedRestoreClosed(this.path)); + } + + /** Records the read of `pages` in progress, until it settles. */ + private track(pages: number[], read: Promise): Promise { + const promise = read.finally(() => { + for (const pgno of pages) { + if (this.pending.get(pgno) === promise) this.pending.delete(pgno); + } + }); + for (const pgno of pages) this.pending.set(pgno, promise); + return promise; + } + + /** Reads the page indexes of the plan, keeping the newest version of each page. */ + private async readPageIndexes(): Promise { + let next = 0; + const worker = async (): Promise => { + for (let i = next++; i < this.plan.length; i = next++) { + const data = await fetchPageIndexData(this.client, this.plan[i]); + parsePageIndex(new ByteReader(data, 0), null, (pgno, offset, size) => { + if (pgno <= this.pageCount && i > this.fileOf[pgno]) { + this.fileOf[pgno] = i; + this.offsetOf[pgno] = offset; + this.sizeOf[pgno] = size; + } + }); + } + }; + await Promise.all(Array.from({ length: Math.min(IndexConcurrency, this.plan.length) }, worker)); + } + + /** + * Fills the pages SQLite reads to open the database: page 1, and the pages + * of the schema table (sqlite_master), a b-tree rooted at page 1, with the + * overflow pages of its records. + */ + private async loadSchema(): Promise { + const page1 = await this.readFromFile(1); + const usable = this.pageSize - page1[20]; + let level: Array<{ pgno: number; overflow: boolean; data: Uint8Array }> = [{ pgno: 1, overflow: false, data: page1 }]; + const seen = new Set([1]); + + while (level.length > 0) { + const children: Array<{ pgno: number; overflow: boolean }> = []; + for (const page of level) { + const next = page.overflow ? overflowNext(page.data) : schemaChildren(page.data, page.pgno, usable); + for (const child of next) { + if (child.pgno < 1 || child.pgno > this.pageCount || seen.has(child.pgno)) { + throw new DatabaseError(`corrupt schema: page ${page.pgno} refers to page ${child.pgno}`); + } + seen.add(child.pgno); + children.push(child); + } + } + level = await Promise.all(children.map(async (c) => ({ ...c, data: await this.readFromFile(c.pgno) }))); + } + } + + /** Makes a page present, and returns its content. */ + private async readFromFile(pgno: number): Promise { + await this.load(pgno); + const page = new Uint8Array(this.pageSize); + const fh = await open(this.path, 'r'); + try { + const { bytesRead } = await fh.read(page, 0, this.pageSize, (pgno - 1) * this.pageSize); + if (bytesRead !== this.pageSize) throw new DatabaseError(`cannot read page ${pgno} of ${this.path}`); + } finally { + await fh.close(); + } + return page; + } + + /** Serves a request of the VFS for a page, on the thread which registered the database. */ + private request(pgno: number): void { + if (this.finished || this.requested.has(pgno)) return; + // A page being read by the background fill is read again, in a smaller read + this.readFor(pgno, true).catch(() => {}); + } + + /** + * Reads a page with the next ones of the same file, retrying a few times. + * A page requested by the VFS is failed if it cannot be read, so that the + * read waiting for it fails instead of waiting until it times out. + */ + private readFor(pgno: number, requested: boolean): Promise { + const run = this.missRun(pgno); + const start = performance.now(); + if (requested) this.requested.add(pgno); + + const read = (async () => { + for (let attempt = 1; ; attempt++) { + try { + if (run === null) { + this.fillPage(pgno, new Uint8Array(this.pageSize)); + } else { + await this.read(run); + } + if (requested) this.onMiss?.(pgno, run?.pages.length ?? 1, performance.now() - start); + return; + } catch (err) { + if (this.closed) throw new ErrPagedRestoreClosed(this.path); + if (attempt >= MissAttempts) { + this.logger.warn('cannot read a page for a paged restore', { path: this.path, pgno, err }); + if (requested) this.db?.fail(pgno); + throw err; + } + await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); + } + } + })().finally(() => { + if (requested) this.requested.delete(pgno); + }); + + return this.track(run?.pages ?? [pgno], read); + } + + /** + * The read of a page and of the next pages of the same file, up to the + * read-ahead, or null if no file has the page. The frames of the pages + * already present are read too, but they are not filled. + */ + private missRun(pgno: number): Run | null { + const file = this.fileOf[pgno]; + if (file < 0) return null; + + const run: Run = { file, start: this.offsetOf[pgno], end: this.offsetOf[pgno] + this.sizeOf[pgno], pages: [pgno] }; + let end = run.end; + for (let p = pgno + 1, frames = 1; p <= this.pageCount && frames < this.readAhead; p++, frames++) { + if (this.fileOf[p] !== file || this.offsetOf[p] !== end) break; + end += this.sizeOf[p]; + if (this.present[p] === 0) { + run.pages.push(p); + run.end = end; + } + } + return run; + } + + /** + * Yields the reads of the background fill, in page order: for each file, + * ranges of the frames of the pages it has the newest version of, which are + * neither present nor being read. Pages no file has are filled with zeros, + * as a full restore leaves them. + */ + private *backgroundRuns(): Generator { + const open = new Map(); + + for (let pgno = 1; pgno <= this.pageCount && !this.finished; pgno++) { + if (this.present[pgno] === 1 || this.pending.has(pgno)) continue; + + const file = this.fileOf[pgno]; + if (file < 0) { + this.fillPage(pgno, new Uint8Array(this.pageSize)); + continue; + } + + const offset = this.offsetOf[pgno]; + const end = offset + this.sizeOf[pgno]; + const run = open.get(file); + if (run !== undefined && offset >= run.end && offset - run.end <= BackgroundGap && end - run.start <= BackgroundRunBytes) { + run.pages.push(pgno); + run.end = end; + continue; + } + if (run !== undefined) yield run; + open.set(file, { file, start: offset, end, pages: [pgno] }); + } + + yield* open.values(); + } + + /** Reads a run, and fills its pages. */ + private async read(run: Run): Promise { + const info = this.plan[run.file]; + const stream = await this.client.openLtxFile({ + level: info.level, + minTXID: info.minTXID, + maxTXID: info.maxTXID, + offset: run.start, + size: run.end - run.start, + }); + const bytes = await readAll(stream); + if (bytes.length !== run.end - run.start) { + throw new LTXFormatError(`short read of ltx file ${info.minTXID}-${info.maxTXID}: ${bytes.length} of ${run.end - run.start} bytes`); + } + + // Filling copies the page, so one buffer serves the whole run + const page = new Uint8Array(this.pageSize); + for (const pgno of run.pages) { + if (this.finished) return; + if (this.present[pgno] === 1) continue; + const offset = this.offsetOf[pgno] - run.start; + const { header, data } = decodePageData(bytes.subarray(offset, offset + this.sizeOf[pgno]), page); + if (header.pgno !== pgno) { + throw new LTXFormatError(`unexpected page ${header.pgno} at the offset of page ${pgno}`); + } + if (data.length !== this.pageSize) { + throw new PageSizeMismatchError(`page ${pgno} has ${data.length} bytes instead of ${this.pageSize}`); + } + this.fillPage(pgno, data); + } + } + + private fillPage(pgno: number, data: Uint8Array): void { + if (this.finished || this.db === null) return; + this.db.fill(pgno, data); + this.present[pgno] = 1; + this.finishIfComplete(); + } + + private finishIfComplete(): void { + if (this.finished || this.db === null || !this.db.complete) return; + this.done = true; + this.pending.clear(); + rm(this.marker, { force: true }).then( + () => this.resolveFilled(), + (err) => this.rejectFilled(err), + ); + } +} + +/** + * The pages a page of the schema table refers to: the children of an + * interior page, or the first overflow page of each record of a leaf page. + * See https://www.sqlite.org/fileformat2.html#b_tree_pages. + */ +function schemaChildren(page: Uint8Array, pgno: number, usable: number): Array<{ pgno: number; overflow: boolean }> { + const header = pgno === 1 ? 100 : 0; + const view = new DataView(page.buffer, page.byteOffset, page.byteLength); + const cells = view.getUint16(header + 3); + const out: Array<{ pgno: number; overflow: boolean }> = []; + + switch (page[header]) { + case 0x05: // Interior table page: a left child per cell, then the right-most child + for (let i = 0; i < cells; i++) { + out.push({ pgno: view.getUint32(view.getUint16(header + 12 + 2 * i)), overflow: false }); + } + out.push({ pgno: view.getUint32(header + 8), overflow: false }); + return out; + case 0x0d: { + // Leaf table page: the payload of a record which does not fit continues in overflow pages + const maxLocal = usable - 35; + const minLocal = Math.floor(((usable - 12) * 32) / 255) - 23; + for (let i = 0; i < cells; i++) { + let offset = view.getUint16(header + 8 + 2 * i); + const [payload, n] = readVarint(page, offset); + offset += n; + offset += readVarint(page, offset)[1]; // rowid + if (payload > maxLocal) { + const k = minLocal + ((payload - minLocal) % (usable - 4)); + const local = k <= maxLocal ? k : minLocal; + out.push({ pgno: view.getUint32(offset + local), overflow: true }); + } + } + return out; + } + default: + throw new DatabaseError(`corrupt schema: page ${pgno} is not a table b-tree page`); + } +} + +/** The next page of an overflow chain, if any. */ +function overflowNext(page: Uint8Array): Array<{ pgno: number; overflow: boolean }> { + const next = new DataView(page.buffer, page.byteOffset, page.byteLength).getUint32(0); + return next === 0 ? [] : [{ pgno: next, overflow: true }]; +} + +/** Reads a SQLite varint: returns its value and its length. */ +function readVarint(b: Uint8Array, offset: number): [number, number] { + let value = 0; + for (let i = 0; i < 8; i++) { + const byte = b[offset + i]; + value = value * 128 + (byte & 0x7f); + if (byte < 0x80) return [value, i + 1]; + } + return [value * 256 + b[offset + 8], 9]; +} diff --git a/src/replica.ts b/src/replica.ts index fef5ae4..9e825de 100644 --- a/src/replica.ts +++ b/src/replica.ts @@ -19,6 +19,7 @@ import { createReadStream } from 'node:fs'; import { access, mkdir, open, readFile, rename, rm, stat, type FileHandle } from 'node:fs/promises'; import nodePath from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { setTimeout as sleep } from 'node:timers/promises'; import { @@ -58,12 +59,14 @@ import { noopLogger } from './util/logger.ts'; import { DatabaseError, ErrGenerationSuperseded, + ErrPagedVfsUnavailable, ErrReplicaAhead, InvalidArgumentError, LTXFormatError, PageSizeMismatchError, SqliteJsStreamError, } from './errors.ts'; +import { loadPagedVfs, PagedRestore, type PagedVfs } from './paged-restore.ts'; export interface RestoreOptions { outputPath: string; @@ -71,6 +74,46 @@ export interface RestoreOptions { timestamp?: Date; } +/** Options for {@link Replica.restorePaged}. */ +export interface PagedRestoreOptions { + /** Restores this transaction instead of the latest. */ + txID?: bigint; + /** Restores the latest backup created at or before this time instead of the latest. */ + timestamp?: Date; + /** + * Restores fully, as {@link Replica.restore} does, when the LTX files to + * read add up to less than this, in bytes. Defaults to 0. + */ + minSize?: number; + /** Pages read with each page a query waits for. Defaults to 32. */ + readAhead?: number; + /** How long a query waits for a missing page before failing, in milliseconds. Defaults to 30 seconds. */ + timeout?: number; + /** + * Called when a page a query waited for is filled, with the number of pages + * read with it and the time the read took. + */ + onMiss?: (pgno: number, pages: number, durationMs: number) => void; +} + +/** The outcome of {@link Replica.restorePaged}. */ +export interface PagedRestoreResult { + /** Whether the database is restored page by page; false if it was restored fully. */ + paged: boolean; + /** Why the database was restored fully although large enough: the paged VFS cannot be used. */ + unavailable: ErrPagedVfsUnavailable | null; + /** The restored TXID. */ + txID: bigint; + /** Total size of the LTX files of the restore, in bytes. */ + size: number; + /** + * Resolves once every page is in the local file. Rejects with + * `ErrPagedRestoreClosed` if the replica is closed or restored again + * before. Already resolved after a full restore. + */ + filled: Promise; +} + /** Options for {@link Replica.follow}, litestream's `restore -f` continuous mode. */ export interface FollowOptions { /** Output database path to continuously restore into. */ @@ -311,6 +354,9 @@ export class Replica { private runActive = false; private openReader: OpenReader | undefined; private supersededError: ErrGenerationSuperseded | null = null; + // The paged restore filling the database, if any, and the paged VFS once loaded + private paged: PagedRestore | null = null; + private pagedVfs: PagedVfs | null = null; constructor( path: string, @@ -407,10 +453,20 @@ export class Replica { this.compactionLevelsValue = compactionLevelsForMax(level); } - /** Lazily opens the local database reader (cached). */ + /** + * Lazily opens the local database reader (cached). While a paged restore + * fills the database, its pages are filled before they are read, and the + * default reader connection opens it through the paged VFS: a read of a + * missing page then fails, where it would return zeros otherwise. + */ private async localDb(): Promise { if (this.db === null) { - this.db = await LocalDatabase.open(this.path, this.openReader); + const vfs = this.paged !== null ? this.pagedVfs : null; + const openReader: OpenReader | undefined = + this.openReader ?? (vfs !== null ? (path) => new DatabaseSync(vfs.databaseUri(path), { readOnly: true }) : undefined); + this.db = await LocalDatabase.open(this.path, openReader); + const paged = this.paged; + if (paged !== null) this.db.setPageLoader((pgno) => paged.load(pgno)); } return this.db; } @@ -438,9 +494,16 @@ export class Replica { return max; } - /** LocalPos returns the local cached position (max local level-0 maxTXID). */ + /** + * LocalPos returns the local cached position (max local level-0 maxTXID). + * It is 0 when a paged restore of the database did not finish, and no paged + * restore of this replica is filling it: the database is incomplete, and + * must be restored again. + */ async localPos(): Promise { - return (await this.localDb()).pos(); + const db = await this.localDb(); + if (this.paged === null && (await db.incomplete())) return 0n; + return db.pos(); } /** @@ -782,11 +845,15 @@ export class Replica { return info; } - /** EnsureExists restores the local database if it is missing. */ + /** + * EnsureExists restores the local database if it is missing, or incomplete + * because a paged restore of it did not finish. + */ async ensureExists(): Promise { + if (this.paged !== null) return; try { await access(this.path); - return; + if (!(await (await this.localDb()).incomplete())) return; } catch { /* missing; restore below */ } @@ -802,22 +869,123 @@ export class Replica { */ async restore(opt: RestoreOptions): Promise { if (!opt.outputPath) throw new InvalidArgumentError('output path required'); + const { txID, timestampMs } = restoreTarget(opt); + const plan = await calcRestorePlan(this.client, txID, timestampMs); + if (plan.length === 0) throw new NoMatchingBackupError(); + await this.restorePlan(plan, opt.outputPath); + } + + /** + * RestorePaged restores the replica's own database page by page, on demand, + * with the SQLite VFS of `@platformatic/sqlite-js-stream-paged-vfs`. It + * resolves once the database can be opened, long before it is downloaded. + * + * Open the database with the VFS, with the URI returned by `databaseUri()` + * of that package, from another thread than the one which called this + * method: a query that reads a page not downloaded yet waits until it is + * downloaded, on demand, while the other pages are downloaded in the + * background. The thread which called this method serves those requests, + * so it must never query the database itself while it is incomplete. Every + * connection that writes to the database must use the VFS. + * + * Replicating goes on as usual meanwhile: {@link sync} and {@link snapshot} + * download the pages they read first, and retention keeps the files the + * restore still reads. `filled` resolves once every page is in the file. + * Closing the replica, or restoring it again, stops the restore and leaves + * the database incomplete: {@link localPos} is then 0, and {@link sync} + * fails with `ErrIncompleteDatabase`, until it is restored again. The same + * holds after a crash during the restore. + * + * It restores fully, as {@link restore} does, when the files to read are + * smaller than `minSize`, or when the paged VFS cannot be used (it is not + * installed, or cannot load on this platform). The result tells which. + */ + async restorePaged(opt: PagedRestoreOptions = {}): Promise { + const { txID, timestampMs } = restoreTarget(opt); + const plan = await calcRestorePlan(this.client, txID, timestampMs); + if (plan.length === 0) throw new NoMatchingBackupError(); + const size = plan.reduce((total, info) => total + info.size, 0); + const restored = plan[plan.length - 1].maxTXID; + const minSize = opt.minSize ?? 0; + + let unavailable: ErrPagedVfsUnavailable | null = null; + if (size >= minSize && this.pagedVfs === null) { + try { + this.pagedVfs = await loadPagedVfs(); + } catch (err) { + if (!(err instanceof ErrPagedVfsUnavailable)) throw err; + unavailable = err; + this.logger.warn('cannot restore page by page, restoring fully', { path: this.path, err }); + } + } - const txID = opt.txID ?? 0n; - if (txID < 0n) throw new InvalidArgumentError('txID must be >= 0'); - const timestampMs = opt.timestamp?.getTime(); - if (timestampMs !== undefined && Number.isNaN(timestampMs)) { - throw new InvalidArgumentError('invalid timestamp'); + const vfs = this.pagedVfs; + if (size < minSize || vfs === null) { + await this.restorePlan(plan, this.path); + return { paged: false, unavailable, txID: restored, size, filled: Promise.resolve() }; } - if (txID !== 0n && timestampMs !== undefined) { - throw new InvalidArgumentError('cannot restore by both txID and timestamp'); + + this.stopPaged(); + this.closeLocal(); + const marker = (await this.localDb()).pagedPath(); + this.closeLocal(); + await mkdir(nodePath.dirname(this.path), { recursive: true }); + + const paged = await PagedRestore.start({ + vfs, + client: this.client, + plan, + path: this.path, + marker, + logger: this.logger, + readAhead: opt.readAhead, + timeout: opt.timeout, + onMiss: opt.onMiss, + }); + + try { + const wal = await writeRestoredWAL(this.path, paged.pageSize, paged.pageCount); + // Set first, so that the local database reads through it + this.paged = paged; + await this.resetLocal(restored, wal); + } catch (err) { + this.stopPaged(); + paged.close(); + throw err; } - const plan = await calcRestorePlan(this.client, txID, timestampMs); - if (plan.length === 0) throw new NoMatchingBackupError(); + const settle = (): void => { + if (this.paged !== paged) return; + this.paged = null; + this.db?.setPageLoader(null); + }; + paged.filled.then(settle, (err) => { + settle(); + if ((err as { code?: string }).code !== 'ERR_PAGED_RESTORE_CLOSED') { + this.logger.error('a paged restore failed; the database is incomplete', { path: this.path, err }); + } + }); + void paged.fill(); + + return { paged: true, unavailable: null, txID: restored, size, filled: paged.filled }; + } + + /** Stops the paged restore filling the database, if any. */ + private stopPaged(): void { + const paged = this.paged; + if (paged === null) return; + this.paged = null; + this.db?.setPageLoader(null); + paged.close(); + } + + /** Restores the files of `plan` into `outputPath`. */ + private async restorePlan(plan: FileInfo[], outputPath: string): Promise { + const self = nodePath.resolve(outputPath) === nodePath.resolve(this.path); + if (self) this.stopPaged(); - await mkdir(nodePath.dirname(opt.outputPath), { recursive: true }); - const tmp = `${opt.outputPath}.tmp`; + await mkdir(nodePath.dirname(outputPath), { recursive: true }); + const tmp = `${outputPath}.tmp`; let fh: FileHandle | null = null; const sources: Array<{ destroy?: () => void }> = []; @@ -857,16 +1025,18 @@ export class Replica { // A WAL left by a previous database at this path would be replayed on top // of the restored one. - await rm(`${opt.outputPath}-wal`, { force: true }); - await rm(`${opt.outputPath}-shm`, { force: true }); - await rename(tmp, opt.outputPath); + await rm(`${outputPath}-wal`, { force: true }); + await rm(`${outputPath}-shm`, { force: true }); + await rename(tmp, outputPath); // Restoring the replicated database itself: the local cache belongs to the // previous database, and the next sync continues after the restored TXID, // incrementally from the WAL written here while it is the current one. - if (nodePath.resolve(opt.outputPath) === nodePath.resolve(this.path)) { - const wal = await writeRestoredWAL(opt.outputPath, pageSize, commit); + // The database is complete, even if a paged restore of it did not finish. + if (self) { + const wal = await writeRestoredWAL(outputPath, pageSize, commit); await this.resetLocal(plan[plan.length - 1].maxTXID, wal); + await rm((await this.localDb()).pagedPath(), { force: true }); } } catch (err) { if (fh !== null) await fh.close().catch(() => {}); @@ -1114,7 +1284,7 @@ export class Replica { * the database otherwise. */ private async resetLocal(txID: bigint, wal: RestoredWAL | null): Promise { - this.close(); + this.closeLocal(); const db = await this.localDb(); await rm(db.ltxDir(), { recursive: true, force: true }); await mkdir(db.ltxLevelDir(0), { recursive: true }); @@ -1122,8 +1292,17 @@ export class Replica { this.posValue = null; } - /** Close releases any internal database connection. */ + /** + * Close releases any internal database connection, and stops the paged + * restore filling the database, if any: the database stays incomplete. + */ close(): void { + this.stopPaged(); + this.closeLocal(); + } + + /** Closes the local database reader. */ + private closeLocal(): void { if (this.db !== null) { this.db.close(); this.db = null; @@ -1336,8 +1515,7 @@ export class Replica { if (retain > 0 && retained.length > retain) retained = retained.slice(retained.length - retain); const retainedSet = new Set(retained); - const deletedFiles = snapshots.filter((s) => !retainedSet.has(s)); - await this.deleteFiles(deletedFiles); + const deletedFiles = await this.deleteFiles(snapshots.filter((s) => !retainedSet.has(s))); let boundary = 0n; for (const d of deletedFiles) if (d.maxTXID > boundary) boundary = d.maxTXID; @@ -1360,8 +1538,7 @@ export class Replica { if (deletedFiles.length > 0 && deletedFiles[deletedFiles.length - 1] === last) { deletedFiles.pop(); // always keep at least one file } - await this.deleteFiles(deletedFiles); - return deletedFiles.length; + return (await this.deleteFiles(deletedFiles)).length; } /** @@ -1391,8 +1568,7 @@ export class Replica { if (info.createdAt > threshold) break; // preserve contiguous L0 coverage if (this.isCoveredByHigherLevel(info, coverers)) deletedFiles.push(info); } - await this.deleteFiles(deletedFiles); - return deletedFiles.length; + return (await this.deleteFiles(deletedFiles)).length; } /** Returns true if `info` is fully contained in a file at a higher level. */ @@ -1413,9 +1589,17 @@ export class Replica { return out; } - /** Deletes files from the client and the local cache. */ - private async deleteFiles(files: FileInfo[]): Promise { - if (files.length === 0) return; + /** + * Deletes files from the client and the local cache, except those a paged + * restore still reads. Returns the deleted files. + */ + private async deleteFiles(candidates: FileInfo[]): Promise { + const paged = this.paged; + const files = paged === null ? candidates : candidates.filter((f) => !paged.reads(f)); + if (files.length < candidates.length) { + this.logger.debug('keeping ltx files a paged restore reads', { count: candidates.length - files.length }); + } + if (files.length === 0) return files; this.logger.debug('deleting ltx files', { count: files.length }); try { await this.client.deleteLtxFiles(files); @@ -1428,6 +1612,7 @@ export class Replica { const p = db.ltxPath(f.level, f.minTXID, f.maxTXID); await rm(p, { force: true }).catch(() => {}); } + return files; } /** @@ -1704,3 +1889,17 @@ async function readDbPageSize(path: string): Promise { await fh.close(); } } + +/** Validates the restore target of `opt`: a TXID (0 for the latest) or a timestamp. */ +function restoreTarget(opt: { txID?: bigint; timestamp?: Date }): { txID: bigint; timestampMs: number | undefined } { + const txID = opt.txID ?? 0n; + if (txID < 0n) throw new InvalidArgumentError('txID must be >= 0'); + const timestampMs = opt.timestamp?.getTime(); + if (timestampMs !== undefined && Number.isNaN(timestampMs)) { + throw new InvalidArgumentError('invalid timestamp'); + } + if (txID !== 0n && timestampMs !== undefined) { + throw new InvalidArgumentError('cannot restore by both txID and timestamp'); + } + return { txID, timestampMs }; +} diff --git a/test/helpers/paged-restore-child.ts b/test/helpers/paged-restore-child.ts new file mode 100644 index 0000000..0f41107 --- /dev/null +++ b/test/helpers/paged-restore-child.ts @@ -0,0 +1,23 @@ +/** + * Test helper: starts a paged restore of `argv[2]` from the file replica at + * `argv[3]`, whose background fill never gets its pages, prints "ready" and + * waits to be killed. + */ + +import { FileReplicaClient, Replica, type OpenLTXFileOptions } from '../../src/index.ts'; + +const backing = new FileReplicaClient(process.argv[3]); +const client = new Proxy(backing, { + get(target, prop) { + if (prop === 'openLtxFile') { + return (opts: OpenLTXFileOptions) => (opts.size >= 256 * 1024 ? new Promise(() => {}) : target.openLtxFile(opts)); + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, +}); + +const replica = new Replica(process.argv[2], client); +await replica.restorePaged(); +process.stdout.write('ready\n'); +setInterval(() => {}, 1000); diff --git a/test/helpers/sql-worker.ts b/test/helpers/sql-worker.ts new file mode 100644 index 0000000..1004217 --- /dev/null +++ b/test/helpers/sql-worker.ts @@ -0,0 +1,40 @@ +/** + * Test helper: runs SQL on a database opened with `workerData.uri`, in a + * worker thread. A paged restore serves the pages its reads wait for from the + * thread which started it, so its database is queried from another thread. + * + * Messages: `{ id, sql, params }` replies `{ id, rows }`; `{ id, exec }` runs + * statements and replies `{ id }`; `{ id, close: true }` closes the database. + * Failures reply `{ id, error }`. + */ + +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; +import { parentPort, workerData } from 'node:worker_threads'; + +const db = new DatabaseSync(workerData.uri as string); +db.exec('PRAGMA busy_timeout = 5000'); +if (workerData.writer) db.exec('PRAGMA wal_autocheckpoint = 0'); + +interface Message { + id: number; + sql?: string; + params?: SQLInputValue[]; + exec?: string; + close?: boolean; +} + +parentPort!.on('message', ({ id, sql, params = [], exec, close }: Message) => { + try { + if (close) { + db.close(); + parentPort!.postMessage({ id }); + } else if (exec !== undefined) { + db.exec(exec); + parentPort!.postMessage({ id }); + } else { + parentPort!.postMessage({ id, rows: db.prepare(sql!).all(...params) }); + } + } catch (err) { + parentPort!.postMessage({ id, error: (err as Error).message }); + } +}); diff --git a/test/integration/paged-restore.test.ts b/test/integration/paged-restore.test.ts new file mode 100644 index 0000000..30d5f3e --- /dev/null +++ b/test/integration/paged-restore.test.ts @@ -0,0 +1,338 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { Worker } from 'node:worker_threads'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + Decoder, + ErrIncompleteDatabase, + FileReplicaClient, + Replica, + WALFrameHeaderSize, + WALHeaderSize, + type FileInfo, + type OpenLTXFileOptions, + type ReplicaClient, +} from '../../src/index.ts'; +import { LocalDatabase } from '../../src/db.ts'; +import { loadPagedVfs, type PagedVfs } from '../../src/paged-restore.ts'; +import { makeTempDir } from '../helpers/make-db.ts'; + +/** + * A paged restore makes the database usable right away: queries from other + * threads wait for the pages they read, while the others are filled in the + * background, and replication goes on meanwhile. + */ + +let vfs: PagedVfs | null = null; +try { + vfs = await loadPagedVfs(); +} catch { + // Build it with pnpm --filter @platformatic/sqlite-js-stream-paged-vfs build:native build +} +const skip = vfs === null ? 'the paged VFS is not built' : false; + +const Rows = 2000; +const Tables = 80; + +/** + * A database of about 2,000 pages, whose schema spans several pages and has + * records larger than a page (so overflow pages), replicated as a level-9 + * snapshot followed by level-0 files which change some of its pages. + */ +async function replicate(t: TestContext, client: ReplicaClient): Promise { + const path = join(makeTempDir('paged-src-'), 'src.db'); + const src = new DatabaseSync(path); + t.after(() => src.close()); + src.exec('PRAGMA journal_mode=WAL'); + src.exec('PRAGMA wal_autocheckpoint=0'); + src.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v BLOB)'); + src.exec('BEGIN'); + for (let i = 0; i < Tables; i++) src.exec(`CREATE TABLE extra_${i} (a INTEGER, b TEXT, c BLOB, d REAL)`); + const columns = Array.from({ length: 400 }, (_, i) => `column_with_a_long_name_${i} TEXT`).join(', '); + src.exec(`CREATE TABLE wide (${columns})`); + src.exec(`WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${Rows}) INSERT INTO t SELECT i, randomblob(3000) FROM n`); + src.exec('COMMIT'); + + const replica = new Replica(path, client); + t.after(() => replica.close()); + await replica.sync(); + for (let i = 0; i < 3; i++) { + src.exec(`UPDATE t SET v = randomblob(3000) WHERE id % 97 = ${i}`); + await replica.sync(); + } + await replica.snapshot(); + for (let i = 3; i < 5; i++) { + src.exec(`UPDATE t SET v = randomblob(3000) WHERE id % 97 = ${i}`); + src.exec(`INSERT INTO t (v) VALUES (randomblob(3000))`); + await replica.sync(); + } + return src; +} + +/** + * A client whose ranged reads of at least `minSize` bytes (those of the + * background fill, not those for the pages queries wait for) wait for + * `release()`. + */ +function gated(client: ReplicaClient, minSize = 256 * 1024): { client: ReplicaClient; release: () => void; reads: OpenLTXFileOptions[] } { + let release!: () => void; + const open = new Promise((resolve) => { + release = resolve; + }); + const reads: OpenLTXFileOptions[] = []; + const proxy = new Proxy(client, { + get(target, prop) { + if (prop === 'openLtxFile') { + return async (opts: OpenLTXFileOptions) => { + reads.push(opts); + if (opts.size >= minSize) await open; + return target.openLtxFile(opts); + }; + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return { client: proxy, release, reads }; +} + +interface SqlWorker { + all(sql: string, ...params: unknown[]): Promise>>; + exec(sql: string): Promise; + close(): Promise; +} + +function sqlWorker(t: TestContext, path: string, writer = false): SqlWorker { + const worker = new Worker(new URL('../helpers/sql-worker.ts', import.meta.url), { + workerData: { uri: vfs!.databaseUri(path), writer }, + }); + t.after(() => worker.terminate()); + let next = 0; + const call = (message: object): Promise<{ rows?: Array> }> => { + const id = next++; + return new Promise((resolve, reject) => { + const onMessage = (m: { id: number; rows?: Array>; error?: string }) => { + if (m.id !== id) return; + worker.off('message', onMessage); + if (m.error !== undefined) reject(new Error(m.error)); + else resolve(m); + }; + worker.on('message', onMessage); + worker.postMessage({ id, ...message }); + }); + }; + return { + all: async (sql, ...params) => (await call({ sql, params })).rows!, + exec: async (exec) => { + await call({ exec }); + }, + close: async () => { + await call({ close: true }); + }, + }; +} + +/** Rows as plain objects, as they come from a worker. */ +function plain(rows: unknown[]): Array> { + return rows.map((row) => ({ ...(row as Record) })); +} + +async function exists(client: ReplicaClient, info: FileInfo): Promise { + for await (const f of client.ltxFiles({ level: info.level, seek: 0n, useMetadata: false })) { + if (f.minTXID === info.minTXID && f.maxTXID === info.maxTXID) return true; + } + return false; +} + +function integrity(path: string): string { + const db = new DatabaseSync(path, { readOnly: true }); + try { + return (db.prepare('PRAGMA integrity_check').get() as { integrity_check: string }).integrity_check; + } finally { + db.close(); + } +} + +test('a paged restore serves queries before the database is downloaded', { skip }, async (t) => { + const backing = new FileReplicaClient(makeTempDir('paged-repl-')); + const src = await replicate(t, backing); + const { client, release, reads } = gated(backing); + + const path = join(makeTempDir('paged-dst-'), 'db.db'); + const replica = new Replica(path, client); + t.after(() => replica.close()); + const misses: number[] = []; + const result = await replica.restorePaged({ readAhead: 8, onMiss: (pgno) => misses.push(pgno) }); + assert.equal(result.paged, true); + assert.equal(result.unavailable, null); + assert.equal(result.txID, 6n); + assert.ok(existsSync(join(`${path}-litestream`, 'paged')), 'the marker exists while the database is incomplete'); + + // The background fill is held, yet queries are answered + const db = sqlWorker(t, path); + const want = plain(src.prepare('SELECT id, v FROM t WHERE id IN (1, 777, 1500, 2001, 2002) ORDER BY id').all()); + assert.deepEqual(await db.all('SELECT id, v FROM t WHERE id IN (1, 777, 1500, 2001, 2002) ORDER BY id'), want); + assert.deepEqual( + await db.all("SELECT count(*) AS n FROM sqlite_master WHERE type = 'table'"), + [{ n: Tables + 2 }], + ); + assert.ok(misses.length > 0, 'queries waited for pages'); + assert.ok(reads.some((r) => r.size >= 256 * 1024), 'the background fill is waiting'); + + release(); + await result.filled; + assert.equal(existsSync(join(`${path}-litestream`, 'paged')), false, 'the marker is removed once complete'); + assert.deepEqual( + await db.all('SELECT count(*) AS n, sum(length(v)) AS size FROM t'), + plain(src.prepare('SELECT count(*) AS n, sum(length(v)) AS size FROM t').all()), + ); + await db.close(); + + // Page for page, the database is the one a full restore writes + const out = join(makeTempDir('paged-full-'), 'db.db'); + await new Replica(join(makeTempDir('paged-other-'), 'x.db'), backing).restore({ outputPath: out }); + assert.deepEqual(await readFile(path), await readFile(out)); + assert.equal(integrity(path), 'ok'); +}); + +test('replication goes on while a paged restore fills the database', { skip }, async (t) => { + const backing = new FileReplicaClient(makeTempDir('paged-repl-')); + const src = await replicate(t, backing); + const { client, release } = gated(backing); + + const path = join(makeTempDir('paged-dst-'), 'db.db'); + const replica = new Replica(path, client); + t.after(() => replica.close()); + const result = await replica.restorePaged(); + + const db = sqlWorker(t, path, true); + await db.exec("UPDATE t SET v = x'00' WHERE id = 10; INSERT INTO t (v) VALUES (x'01')"); + + // The first sync is incremental, from the WAL the restore wrote + const info = await replica.sync(); + assert.equal(info?.maxTXID, 7n); + const local = await LocalDatabase.open(path); + t.after(() => local.close()); + const dec = new Decoder(new Uint8Array(await readFile(local.ltxPath(0, 7n, 7n)))); + dec.decodeHeader(); + assert.equal(dec.Header().walOffset, BigInt(WALHeaderSize + WALFrameHeaderSize + 4096)); + + // Retention keeps the files the restore reads (the snapshot at TXID 4 and + // the level-0 files after it), until it is done + const l0 = (txid: bigint): FileInfo => ({ level: 0, minTXID: txid, maxTXID: txid, size: 0, createdAt: 0 }); + assert.equal(await replica.enforceRetentionByTXID(0, 7n), 4); + assert.equal(await exists(backing, l0(1n)), false); + assert.equal(await exists(backing, l0(5n)), true); + + // A snapshot reads every page: the missing ones are read first, or when the + // background fill, held until now, reads them + const snapshot = replica.snapshot(); + release(); + assert.equal((await snapshot)?.maxTXID, 7n); + + await result.filled; + assert.equal(await replica.enforceRetentionByTXID(0, 7n), 2); + assert.equal(await exists(backing, l0(5n)), false, 'deleted once the restore is done'); + + // The replica has the changes made during the restore + const out = join(makeTempDir('paged-check-'), 'db.db'); + await new Replica(join(makeTempDir('paged-other-'), 'x.db'), backing).restore({ outputPath: out }); + const check = new DatabaseSync(out, { readOnly: true }); + t.after(() => check.close()); + assert.deepEqual(plain(check.prepare('SELECT v FROM t WHERE id = 10').all()), [{ v: new Uint8Array([0]) }]); + const before = (src.prepare('SELECT count(*) AS n FROM t').get() as { n: number }).n; + assert.deepEqual(plain(check.prepare('SELECT count(*) AS n FROM t').all()), [{ n: before + 1 }]); + await db.close(); +}); + +test('closing the replica stops a paged restore, and the database is restored again', { skip }, async (t) => { + const backing = new FileReplicaClient(makeTempDir('paged-repl-')); + await replicate(t, backing); + const { client } = gated(backing); + + const path = join(makeTempDir('paged-dst-'), 'db.db'); + const replica = new Replica(path, client); + const result = await replica.restorePaged(); + replica.close(); + await assert.rejects(result.filled, { code: 'ERR_PAGED_RESTORE_CLOSED' }); + + // As after a crash: the database is incomplete + const next = new Replica(path, backing); + t.after(() => next.close()); + assert.equal(await next.localPos(), 0n); + await assert.rejects(next.sync(), ErrIncompleteDatabase); + await assert.rejects(next.snapshot(), ErrIncompleteDatabase); + + await next.ensureExists(); + assert.equal(await next.localPos(), 6n); + assert.equal(existsSync(join(`${path}-litestream`, 'paged')), false); + assert.equal(integrity(path), 'ok'); +}); + +test('a database left incomplete by a crash during a paged restore is restored again', { skip }, async (t) => { + const dir = makeTempDir('paged-repl-'); + const backing = new FileReplicaClient(dir); + await replicate(t, backing); + + const path = join(makeTempDir('paged-dst-'), 'db.db'); + const child = spawn(process.execPath, [new URL('../helpers/paged-restore-child.ts', import.meta.url).pathname, path, dir], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + t.after(() => child.kill('SIGKILL')); + const [ready] = await once(child.stdout!, 'data'); + assert.equal(String(ready), 'ready\n'); + child.kill('SIGKILL'); + await once(child, 'exit'); + + const replica = new Replica(path, backing); + t.after(() => replica.close()); + assert.equal(await replica.localPos(), 0n); + await assert.rejects(replica.sync(), ErrIncompleteDatabase); + + // A paged restore again, this time to the end + const result = await replica.restorePaged(); + await result.filled; + assert.equal(await replica.localPos(), 6n); + assert.equal(integrity(path), 'ok'); +}); + +test('a paged restore restores fully a database smaller than minSize', { skip }, async (t) => { + const backing = new FileReplicaClient(makeTempDir('paged-repl-')); + await replicate(t, backing); + + const path = join(makeTempDir('paged-dst-'), 'db.db'); + const replica = new Replica(path, backing); + t.after(() => replica.close()); + const result = await replica.restorePaged({ minSize: Number.MAX_SAFE_INTEGER }); + assert.equal(result.paged, false); + assert.equal(result.unavailable, null); + assert.ok(result.size > 0); + await result.filled; + assert.equal(existsSync(join(`${path}-litestream`, 'paged')), false); + assert.equal(await replica.localPos(), 6n); + assert.equal(integrity(path), 'ok'); +}); + +test('a full restore completes a database a paged restore left incomplete', { skip }, async (t) => { + const backing = new FileReplicaClient(makeTempDir('paged-repl-')); + await replicate(t, backing); + const { client } = gated(backing); + + const path = join(makeTempDir('paged-dst-'), 'db.db'); + const replica = new Replica(path, client); + t.after(() => replica.close()); + const result = await replica.restorePaged(); + + // Restoring again stops the paged restore + await replica.restore({ outputPath: path }); + await assert.rejects(result.filled, { code: 'ERR_PAGED_RESTORE_CLOSED' }); + assert.equal(existsSync(join(`${path}-litestream`, 'paged')), false); + assert.equal(await replica.localPos(), 6n); + assert.equal(integrity(path), 'ok'); +});