@claudexor/daemon 3.3.16 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/command-store.d.ts +3 -0
- package/dist/command-store.d.ts.map +1 -1
- package/dist/command-store.js +4 -0
- package/dist/command-store.js.map +1 -1
- package/dist/daemon-listen.d.ts +10 -0
- package/dist/daemon-listen.d.ts.map +1 -0
- package/dist/daemon-listen.js +36 -0
- package/dist/daemon-listen.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +3 -0
- package/dist/interactions.d.ts.map +1 -1
- package/dist/interactions.js +4 -0
- package/dist/interactions.js.map +1 -1
- package/dist/journal-manager-lifecycle.d.ts +8 -0
- package/dist/journal-manager-lifecycle.d.ts.map +1 -0
- package/dist/journal-manager-lifecycle.js +2 -0
- package/dist/journal-manager-lifecycle.js.map +1 -0
- package/dist/journal-manager.d.ts +21 -11
- package/dist/journal-manager.d.ts.map +1 -1
- package/dist/journal-manager.js +234 -198
- package/dist/journal-manager.js.map +1 -1
- package/dist/journal-prepared-operation.d.ts +53 -0
- package/dist/journal-prepared-operation.d.ts.map +1 -0
- package/dist/journal-prepared-operation.js +158 -0
- package/dist/journal-prepared-operation.js.map +1 -0
- package/dist/journal-projection-slot.d.ts +28 -0
- package/dist/journal-projection-slot.d.ts.map +1 -0
- package/dist/journal-projection-slot.js +71 -0
- package/dist/journal-projection-slot.js.map +1 -0
- package/dist/journal-projection.d.ts +2 -0
- package/dist/journal-projection.d.ts.map +1 -1
- package/dist/journal-recovery-files.d.ts +19 -1
- package/dist/journal-recovery-files.d.ts.map +1 -1
- package/dist/journal-recovery-files.js +271 -26
- package/dist/journal-recovery-files.js.map +1 -1
- package/dist/journal-recovery-operation.d.ts +27 -0
- package/dist/journal-recovery-operation.d.ts.map +1 -0
- package/dist/journal-recovery-operation.js +68 -0
- package/dist/journal-recovery-operation.js.map +1 -0
- package/dist/project-partition-preparation.d.ts +75 -0
- package/dist/project-partition-preparation.d.ts.map +1 -0
- package/dist/project-partition-preparation.js +233 -0
- package/dist/project-partition-preparation.js.map +1 -0
- package/dist/project-partitions.d.ts +18 -16
- package/dist/project-partitions.d.ts.map +1 -1
- package/dist/project-partitions.js +98 -99
- package/dist/project-partitions.js.map +1 -1
- package/dist/project-thread-listing.d.ts +14 -0
- package/dist/project-thread-listing.d.ts.map +1 -0
- package/dist/project-thread-listing.js +37 -0
- package/dist/project-thread-listing.js.map +1 -0
- package/dist/quota-projection.d.ts +9 -0
- package/dist/quota-projection.d.ts.map +1 -0
- package/dist/quota-projection.js +10 -0
- package/dist/quota-projection.js.map +1 -0
- package/dist/quota-registry.d.ts +3 -5
- package/dist/quota-registry.d.ts.map +1 -1
- package/dist/quota-registry.js +9 -10
- package/dist/quota-registry.js.map +1 -1
- package/dist/root-authority.d.ts +68 -0
- package/dist/root-authority.d.ts.map +1 -0
- package/dist/root-authority.js +380 -0
- package/dist/root-authority.js.map +1 -0
- package/dist/rpc-followers.d.ts +17 -0
- package/dist/rpc-followers.d.ts.map +1 -0
- package/dist/rpc-followers.js +46 -0
- package/dist/rpc-followers.js.map +1 -0
- package/dist/server.d.ts +4 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +42 -62
- package/dist/server.js.map +1 -1
- package/dist/serving-admission.d.ts +21 -0
- package/dist/serving-admission.d.ts.map +1 -0
- package/dist/serving-admission.js +17 -0
- package/dist/serving-admission.js.map +1 -0
- package/dist/terminate.d.ts +18 -7
- package/dist/terminate.d.ts.map +1 -1
- package/dist/terminate.js +213 -69
- package/dist/terminate.js.map +1 -1
- package/dist/token.d.ts +6 -0
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +8 -0
- package/dist/token.js.map +1 -1
- package/dist/writer-lease.d.ts +76 -4
- package/dist/writer-lease.d.ts.map +1 -1
- package/dist/writer-lease.js +346 -56
- package/dist/writer-lease.js.map +1 -1
- package/package.json +5 -5
package/dist/journal-manager.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, realpathSync, renameSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { DurableJournal, JournalRecoveryRequiredError, journalPartitionDirectory, } from "@claudexor/journal";
|
|
5
|
-
import { ControlJournalQuarantineReceipt as QuarantineReceiptSchema, } from "@claudexor/schema";
|
|
6
5
|
import { ensureCanonicalPrivateDirectory, fsyncDirectory } from "@claudexor/util";
|
|
7
|
-
import {
|
|
6
|
+
import { exportJournalRecovery, fingerprintPartition, cloneRecovery, recoveryAt, recoveryFrom, safeMessage, sha256, writeAtomicPrivateJson, } from "./journal-recovery-files.js";
|
|
7
|
+
import { conflict, matchingReceipt, quarantineRequestDigest, readOperation, typedError, validateRequest, } from "./journal-recovery-operation.js";
|
|
8
8
|
import { journalEvents } from "./journal-events.js";
|
|
9
|
+
import { applyPreparedOperation, completePreparedReceipt, inspectPreparedOperation, preparationFingerprint, reconcilePreparedOperationOnStart, revalidatePreparedState, } from "./journal-prepared-operation.js";
|
|
10
|
+
import { prepareProjectionSlots, ProjectionSlot, } from "./journal-projection-slot.js";
|
|
9
11
|
export class JournalManager {
|
|
10
12
|
rootDir;
|
|
11
13
|
partition;
|
|
@@ -20,16 +22,15 @@ export class JournalManager {
|
|
|
20
22
|
journal = null;
|
|
21
23
|
recovery = { status: "ready", discardedTailBytes: 0 };
|
|
22
24
|
generationValue = 0;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
preparedOperation = null;
|
|
26
|
+
preparationResult = null;
|
|
27
|
+
lifecycle = "idle";
|
|
25
28
|
constructor(rootDir, options = {}) {
|
|
26
|
-
this.rootDir = rootDir;
|
|
27
29
|
this.partition = options.partition?.trim() || "global";
|
|
28
30
|
this.now = options.now ?? (() => new Date());
|
|
29
31
|
this.faults = options.faults ?? {};
|
|
30
|
-
|
|
31
|
-
this.journalRoot = join(
|
|
32
|
-
ensureCanonicalPrivateDirectory(this.journalRoot);
|
|
32
|
+
this.rootDir = realpathSync(rootDir);
|
|
33
|
+
this.journalRoot = join(this.rootDir, "journal");
|
|
33
34
|
this.partitionDir = journalPartitionDirectory(this.journalRoot, this.partition);
|
|
34
35
|
this.artifactPrefix = basename(this.partitionDir);
|
|
35
36
|
this.operationsDir = join(this.rootDir, "recovery-operations", basename(this.partitionDir));
|
|
@@ -37,7 +38,7 @@ export class JournalManager {
|
|
|
37
38
|
}
|
|
38
39
|
registerProjection(descriptor) {
|
|
39
40
|
this.assertOpen();
|
|
40
|
-
if (this.
|
|
41
|
+
if (this.lifecycle !== "idle")
|
|
41
42
|
throw new Error("journal projection registration is closed");
|
|
42
43
|
if (!/^[A-Za-z0-9._-]+$/.test(descriptor.name) || this.registrations.has(descriptor.name)) {
|
|
43
44
|
throw new Error(`invalid or duplicate journal projection '${descriptor.name}'`);
|
|
@@ -48,15 +49,158 @@ export class JournalManager {
|
|
|
48
49
|
}
|
|
49
50
|
start() {
|
|
50
51
|
this.assertOpen();
|
|
51
|
-
if (this.
|
|
52
|
+
if (this.lifecycle !== "idle")
|
|
52
53
|
return this.inspect();
|
|
53
54
|
if (this.registrations.size === 0)
|
|
54
55
|
throw new Error("journal partition requires a projection");
|
|
55
|
-
this.
|
|
56
|
+
ensureCanonicalPrivateDirectory(this.rootDir);
|
|
57
|
+
ensureCanonicalPrivateDirectory(this.journalRoot);
|
|
56
58
|
if (!this.reconcilePrepared())
|
|
57
59
|
this.openGeneration();
|
|
58
60
|
return this.inspect();
|
|
59
61
|
}
|
|
62
|
+
prepare() {
|
|
63
|
+
this.assertOpen();
|
|
64
|
+
if (this.preparationResult &&
|
|
65
|
+
(this.lifecycle === "prepared" || this.lifecycle === "recovery_required")) {
|
|
66
|
+
return structuredClone(this.preparationResult);
|
|
67
|
+
}
|
|
68
|
+
if (this.lifecycle !== "idle")
|
|
69
|
+
throw new Error("journal manager is already running");
|
|
70
|
+
if (this.registrations.size === 0)
|
|
71
|
+
throw new Error("journal partition requires a projection");
|
|
72
|
+
this.generationValue += 1;
|
|
73
|
+
const projectionStatus = [];
|
|
74
|
+
try {
|
|
75
|
+
this.journal = DurableJournal.prepare({
|
|
76
|
+
rootDir: this.journalRoot,
|
|
77
|
+
partition: this.partition,
|
|
78
|
+
now: this.now,
|
|
79
|
+
});
|
|
80
|
+
this.recovery = this.journal.state();
|
|
81
|
+
this.preparedOperation = inspectPreparedOperation({
|
|
82
|
+
rootDir: this.rootDir,
|
|
83
|
+
operationsDir: this.operationsDir,
|
|
84
|
+
quarantineDir: this.quarantineDir,
|
|
85
|
+
partitionDir: this.partitionDir,
|
|
86
|
+
partition: this.partition,
|
|
87
|
+
artifactPrefix: this.artifactPrefix,
|
|
88
|
+
journal: this.journal,
|
|
89
|
+
});
|
|
90
|
+
if (this.recovery.status === "ready") {
|
|
91
|
+
const prepared = prepareProjectionSlots(this.registrations.values(), this.journal);
|
|
92
|
+
projectionStatus.push(...prepared.status);
|
|
93
|
+
if (prepared.recovery)
|
|
94
|
+
this.enterRecovery(prepared.recovery);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
this.enterRecovery(recoveryFrom(error, `${this.partition} journal could not be prepared`));
|
|
99
|
+
}
|
|
100
|
+
const partitionFingerprint = this.observeFingerprint(this.partitionDir, "journal partition");
|
|
101
|
+
const operationsFingerprint = this.observeFingerprint(this.operationsDir, "journal recovery operations");
|
|
102
|
+
const preparedFingerprint = preparationFingerprint(this.journal?.preparation().preparationIdentity ?? partitionFingerprint.preparationIdentity, this.preparedOperation?.fingerprint ?? operationsFingerprint.preparationIdentity);
|
|
103
|
+
const inspection = this.inspection(partitionFingerprint.fingerprint);
|
|
104
|
+
this.preparationResult = {
|
|
105
|
+
partition: this.partition,
|
|
106
|
+
coverage: "complete",
|
|
107
|
+
inspection,
|
|
108
|
+
validation: { ...inspection, projectionStatus },
|
|
109
|
+
preparationFingerprint: preparedFingerprint,
|
|
110
|
+
virtual: this.journal?.preparation().virtual ?? !partitionFingerprint.exists,
|
|
111
|
+
};
|
|
112
|
+
if (this.recovery.status === "ready")
|
|
113
|
+
this.lifecycle = "prepared";
|
|
114
|
+
else
|
|
115
|
+
this.enterRecovery(this.recovery);
|
|
116
|
+
return structuredClone(this.preparationResult);
|
|
117
|
+
}
|
|
118
|
+
revalidatePreparation() {
|
|
119
|
+
this.assertStarted();
|
|
120
|
+
if (this.lifecycle === "recovery_required") {
|
|
121
|
+
throw new JournalRecoveryRequiredError(this.recovery);
|
|
122
|
+
}
|
|
123
|
+
if (this.lifecycle !== "prepared")
|
|
124
|
+
throw new Error("journal manager is not prepared");
|
|
125
|
+
if (!this.preparationResult || !this.journal)
|
|
126
|
+
throw new Error("journal is not prepared");
|
|
127
|
+
try {
|
|
128
|
+
this.preparedOperation = revalidatePreparedState({
|
|
129
|
+
rootDir: this.rootDir,
|
|
130
|
+
operationsDir: this.operationsDir,
|
|
131
|
+
quarantineDir: this.quarantineDir,
|
|
132
|
+
partitionDir: this.partitionDir,
|
|
133
|
+
partition: this.partition,
|
|
134
|
+
artifactPrefix: this.artifactPrefix,
|
|
135
|
+
journal: this.journal,
|
|
136
|
+
expectedFingerprint: this.preparationResult.preparationFingerprint,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const recovery = recoveryFrom(error, `${this.partition} preparation revalidation failed`);
|
|
141
|
+
this.enterRecovery(recovery);
|
|
142
|
+
throw new JournalRecoveryRequiredError(recovery);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
activatePrepared() {
|
|
146
|
+
this.assertStarted();
|
|
147
|
+
if (this.lifecycle === "active")
|
|
148
|
+
return;
|
|
149
|
+
if (this.lifecycle === "recovery_required") {
|
|
150
|
+
throw new JournalRecoveryRequiredError(this.recovery);
|
|
151
|
+
}
|
|
152
|
+
if (this.lifecycle !== "prepared")
|
|
153
|
+
throw new Error("journal manager is not prepared");
|
|
154
|
+
if (!this.preparationResult || !this.journal)
|
|
155
|
+
throw new Error("journal is not prepared");
|
|
156
|
+
try {
|
|
157
|
+
this.revalidatePreparation();
|
|
158
|
+
this.journal.activatePrepared();
|
|
159
|
+
if (!this.preparedOperation)
|
|
160
|
+
throw new Error("journal recovery operation is not prepared");
|
|
161
|
+
applyPreparedOperation({
|
|
162
|
+
rootDir: this.rootDir,
|
|
163
|
+
plan: this.preparedOperation,
|
|
164
|
+
journal: this.journal,
|
|
165
|
+
partition: this.partition,
|
|
166
|
+
artifactPrefix: this.artifactPrefix,
|
|
167
|
+
now: this.now,
|
|
168
|
+
afterReceipt: this.faults.afterQuarantineReceipt,
|
|
169
|
+
});
|
|
170
|
+
const activatedRecovery = this.journal.state();
|
|
171
|
+
if (activatedRecovery.status === "recovery_required") {
|
|
172
|
+
throw new JournalRecoveryRequiredError(activatedRecovery);
|
|
173
|
+
}
|
|
174
|
+
for (const registration of this.registrations.values()) {
|
|
175
|
+
this.faults.beforeProjectionActivation?.();
|
|
176
|
+
registration.slot.activate(this.generationValue);
|
|
177
|
+
}
|
|
178
|
+
this.recovery = activatedRecovery;
|
|
179
|
+
this.lifecycle = "active";
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
const recovery = recoveryFrom(error, `${this.partition} prepared activation failed`);
|
|
183
|
+
this.journal?.close();
|
|
184
|
+
this.journal = null;
|
|
185
|
+
this.enterRecovery(recovery);
|
|
186
|
+
throw new JournalRecoveryRequiredError(recovery);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
recoverAfterStartup() {
|
|
190
|
+
this.assertStarted();
|
|
191
|
+
if (this.lifecycle !== "active")
|
|
192
|
+
throw new Error("journal manager is not active");
|
|
193
|
+
try {
|
|
194
|
+
for (const registration of this.registrations.values()) {
|
|
195
|
+
registration.descriptor.recover?.(registration.slot.current());
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const recovery = recoveryFrom(error, `${this.partition} projection recovery failed`);
|
|
200
|
+
this.enterRecovery(recovery);
|
|
201
|
+
throw new JournalRecoveryRequiredError(recovery);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
60
204
|
inspect() {
|
|
61
205
|
this.assertStarted();
|
|
62
206
|
if (this.journal && this.recovery.status === "ready") {
|
|
@@ -64,7 +208,7 @@ export class JournalManager {
|
|
|
64
208
|
if (state.status === "recovery_required")
|
|
65
209
|
this.enterRecovery(state);
|
|
66
210
|
}
|
|
67
|
-
return this.inspection(
|
|
211
|
+
return this.inspection(this.observeFingerprint(this.partitionDir, "journal partition").fingerprint);
|
|
68
212
|
}
|
|
69
213
|
ready() {
|
|
70
214
|
this.assertStarted();
|
|
@@ -73,15 +217,21 @@ export class JournalManager {
|
|
|
73
217
|
if (state.status === "recovery_required")
|
|
74
218
|
this.enterRecovery(state);
|
|
75
219
|
}
|
|
76
|
-
return this.recovery.status === "ready";
|
|
220
|
+
return this.lifecycle === "active" && this.recovery.status === "ready";
|
|
77
221
|
}
|
|
78
222
|
validate() {
|
|
79
223
|
this.assertStarted();
|
|
80
|
-
|
|
224
|
+
if (this.lifecycle === "prepared") {
|
|
225
|
+
try {
|
|
226
|
+
this.revalidatePreparation();
|
|
227
|
+
}
|
|
228
|
+
catch { }
|
|
229
|
+
}
|
|
230
|
+
const before = this.observeFingerprint(this.partitionDir, "journal validation input");
|
|
81
231
|
const projectionStatus = [];
|
|
82
232
|
for (const registration of this.registrations.values()) {
|
|
83
233
|
try {
|
|
84
|
-
const projection = registration.slot.
|
|
234
|
+
const projection = registration.slot.prepared();
|
|
85
235
|
registration.descriptor.validate(projection);
|
|
86
236
|
projectionStatus.push({
|
|
87
237
|
name: registration.descriptor.name,
|
|
@@ -98,10 +248,11 @@ export class JournalManager {
|
|
|
98
248
|
});
|
|
99
249
|
}
|
|
100
250
|
}
|
|
101
|
-
const after =
|
|
102
|
-
if (before !== after)
|
|
251
|
+
const after = this.observeFingerprint(this.partitionDir, "journal validation input");
|
|
252
|
+
if (before.fingerprint !== after.fingerprint) {
|
|
103
253
|
this.enterRecovery(recoveryAt(0, "journal changed during validation"));
|
|
104
|
-
|
|
254
|
+
}
|
|
255
|
+
return { ...this.inspection(after.fingerprint), projectionStatus };
|
|
105
256
|
}
|
|
106
257
|
events(afterCursor) {
|
|
107
258
|
this.assertStarted();
|
|
@@ -109,44 +260,13 @@ export class JournalManager {
|
|
|
109
260
|
}
|
|
110
261
|
exportRecovery() {
|
|
111
262
|
this.assertStarted();
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const entries = exportPartitionEntries(this.partitionDir, bundlePath);
|
|
120
|
-
const createdAt = this.now().toISOString();
|
|
121
|
-
const manifestPath = join(bundlePath, "manifest.json");
|
|
122
|
-
writeExclusiveFile(manifestPath, Buffer.from(`${JSON.stringify({
|
|
123
|
-
schemaVersion: 1,
|
|
124
|
-
exportId,
|
|
125
|
-
partition: this.partition,
|
|
126
|
-
fingerprint,
|
|
127
|
-
recovery: this.recovery,
|
|
128
|
-
createdAt,
|
|
129
|
-
entries,
|
|
130
|
-
}, null, 2)}\n`), 0o400);
|
|
131
|
-
fsyncDirectory(bundlePath);
|
|
132
|
-
if (fingerprintPartition(this.partitionDir) !== fingerprint) {
|
|
133
|
-
throw new Error("journal changed during recovery export");
|
|
134
|
-
}
|
|
135
|
-
return {
|
|
136
|
-
schemaVersion: 1,
|
|
137
|
-
exportId,
|
|
138
|
-
partition: this.partition,
|
|
139
|
-
fingerprint,
|
|
140
|
-
bundlePath,
|
|
141
|
-
manifestSha256: sha256File(manifestPath),
|
|
142
|
-
createdAt,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
catch (error) {
|
|
146
|
-
rmSync(bundlePath, { recursive: true, force: true });
|
|
147
|
-
fsyncDirectory(exportsRoot);
|
|
148
|
-
throw error;
|
|
149
|
-
}
|
|
263
|
+
return exportJournalRecovery({
|
|
264
|
+
rootDir: this.rootDir,
|
|
265
|
+
partitionDir: this.partitionDir,
|
|
266
|
+
partition: this.partition,
|
|
267
|
+
recovery: this.recovery,
|
|
268
|
+
now: this.now,
|
|
269
|
+
});
|
|
150
270
|
}
|
|
151
271
|
preflightQuarantine(input) {
|
|
152
272
|
this.assertStarted();
|
|
@@ -169,7 +289,8 @@ export class JournalManager {
|
|
|
169
289
|
if (this.recovery.status !== "recovery_required") {
|
|
170
290
|
throw typedError("journal_partition_ready", 409, "only a corrupt partition can be quarantined");
|
|
171
291
|
}
|
|
172
|
-
if (
|
|
292
|
+
if (this.stableFingerprint(this.partitionDir, "journal recovery source").fingerprint !==
|
|
293
|
+
input.expectedFingerprint) {
|
|
173
294
|
throw conflict("recovery_fingerprint_mismatch");
|
|
174
295
|
}
|
|
175
296
|
return { disposition: "new", receipt: null };
|
|
@@ -201,9 +322,9 @@ export class JournalManager {
|
|
|
201
322
|
return this.resume(operation, operationPath);
|
|
202
323
|
}
|
|
203
324
|
close() {
|
|
204
|
-
if (this.closed)
|
|
325
|
+
if (this.lifecycle === "closed")
|
|
205
326
|
return;
|
|
206
|
-
this.
|
|
327
|
+
this.lifecycle = "closed";
|
|
207
328
|
this.clearSlots();
|
|
208
329
|
this.journal?.close();
|
|
209
330
|
this.journal = null;
|
|
@@ -247,7 +368,7 @@ export class JournalManager {
|
|
|
247
368
|
this.openGeneration();
|
|
248
369
|
throw error;
|
|
249
370
|
}
|
|
250
|
-
this.
|
|
371
|
+
this.lifecycle = "closed";
|
|
251
372
|
fsyncDirectory(this.journalRoot);
|
|
252
373
|
fsyncDirectory(archiveDir);
|
|
253
374
|
return dest;
|
|
@@ -269,7 +390,7 @@ export class JournalManager {
|
|
|
269
390
|
}
|
|
270
391
|
renameSync(archivedPath, this.partitionDir);
|
|
271
392
|
fsyncDirectory(this.journalRoot);
|
|
272
|
-
this.
|
|
393
|
+
this.lifecycle = "idle";
|
|
273
394
|
this.openGeneration();
|
|
274
395
|
}
|
|
275
396
|
openGeneration() {
|
|
@@ -285,13 +406,17 @@ export class JournalManager {
|
|
|
285
406
|
now: this.now,
|
|
286
407
|
});
|
|
287
408
|
this.recovery = this.journal.state();
|
|
288
|
-
if (this.recovery.status === "recovery_required")
|
|
409
|
+
if (this.recovery.status === "recovery_required") {
|
|
410
|
+
this.enterRecovery(this.recovery);
|
|
289
411
|
return;
|
|
412
|
+
}
|
|
290
413
|
for (const registration of this.registrations.values()) {
|
|
291
414
|
const projection = registration.descriptor.create(this.journal);
|
|
292
415
|
registration.descriptor.validate(projection);
|
|
293
416
|
registration.slot.bind(projection, this.generationValue);
|
|
294
417
|
}
|
|
418
|
+
this.lifecycle = "active";
|
|
419
|
+
this.recoverAfterStartup();
|
|
295
420
|
}
|
|
296
421
|
catch (error) {
|
|
297
422
|
const failed = this.journal;
|
|
@@ -308,12 +433,14 @@ export class JournalManager {
|
|
|
308
433
|
this.clearSlots();
|
|
309
434
|
}
|
|
310
435
|
resume(operation, operationPath) {
|
|
311
|
-
const
|
|
312
|
-
const
|
|
436
|
+
const source = this.stableFingerprint(this.partitionDir, "journal recovery source");
|
|
437
|
+
const target = this.stableFingerprint(operation.quarantinePath, "journal quarantine target");
|
|
438
|
+
const sourceExists = source.exists;
|
|
439
|
+
const targetExists = target.exists;
|
|
313
440
|
if (sourceExists && targetExists)
|
|
314
441
|
return this.completeFromReceipt(operation, operationPath);
|
|
315
442
|
if (sourceExists) {
|
|
316
|
-
if (
|
|
443
|
+
if (source.fingerprint !== operation.expectedFingerprint) {
|
|
317
444
|
throw conflict("recovery_fingerprint_mismatch");
|
|
318
445
|
}
|
|
319
446
|
this.clearSlots();
|
|
@@ -322,7 +449,8 @@ export class JournalManager {
|
|
|
322
449
|
renameSync(this.partitionDir, operation.quarantinePath);
|
|
323
450
|
fsyncDirectory(this.journalRoot);
|
|
324
451
|
fsyncDirectory(dirname(operation.quarantinePath));
|
|
325
|
-
if (
|
|
452
|
+
if (this.stableFingerprint(operation.quarantinePath, "journal quarantine target")
|
|
453
|
+
.fingerprint !== operation.expectedFingerprint) {
|
|
326
454
|
throw typedError("recovery_quarantine_mismatch", 503, "quarantined bytes changed");
|
|
327
455
|
}
|
|
328
456
|
this.faults.afterQuarantineRename?.();
|
|
@@ -330,7 +458,7 @@ export class JournalManager {
|
|
|
330
458
|
else if (!targetExists) {
|
|
331
459
|
throw typedError("recovery_operation_missing", 503, "recovery source and target are missing");
|
|
332
460
|
}
|
|
333
|
-
else if (
|
|
461
|
+
else if (target.fingerprint !== operation.expectedFingerprint) {
|
|
334
462
|
throw typedError("recovery_quarantine_mismatch", 503, "quarantined bytes changed");
|
|
335
463
|
}
|
|
336
464
|
this.openGeneration();
|
|
@@ -358,52 +486,31 @@ export class JournalManager {
|
|
|
358
486
|
return receipt;
|
|
359
487
|
}
|
|
360
488
|
completeFromReceipt(operation, operationPath) {
|
|
361
|
-
if (fingerprintPartition(operation.quarantinePath) !== operation.expectedFingerprint) {
|
|
362
|
-
throw typedError("recovery_quarantine_mismatch", 503, "quarantined bytes changed");
|
|
363
|
-
}
|
|
364
489
|
if (!this.journal)
|
|
365
490
|
this.openGeneration();
|
|
366
491
|
if (!this.journal || this.recovery.status === "recovery_required") {
|
|
367
492
|
throw typedError("recovery_operation_ambiguous", 503, "fresh journal is unreadable");
|
|
368
493
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
494
|
+
return completePreparedReceipt({
|
|
495
|
+
rootDir: this.rootDir,
|
|
496
|
+
operation,
|
|
497
|
+
operationPath,
|
|
498
|
+
journal: this.journal,
|
|
499
|
+
partition: this.partition,
|
|
500
|
+
artifactPrefix: this.artifactPrefix,
|
|
501
|
+
});
|
|
376
502
|
}
|
|
377
503
|
reconcilePrepared() {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
};
|
|
389
|
-
})
|
|
390
|
-
.filter((entry) => entry.operation?.status === "prepared");
|
|
391
|
-
if (prepared.length === 0)
|
|
392
|
-
return false;
|
|
393
|
-
if (prepared.length !== 1)
|
|
394
|
-
throw new Error("multiple prepared recovery operations");
|
|
395
|
-
const pending = prepared[0];
|
|
396
|
-
if (existsSync(this.partitionDir) && !existsSync(pending.operation.quarantinePath)) {
|
|
397
|
-
this.openGeneration();
|
|
398
|
-
}
|
|
399
|
-
else {
|
|
400
|
-
this.resume(pending.operation, pending.path);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
catch (error) {
|
|
404
|
-
this.enterRecovery(recoveryFrom(error, "prepared quarantine reconciliation failed"));
|
|
405
|
-
}
|
|
406
|
-
return true;
|
|
504
|
+
return reconcilePreparedOperationOnStart({
|
|
505
|
+
operationsDir: this.operationsDir,
|
|
506
|
+
quarantineDir: this.quarantineDir,
|
|
507
|
+
partitionDir: this.partitionDir,
|
|
508
|
+
partition: this.partition,
|
|
509
|
+
artifactPrefix: this.artifactPrefix,
|
|
510
|
+
openGeneration: () => this.openGeneration(),
|
|
511
|
+
resume: (operation, path) => void this.resume(operation, path),
|
|
512
|
+
failed: (error) => this.enterRecovery(recoveryFrom(error, "prepared quarantine reconciliation failed")),
|
|
513
|
+
});
|
|
407
514
|
}
|
|
408
515
|
inspection(fingerprint) {
|
|
409
516
|
return {
|
|
@@ -418,8 +525,26 @@ export class JournalManager {
|
|
|
418
525
|
};
|
|
419
526
|
}
|
|
420
527
|
enterRecovery(state) {
|
|
528
|
+
const closeWriter = this.lifecycle === "active";
|
|
421
529
|
this.recovery = cloneRecovery(state);
|
|
530
|
+
this.lifecycle = "recovery_required";
|
|
422
531
|
this.clearSlots();
|
|
532
|
+
if (closeWriter) {
|
|
533
|
+
this.journal?.close();
|
|
534
|
+
this.journal = null;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
observeFingerprint(path, context) {
|
|
538
|
+
const observed = fingerprintPartition(path, this.rootDir);
|
|
539
|
+
if (observed.problem)
|
|
540
|
+
this.enterRecovery(recoveryAt(0, `${context}: ${observed.problem}`));
|
|
541
|
+
return observed;
|
|
542
|
+
}
|
|
543
|
+
stableFingerprint(path, context) {
|
|
544
|
+
const observed = this.observeFingerprint(path, context);
|
|
545
|
+
if (observed.problem)
|
|
546
|
+
throw new JournalRecoveryRequiredError(this.recovery);
|
|
547
|
+
return observed;
|
|
423
548
|
}
|
|
424
549
|
clearSlots() {
|
|
425
550
|
for (const registration of this.registrations.values())
|
|
@@ -427,101 +552,12 @@ export class JournalManager {
|
|
|
427
552
|
}
|
|
428
553
|
assertStarted() {
|
|
429
554
|
this.assertOpen();
|
|
430
|
-
if (
|
|
555
|
+
if (this.lifecycle === "idle")
|
|
431
556
|
throw new Error("journal manager is not running");
|
|
432
557
|
}
|
|
433
558
|
assertOpen() {
|
|
434
|
-
if (this.closed)
|
|
559
|
+
if (this.lifecycle === "closed")
|
|
435
560
|
throw new Error("journal manager is closed");
|
|
436
561
|
}
|
|
437
562
|
}
|
|
438
|
-
class ProjectionSlot {
|
|
439
|
-
recovery;
|
|
440
|
-
value = null;
|
|
441
|
-
generationValue = 0;
|
|
442
|
-
constructor(recovery) {
|
|
443
|
-
this.recovery = recovery;
|
|
444
|
-
}
|
|
445
|
-
current() {
|
|
446
|
-
if (this.value !== null)
|
|
447
|
-
return this.value;
|
|
448
|
-
const state = this.recovery();
|
|
449
|
-
throw new JournalRecoveryRequiredError(state.status === "recovery_required"
|
|
450
|
-
? state
|
|
451
|
-
: recoveryAt(0, "journal projection is unavailable"));
|
|
452
|
-
}
|
|
453
|
-
generation() {
|
|
454
|
-
return this.generationValue;
|
|
455
|
-
}
|
|
456
|
-
bind(value, generation) {
|
|
457
|
-
this.value = value;
|
|
458
|
-
this.generationValue = generation;
|
|
459
|
-
}
|
|
460
|
-
clear() {
|
|
461
|
-
this.value = null;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
function readOperation(path, quarantineDir, partition, artifactPrefix) {
|
|
465
|
-
if (!existsSync(path))
|
|
466
|
-
return null;
|
|
467
|
-
const value = JSON.parse(readOwnedFile(path).toString("utf8"));
|
|
468
|
-
if (!isRecord(value) ||
|
|
469
|
-
value.schemaVersion !== 1 ||
|
|
470
|
-
typeof value.operationId !== "string" ||
|
|
471
|
-
typeof value.keyDigest !== "string" ||
|
|
472
|
-
basename(path) !== `${value.keyDigest}.json` ||
|
|
473
|
-
typeof value.requestDigest !== "string" ||
|
|
474
|
-
typeof value.expectedFingerprint !== "string" ||
|
|
475
|
-
typeof value.quarantinePath !== "string" ||
|
|
476
|
-
value.quarantinePath !== join(quarantineDir, `${artifactPrefix}-${value.operationId}`) ||
|
|
477
|
-
(value.status !== "prepared" && value.status !== "completed")) {
|
|
478
|
-
throw new Error("recovery operation is malformed");
|
|
479
|
-
}
|
|
480
|
-
const operation = value;
|
|
481
|
-
if (operation.status === "prepared" && operation.receipt !== null) {
|
|
482
|
-
throw new Error("prepared recovery operation contains a receipt");
|
|
483
|
-
}
|
|
484
|
-
if (operation.status === "completed") {
|
|
485
|
-
matchingReceipt(operation, undefined, partition, artifactPrefix);
|
|
486
|
-
}
|
|
487
|
-
return operation;
|
|
488
|
-
}
|
|
489
|
-
function matchingReceipt(operation, value, partition, artifactPrefix) {
|
|
490
|
-
const receipt = QuarantineReceiptSchema.parse(value ?? operation.receipt);
|
|
491
|
-
if (receipt.operationId !== operation.operationId ||
|
|
492
|
-
receipt.partition !== partition ||
|
|
493
|
-
receipt.previousFingerprint !== operation.expectedFingerprint ||
|
|
494
|
-
receipt.quarantinePath !== operation.quarantinePath ||
|
|
495
|
-
receipt.quarantineArtifactId !== `${artifactPrefix}-${operation.operationId}`) {
|
|
496
|
-
throw typedError("recovery_receipt_mismatch", 503, "quarantine receipt does not match intent");
|
|
497
|
-
}
|
|
498
|
-
return receipt;
|
|
499
|
-
}
|
|
500
|
-
function validateRequest(input) {
|
|
501
|
-
if (!input.idempotencyKey || input.idempotencyKey.length > 256) {
|
|
502
|
-
throw typedError("invalid_idempotency_key", 400, "Idempotency-Key must contain 1-256 characters");
|
|
503
|
-
}
|
|
504
|
-
if (input.confirmation !== "quarantine_and_start_fresh") {
|
|
505
|
-
throw typedError("quarantine_confirmation_required", 400, "explicit confirmation is required");
|
|
506
|
-
}
|
|
507
|
-
if (!/^[a-f0-9]{64}$/.test(input.expectedFingerprint)) {
|
|
508
|
-
throw typedError("invalid_recovery_fingerprint", 400, "expectedFingerprint must be SHA-256");
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
function quarantineRequestDigest(partition, input) {
|
|
512
|
-
return sha256(Buffer.from(JSON.stringify({
|
|
513
|
-
partition,
|
|
514
|
-
expectedFingerprint: input.expectedFingerprint,
|
|
515
|
-
confirmation: input.confirmation,
|
|
516
|
-
})));
|
|
517
|
-
}
|
|
518
|
-
function conflict(code) {
|
|
519
|
-
return typedError(code, 409, code.replaceAll("_", " "));
|
|
520
|
-
}
|
|
521
|
-
function typedError(code, status, message) {
|
|
522
|
-
return Object.assign(new Error(message), { code, status });
|
|
523
|
-
}
|
|
524
|
-
function isRecord(value) {
|
|
525
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
526
|
-
}
|
|
527
563
|
//# sourceMappingURL=journal-manager.js.map
|