From f021eae629aa31e3f9b7924efe1ce7974e1e3b81 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:45:25 +0300 Subject: [PATCH 1/2] fix(server): snapshot service-update databases with VACUUM INTO The launcher copied state.sqlite plus WAL plus shm with copyFile. That can be a torn snapshot. VACUUM INTO writes one consistent file even if the source is open. Restore still copies that file and drops leftover WAL and shm sidecars. --- apps/server/src/serviceLauncher.test.ts | 72 +++++++++++++++++++++++-- apps/server/src/serviceLauncher.ts | 32 ++++++++--- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 45c472af1fc4..c8e6d5906c37 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -3,8 +3,18 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { DatabaseSync } from "node:sqlite"; -import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts"; +import { + Launcher, + readServiceState, + vacuumDatabaseInto, + writeServiceState, +} from "./serviceLauncher.ts"; import { compareExactServiceVersions, decodeServiceState, @@ -32,6 +42,38 @@ it("orders exact semantic versions without treating build metadata as precedence assert.equal(compareExactServiceVersions("2.0.0+one", "2.0.0+two"), 0); }); +it("snapshots sqlite with VACUUM INTO and no wal sidecar", async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-vacuum-backup-")); + try { + const source = NodePath.join(dir, "state.sqlite"); + const destination = NodePath.join(dir, "backup"); + const seed = new DatabaseSync(source); + try { + seed.exec( + "CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT NOT NULL); INSERT INTO kv VALUES ('phase', 'before');", + ); + } finally { + seed.close(); + } + + vacuumDatabaseInto(source, destination); + + const restored = new DatabaseSync(destination, { readOnly: true }); + try { + const row = restored.prepare("SELECT v AS v FROM kv WHERE k = 'phase'").get() as { + v: string; + }; + assert.equal(row.v, "before"); + } finally { + restored.close(); + } + assert.isFalse(NodeFS.existsSync(`${destination}-wal`)); + assert.isFalse(NodeFS.existsSync(`${destination}-shm`)); + } finally { + await NodeFSP.rm(dir, { recursive: true, force: true }); + } +}); + it("rejects contradictory service state", () => { assert.isUndefined( decodeServiceState({ @@ -236,16 +278,28 @@ if (context.update?.status === "pending") { const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-db-" }); const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); - const original = "database before migration"; yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, original); + const seed = new DatabaseSync(databasePath); + try { + seed.exec( + "CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT NOT NULL); INSERT INTO kv VALUES ('phase', 'before');", + ); + } finally { + seed.close(); + } // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` import { writeFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); if (context.update?.status === "pending") { - writeFileSync(context.update.dbPath, "database after migration"); + const db = new DatabaseSync(context.update.dbPath); + try { + db.exec("UPDATE kv SET v = 'after'"); + } finally { + db.close(); + } writeFileSync(context.update.dbPath + "-wal", "trial wal"); writeFileSync(context.update.dbPath + "-shm", "trial shm"); process.exit(1); @@ -281,7 +335,15 @@ if (context.update?.status === "pending") { const state = yield* Effect.promise(() => readServiceState(statePath)); assert.equal(state.activeVersion, "1.0.0"); assert.equal(state.update?.status, "rolled-back"); - assert.equal(yield* fs.readFileString(databasePath), original); + const restored = new DatabaseSync(databasePath, { readOnly: true }); + try { + const row = restored.prepare("SELECT v AS v FROM kv WHERE k = 'phase'").get() as { + v: string; + }; + assert.equal(row.v, "before"); + } finally { + restored.close(); + } assert.isFalse(yield* fs.exists(`${databasePath}-wal`)); assert.isFalse(yield* fs.exists(`${databasePath}-shm`)); const updateId = state.update?.id; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 68f3c346759d..2f78eb797096 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -8,6 +8,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import { DatabaseSync } from "node:sqlite"; import type { PendingServiceUpdate, @@ -89,10 +90,27 @@ async function syncDirectory(directory: string): Promise { } } +function quoteSqliteLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function vacuumDatabaseInto(sourcePath: string, destinationPath: string): void { + const db = new DatabaseSync(sourcePath, { readOnly: true }); + try { + db.exec(`VACUUM INTO ${quoteSqliteLiteral(destinationPath.replaceAll("\\", "/"))}`); + } finally { + db.close(); + } +} + /** * Snapshots the database once per update before the first trial. A completed * backup is never overwritten because a restarted launcher may be looking at * database writes from an earlier attempt by the same trial. + * + * VACUUM INTO writes one consistent file. A live copy of sqlite plus WAL plus + * shm can be a torn snapshot. WAL does not survive VACUUM INTO, so restore + * copies this file and drops leftover sidecars. */ async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); @@ -102,13 +120,10 @@ async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate await NodeFSP.rm(stagingDir, { recursive: true, force: true }); await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); try { - for (const suffix of DB_FILE_SUFFIXES) { - const source = `${pending.dbPath}${suffix}`; - if (suffix !== "" && !(await pathExists(source))) continue; - const destination = databaseBackupFile(stagingDir, suffix); - await NodeFSP.copyFile(source, destination); - await syncFile(destination); - } + const destination = databaseBackupFile(stagingDir, ""); + vacuumDatabaseInto(pending.dbPath, destination); + await NodeFSP.chmod(destination, 0o600); + await syncFile(destination); await NodeFSP.rename(stagingDir, backupDir); await syncDirectory(NodePath.dirname(backupDir)); } catch (cause) { @@ -376,7 +391,8 @@ export class Launcher { } async #startTrial(pending: PendingServiceUpdate): Promise { - // The previous child is dead here, so all three SQLite files are quiescent. + // The previous child is dead here. VACUUM INTO still snapshots one + // consistent file if a sidecar is left behind. try { await backupDatabaseOnce(this.#baseDir, pending); } catch { From 37b50d250ffc8df7e06fce664d3c9acb7651da98 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:00:47 +0300 Subject: [PATCH 2/2] fix(server): keep POSIX paths intact in VACUUM INTO backups Windows still turns backslashes into slashes for SQLite. POSIX paths with a real backslash in the name stay unchanged. Launcher flow tests now seed a real sqlite file, so backup no longer fails with db-backup-failed. --- apps/server/src/serviceLauncher.test.ts | 51 ++++++++++++++++--------- apps/server/src/serviceLauncher.ts | 4 +- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index c8e6d5906c37..036d19f71ddf 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -42,20 +42,23 @@ it("orders exact semantic versions without treating build metadata as precedence assert.equal(compareExactServiceVersions("2.0.0+one", "2.0.0+two"), 0); }); +function seedSqlite(databasePath: string): void { + const seed = new DatabaseSync(databasePath); + try { + seed.exec( + "CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT NOT NULL); INSERT INTO kv VALUES ('phase', 'before');", + ); + } finally { + seed.close(); + } +} + it("snapshots sqlite with VACUUM INTO and no wal sidecar", async () => { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-vacuum-backup-")); try { const source = NodePath.join(dir, "state.sqlite"); const destination = NodePath.join(dir, "backup"); - const seed = new DatabaseSync(source); - try { - seed.exec( - "CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT NOT NULL); INSERT INTO kv VALUES ('phase', 'before');", - ); - } finally { - seed.close(); - } - + seedSqlite(source); vacuumDatabaseInto(source, destination); const restored = new DatabaseSync(destination, { readOnly: true }); @@ -74,6 +77,23 @@ it("snapshots sqlite with VACUUM INTO and no wal sidecar", async () => { } }); +it.skipIf(NodePath.sep === "\\")( + "keeps a literal backslash in a POSIX vacuum destination", + async () => { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-vacuum-posix-")); + try { + const source = NodePath.join(dir, "state.sqlite"); + const destination = NodePath.join(dir, "back\\up"); + seedSqlite(source); + vacuumDatabaseInto(source, destination); + assert.isTrue(NodeFS.existsSync(destination)); + assert.isFalse(NodeFS.existsSync(NodePath.join(dir, "back", "up"))); + } finally { + await NodeFSP.rm(dir, { recursive: true, force: true }); + } + }, +); + it("rejects contradictory service state", () => { assert.isUndefined( decodeServiceState({ @@ -172,7 +192,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "before trial"); + seedSqlite(databasePath); // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` @@ -225,7 +245,7 @@ if (context.update?.status === "pending") { const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "before trial"); + seedSqlite(databasePath); // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` @@ -279,14 +299,7 @@ if (context.update?.status === "pending") { const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - const seed = new DatabaseSync(databasePath); - try { - seed.exec( - "CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT NOT NULL); INSERT INTO kv VALUES ('phase', 'before');", - ); - } finally { - seed.close(); - } + seedSqlite(databasePath); // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 2f78eb797096..924ce5682c21 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -97,7 +97,9 @@ function quoteSqliteLiteral(value: string): string { export function vacuumDatabaseInto(sourcePath: string, destinationPath: string): void { const db = new DatabaseSync(sourcePath, { readOnly: true }); try { - db.exec(`VACUUM INTO ${quoteSqliteLiteral(destinationPath.replaceAll("\\", "/"))}`); + const vacuumPath = + NodePath.sep === "\\" ? destinationPath.replaceAll("\\", "/") : destinationPath; + db.exec(`VACUUM INTO ${quoteSqliteLiteral(vacuumPath)}`); } finally { db.close(); }