Skip to content
Merged
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
17 changes: 17 additions & 0 deletions apps/server/src/cloud/servicePreflight.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, it } from "@effect/vitest";

import { migrationManifest } from "../persistence/Migrations.ts";
import { runServicePreflight } from "./servicePreflight.ts";
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";

Expand All @@ -20,3 +21,19 @@ it.each([1])("blocks legacy launcher protocol %i with exact recovery steps", (la
expect(reason).toContain("t3 --version");
expect(reason).toContain("which -a t3");
});

// The launcher skips the update backup only when every migration listed here is already applied.
it("lists every migration as id and name pairs when ready", () => {
const result = runServicePreflight({
databasePath: "/missing/state.sqlite",
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
});

const printed: unknown = JSON.parse(JSON.stringify(result));
expect(printed).toMatchObject({ status: "ready", launcherProtocol: SERVICE_LAUNCHER_PROTOCOL });
expect(printed).toHaveProperty(
"migrations",
migrationManifest.map(([id, name]) => [id, name]),
);
expect(migrationManifest.length).toBeGreaterThan(0);
});
10 changes: 9 additions & 1 deletion apps/server/src/cloud/servicePreflight.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import packageJson from "../../package.json" with { type: "json" };
import { migrationManifest } from "../persistence/Migrations.ts";
import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";

export type ServicePreflightResult =
| {
readonly status: "ready";
readonly version: string;
readonly launcherProtocol: typeof SERVICE_LAUNCHER_PROTOCOL;
/** The launcher compares these with the database to decide whether the update needs a backup. */
readonly migrations?: typeof migrationManifest;
}
| {
readonly status: "blocked";
Expand Down Expand Up @@ -34,7 +37,12 @@ export function runServicePreflight(input: {
};
}

return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL };
return {
status: "ready",
version,
launcherProtocol: SERVICE_LAUNCHER_PROTOCOL,
migrations: migrationManifest,
};
}

export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined {
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/cloud/serviceProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface PendingServiceUpdate {
readonly targetVersion: string;
readonly dbPath: string;
readonly status: "pending";
/** Set when the target applies no migration the database lacks, so the previous version can still open it. */
readonly skipDatabaseBackup?: true;
}

export type ServiceUpdateRecord = PendingServiceUpdate | ServerSelfUpdateOutcome;
Expand Down Expand Up @@ -84,7 +86,14 @@ function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined {
}
if (status === "pending") {
return typeof value.dbPath === "string" && value.dbPath.trim() !== ""
? { id, fromVersion, targetVersion, dbPath: value.dbPath, status }
? {
id,
fromVersion,
targetVersion,
dbPath: value.dbPath,
status,
...(value.skipDatabaseBackup === true ? { skipDatabaseBackup: true } : {}),
}
: undefined;
}
if (
Expand Down
175 changes: 175 additions & 0 deletions apps/server/src/serviceLauncher.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @effect-diagnostics-next-line nodeBuiltinImport:off - observes the children the launcher spawns with Node built-ins.
import type * as NodeChildProcess from "node:child_process";
import * as NodeSqlite from "node:sqlite";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
Expand Down Expand Up @@ -461,6 +462,180 @@ if (context.update?.status === "pending") {
}),
);

