Skip to content
Closed
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
19 changes: 19 additions & 0 deletions apps/server/src/persistence/initializeV2Database.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
Expand Down Expand Up @@ -196,3 +197,21 @@ it.effect("starts fresh without V1 and never imports over existing V2 state", ()
Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))),
);
});

it.effect("removes copies left by a killed start, but not one that is still running", () => {
const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-abandoned-"));
// The child has exited, so nothing runs under its process ID.
const killed = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid;
const running = `.v2-import-${process.pid}-Kq81Zx`;
return Effect.gen(function* () {
for (const name of [`.v2-import-${killed}-Wm3nPa`, ".v2-import-Q3xZ9a", running]) {
NodeFS.mkdirSync(NodePath.join(directory, name));
NodeFS.writeFileSync(NodePath.join(directory, name, "snapshot.sqlite"), "partial copy");
}
yield* initializeV2Database(NodePath.join(directory, "statev2.sqlite"));
assert.deepEqual(NodeFS.readdirSync(directory), [running]);
}).pipe(
Effect.provide(NodeServices.layer),
Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))),
);
});
24 changes: 23 additions & 1 deletion apps/server/src/persistence/initializeV2Database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import { isProcessAlive } from "../serverRuntimeState.ts";

export class V2DatabaseImportError extends Schema.TaggedError<V2DatabaseImportError>()(
"V2DatabaseImportError",
{ sourcePath: Schema.String, destinationPath: Schema.String, cause: Schema.Defect() },
Expand All @@ -14,6 +16,25 @@ export class V2DatabaseImportError extends Schema.TaggedError<V2DatabaseImportEr
}
}

// A start killed while copying leaves a database-sized directory behind. Its
// name carries the copying process ID, so a later start never removes a copy
// that is still running. Directories without an ID come from older builds.
const removeAbandonedCopies = Effect.fn("removeAbandonedCopies")(function* (directory: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const entries = yield* fs.readDirectory(directory).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.succeed([]),
),
);
for (const entry of entries) {
const copy = /^\.v2-import-(?:(\d+)-)?/.exec(entry);
if (copy === null || (copy[1] !== undefined && isProcessAlive(Number(copy[1])))) continue;
yield* fs.remove(path.join(directory, entry), { recursive: true });
}
});

/** Its copied legacy tables remain the source for lazy transcript import. */
export const initializeV2Database = Effect.fn("initializeV2Database")(function* (
destinationPath: string,
Expand All @@ -22,12 +43,13 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function*
const path = yield* Path.Path;
const directory = path.dirname(destinationPath);
const sourcePath = path.join(directory, "state.sqlite");
yield* removeAbandonedCopies(directory).pipe(Effect.ignore({ log: true }));
yield* Effect.gen(function* () {
if (yield* fs.exists(destinationPath)) return;
if (!(yield* fs.exists(sourcePath))) return;
const temporaryDirectory = yield* fs.makeTempDirectoryScoped({
directory,
prefix: ".v2-import-",
prefix: `.v2-import-${process.pid}-`,
});
const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite");
yield* Effect.tryPromise(async () => {
Expand Down
Loading