Skip to content
Source
Sign inSign up
Open History to see the last commit

doctor.test.ts

197 lines7.6 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { assert, it } from "@effect/vitest";
5import * as Console from "effect/Console";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as TestClock from "effect/testing/TestClock";
11​
12import * as BootService from "../cloud/bootService.ts";
13import { runDoctor } from "./doctor.ts";
14import { DatabaseMaintenance } from "../persistence/DatabaseMaintenance.ts";
15import * as ProcessRunner from "../processRunner.ts";
16import { acquireServerOwnership } from "../serverOwnership.ts";
17import { makePersistedServerRuntimeState } from "../serverRuntimeState.ts";
18​
19// An empty home, so no boot service unit installed on this machine is read.
20const layerHost = Layer.mergeAll(
21 ProcessRunner.layer,
22 Layer.effect(
23 HostProcess.HomeDirectory,
24 Effect.gen(function* () {
25 return yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
26 }),
27 ),
28 Layer.succeed(HostProcess.Environment, {}),
29).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
30​
31it.effect(
32 "--fix locks the home while the service is paused, names a server that holds it, keeps the V1 database, and --dry-run changes nothing",
33 () =>
34 Effect.gen(function* () {
35 const fs = yield* FileSystem.FileSystem;
36 const path = yield* Path.Path;
37 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-doctor-" });
38 const serverRuntimeStatePath = path.join(stateDir, "server-runtime.json");
39 const calls: string[] = [];
40 const services =
41 yield* Effect.context<Effect.Services<ReturnType<typeof acquireServerOwnership>>>();
42 // Stands in for a server launched for the same home, for example over SSH.
43 const startServer = Effect.scoped(acquireServerOwnership(serverRuntimeStatePath)).pipe(
44 Effect.result,
45 Effect.map((result) =>
46 calls.push(result._tag === "Success" ? "server started" : result.failure._tag),
47 ),
48 Effect.provide(services),
49 );
50 const doctor = (dryRun: boolean) =>
51 runDoctor(
52 { dbPath: path.join(stateDir, "statev2.sqlite"), serverRuntimeStatePath },
53 { fix: true, dryRun, interactive: false },
54 ).pipe(
55 Effect.provide(
56 Layer.mergeAll(
57 Layer.mock(BootService.BootService)({
58 withStopped: (operation) =>
59 Effect.sync(() => calls.push("pause")).pipe(
60 Effect.andThen(operation),
61 Effect.ensuring(Effect.sync(() => calls.push("resume"))),
62 ),
63 }),
64 Layer.succeed(DatabaseMaintenance, {
65 inspect: () =>
66 Effect.succeed({
67 databaseBytes: 2000,
68 reclaimableBytes: 1000,
69 incrementalVacuumEnabled: false,
70 legacyDatabaseBytes: 4000,
71 }),
72 compact: () =>
73 startServer.pipe(
74 Effect.map(() => {
75 calls.push("compact");
76 return {
77 beforeBytes: 2000,
78 afterBytes: 1000,
79 deletedEventCount: 1,
80 deletedReceiptCount: 0,
81 };
82 }),
83 ),
84 removeLegacyDatabase: () => Effect.sync(() => calls.push("remove legacy")),
85 }),
86 ),
87 ),
88 );
89 yield* doctor(true);
90 assert.deepEqual(calls, []);
91 yield* doctor(false);
92 yield* startServer;
93 assert.deepEqual(calls, [
94 "pause",
95 "ServerAlreadyRunningError",
96 "compact",
97 "resume",
98 "server started",
99 ]);
100​
101 calls.length = 0;
102 const state = yield* makePersistedServerRuntimeState({
103 config: { host: undefined, devUrl: undefined },
104 port: 3773,
105 });
106 const refused = yield* Effect.scoped(
107 acquireServerOwnership(serverRuntimeStatePath).pipe(
108 Effect.tap((owner) => owner.publish(state)),
109 Effect.andThen(doctor(false)),
110 ),
111 ).pipe(Effect.flip);
112 assert.equal(
113 refused.message,
114 `Stop T3 Code server process ${state.pid}, which the background service did not start, before repairing the database.`,
115 );
116 assert.deepEqual(calls, ["pause", "resume"]);
117 }).pipe(Effect.provide(layerHost)),
118);
119​
120it.effect("warns how long reclaiming can take and reports each step with its elapsed time", () =>
121 Effect.gen(function* () {
122 const output: string[] = [];
123 const steps = [
124 ["prune-events", "65 seconds"],
125 ["vacuum", "701 seconds"],
126 ["rebuild-search", "1 second"],
127 ["checkpoint", "1 second"],
128 ] as const;
129 const stateDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
130 const path = yield* Path.Path;
131 yield* runDoctor(
132 {
133 dbPath: path.join(stateDir, "statev2.sqlite"),
134 serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"),
135 },
136 { fix: true, dryRun: false, interactive: false },
137 ).pipe(
138 Effect.provide(
139 Layer.mergeAll(
140 Layer.mock(BootService.BootService)({
141 withStopped: (operation) =>
142 Effect.sync(() => output.push("<pause>")).pipe(
143 Effect.andThen(operation),
144 Effect.ensuring(Effect.sync(() => output.push("<resume>"))),
145 ),
146 }),
147 Layer.succeed(DatabaseMaintenance, {
148 inspect: () =>
149 Effect.succeed({
150 databaseBytes: 12.8 * 1024 ** 3,
151 reclaimableBytes: 0,
152 incrementalVacuumEnabled: false,
153 legacyDatabaseBytes: null,
154 }),
155 compact: (_databasePath, onStep) =>
156 Effect.gen(function* () {
157 for (const [step, duration] of steps) {
158 if (onStep) yield* onStep(step);
159 yield* TestClock.adjust(duration);
160 }
161 return {
162 beforeBytes: 12.8 * 1024 ** 3,
163 afterBytes: 12.8 * 1024 ** 3,
164 deletedEventCount: 0,
165 deletedReceiptCount: 0,
166 };
167 }),
168 removeLegacyDatabase: () => Effect.void,
169 }),
170 ),
171 ),
172 Effect.provideService(Console.Console, {
173 ...globalThis.console,
174 log: (...args: ReadonlyArray<unknown>) => {
175 output.push(...args.join(" ").split("\n"));
176 },
177 }),
178 );
179​
180 const paused = output.indexOf("<pause>");
181 const resumed = output.indexOf("<resume>");
182 assert.isAbove(paused, -1, "the repair must run with the service paused");
183 const beforePause = output.slice(0, paused).join("\n");
184 const description = beforePause.slice(beforePause.indexOf("Reclaim database space"));
185 assert.include(description, "12.80 GiB", "the repair must state the database size up front");
186 assert.match(description, /hour/, "the repair must warn it can take over an hour");
187 const progress = output
188 .slice(paused + 1, resumed)
189 .filter((line) => line.trim() !== "" && !line.trim().startsWith("Done"))
190 .map((line) => /\[(\d+:\d{2})\]/.exec(line)?.[1] ?? `unprefixed: ${line}`);
191 assert.deepEqual(
192 progress,
193 ["0:00", "1:05", "12:46", "12:47"],
194 "each compaction step must print one line with the elapsed time as it starts",
195 );
196 }).pipe(Effect.provide(layerHost)),
197);