it.effect.each([
{
target: "adds no migrations, even with a full disk",
migrations: [[1, "Init"]],
freeBytes: 0,
sawBackup: "false",
afterRollback: "after trial",
reason: "candidate-exited:1:no-backup",
},
{
target: "renames a recorded migration",
migrations: [[1, "Renamed"]],
freeBytes: Number.MAX_SAFE_INTEGER,
sawBackup: "true",
afterRollback: "before trial",
reason: "candidate-exited:1",
},
{
target: "adds a migration",
migrations: [
[1, "Init"],
[2, "Next"],
],
freeBytes: Number.MAX_SAFE_INTEGER,
sawBackup: "true",
afterRollback: "before trial",
reason: "candidate-exited:1",
},
])(
"backs up only when the target $target, and rolls back either way",
({ migrations, freeBytes, sawBackup, afterRollback, reason }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-skip-" });
const statePath = path.join(root, "runtime", "service-state.json");
const databasePath = path.join(root, "userdata", "state.sqlite");
const observedPath = path.join(root, "trial-saw-backup");
yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true });
const database = new NodeSqlite.DatabaseSync(databasePath);
database.exec(`
CREATE TABLE effect_sql_migrations (migration_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO effect_sql_migrations VALUES (1, 'Init');
CREATE TABLE notes (body TEXT NOT NULL);
INSERT INTO notes VALUES ('before trial');
`);
database.close();
const encoded = JSON.stringify({ root, databasePath, observedPath, migrations });
const childSource = `
import { existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
const paths = ${encoded};
if (process.argv.includes("__service-preflight")) {
console.log(JSON.stringify({ status: "ready", version: "1.1.0", launcherProtocol: ${SERVICE_LAUNCHER_PROTOCOL}, migrations: paths.migrations }));
} else {
const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT);
if (context.update?.status === "pending") {
writeFileSync(paths.observedPath, String(existsSync(join(paths.root, "runtime", "db-backup", context.update.id))));
const database = new DatabaseSync(context.update.dbPath);
database.exec("UPDATE notes SET body = 'after trial'");
database.close();
process.exit(1);
} else if (context.update === undefined) {
process.on("message", (message) => {
if (message.type === "update-rejected") process.exit(2);
});
process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: paths.databasePath });
setInterval(() => {}, 1_000);
} else {
process.exit(0);
}
}
`;
for (const version of ["1.0.0", "1.1.0"]) {
yield* writeFakeRuntime(
fs,
path,
path.join(root, "runtime", "versions", version),
childSource,
);
}
yield* Effect.promise(() =>
writeServiceState(statePath, {
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.0.0",
}),
);

const launcher = new Launcher(
root,
yield* Effect.promise(() => readServiceState(statePath)),
{
backupSpaceProbe: {
readFreeBytes: () => Promise.resolve(freeBytes),
canClone: () => Promise.resolve(false),
},
},
);
yield* Effect.promise(() =>
launcher.run().then(
() => Promise.reject(new Error("launcher unexpectedly completed")),
() => Promise.resolve(),
),
);

const state = yield* Effect.promise(() => readServiceState(statePath));
assert.equal(state.activeVersion, "1.0.0");
assert.equal(state.update?.status, "rolled-back");
assert.equal(
state.update?.status === "rolled-back" ? state.update.reason : undefined,
reason,
);
assert.equal(yield* fs.readFileString(observedPath), sawBackup);
const after = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true });
try {
assert.deepEqual(
after
.prepare("SELECT body FROM notes")
.all()
.map((row) => row.body),
[afterRollback],
);
} finally {
after.close();
}
}),
);

it.effect("does not flag a skipped backup when no trial ran", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-no-trial-" });
const statePath = path.join(root, "runtime", "service-state.json");
yield* writeFakeRuntime(
fs,
path,
path.join(root, "runtime", "versions", "1.0.0"),
"process.exit(0);",
);
yield* Effect.promise(() =>
writeServiceState(statePath, {
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.0.0",
update: {
id: "update-1",
fromVersion: "1.0.0",
targetVersion: "1.1.0",
dbPath: path.join(root, "userdata", "state.sqlite"),
status: "pending",
skipDatabaseBackup: true,
},
}),
);

const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)));
yield* Effect.promise(() =>
launcher.run().then(
() => Promise.reject(new Error("launcher unexpectedly completed")),
() => Promise.resolve(),
),
);

const state = yield* Effect.promise(() => readServiceState(statePath));
assert.equal(state.activeVersion, "1.0.0");
assert.equal(state.update?.status, "failed");
assert.equal(
state.update?.status === "failed" ? state.update.reason : undefined,
"target-runtime-missing",
);
}),
);

const requestRejectedUpdate = (backupSpaceProbe: Partial<BackupSpaceProbe>) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
Loading
Loading