Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 82 additions & 7 deletions apps/server/src/serviceLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -32,6 +42,58 @@ 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");
seedSqlite(source);
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.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({
Expand Down Expand Up @@ -130,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 = `
Expand Down Expand Up @@ -183,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 = `
Expand Down Expand Up @@ -236,16 +298,21 @@ 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";
Comment thread
cursor[bot] marked this conversation as resolved.
yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });
yield* fs.writeFileString(databasePath, original);
seedSqlite(databasePath);
// @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);
Expand Down Expand Up @@ -281,7 +348,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;
Expand Down
34 changes: 26 additions & 8 deletions apps/server/src/serviceLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,10 +90,29 @@ async function syncDirectory(directory: string): Promise<void> {
}
}

function quoteSqliteLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}

export function vacuumDatabaseInto(sourcePath: string, destinationPath: string): void {
const db = new DatabaseSync(sourcePath, { readOnly: true });
try {
const vacuumPath =
NodePath.sep === "\\" ? destinationPath.replaceAll("\\", "/") : destinationPath;
db.exec(`VACUUM INTO ${quoteSqliteLiteral(vacuumPath)}`);
} 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<void> {
const backupDir = databaseBackupDir(baseDir, pending.id);
Expand All @@ -102,13 +122,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) {
Expand Down Expand Up @@ -376,7 +393,8 @@ export class Launcher {
}

async #startTrial(pending: PendingServiceUpdate): Promise<void> {
// 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 {
Expand Down
Loading