@averyyy/pi-server 0.82.1-piclient.2 → 0.82.1-piclient.4
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/CHANGELOG.md +11 -0
- package/README.md +25 -0
- package/bin/update.js +156 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pi-server-protocol.d.ts +16 -0
- package/dist/pi-server-protocol.d.ts.map +1 -1
- package/dist/pi-server-protocol.js +30 -1
- package/dist/pi-server-protocol.js.map +1 -1
- package/dist/receive-upload.d.ts +5 -0
- package/dist/receive-upload.d.ts.map +1 -1
- package/dist/receive-upload.js +88 -6
- package/dist/receive-upload.js.map +1 -1
- package/dist/request-chunks.d.ts +33 -0
- package/dist/request-chunks.d.ts.map +1 -1
- package/dist/request-chunks.js +540 -48
- package/dist/request-chunks.js.map +1 -1
- package/dist/server.d.ts +41 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3088 -366
- package/dist/server.js.map +1 -1
- package/dist/session-persistence.d.ts +39 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +1132 -116
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-store.d.ts +112 -6
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +695 -120
- package/dist/session-store.js.map +1 -1
- package/dist/stream-run-persistence.d.ts +163 -0
- package/dist/stream-run-persistence.d.ts.map +1 -0
- package/dist/stream-run-persistence.js +1824 -0
- package/dist/stream-run-persistence.js.map +1 -0
- package/package.json +3 -3
|
@@ -1,19 +1,195 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
const
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, truncateSync, writeSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { calculateSessionLogicalBytes, exportSessionState, getSession, getSessionCapacityLimits, getSessionCapacityUsage, markSessionPersisted, restoreSessionState, validatePersistedSessionState, } from "./session-store.js";
|
|
6
|
+
const LEGACY_SESSION_VERSION = 1;
|
|
7
|
+
const DURABLE_SESSION_VERSION = 2;
|
|
8
|
+
const LEGACY_DURABLE_WAL_VERSION = 4;
|
|
9
|
+
const DURABLE_WAL_VERSION = 5;
|
|
10
|
+
const HEAD_VERSION = 1;
|
|
11
|
+
const MIN_WAL_RECORDS_PER_SNAPSHOT = 32;
|
|
12
|
+
const MIN_WAL_BYTES_PER_SNAPSHOT = 1024 * 1024;
|
|
7
13
|
const REPLACE_RETRY_DELAYS_MS = [25, 50, 100, 200, 400];
|
|
14
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
15
|
+
const SESSION_FILE_PATTERN = /^[a-f0-9]{64}\.json$/u;
|
|
16
|
+
const SESSION_ARTIFACT_PATTERN = /^([a-f0-9]{64}\.json)\.(?:head|snapshot|wal)\.[01]$/u;
|
|
17
|
+
const LEGACY_WAL_PATTERN = /^([a-f0-9]{64}\.json)\.wal$/u;
|
|
18
|
+
const ZERO_DIGEST = "0".repeat(64);
|
|
19
|
+
export class SessionPersistenceCapacityError extends Error {
|
|
20
|
+
code = "PI_SERVER_SESSION_PERSISTENCE_CAPACITY_EXCEEDED";
|
|
21
|
+
retryable = false;
|
|
22
|
+
resource;
|
|
23
|
+
sessionId;
|
|
24
|
+
path;
|
|
25
|
+
current;
|
|
26
|
+
requested;
|
|
27
|
+
limit;
|
|
28
|
+
constructor(input) {
|
|
29
|
+
super(`Persisted session capacity exceeded: resource=${input.resource}, session=${input.sessionId}, path=${input.path}, requested=${input.requested}, limit=${input.limit}`);
|
|
30
|
+
this.name = "SessionPersistenceCapacityError";
|
|
31
|
+
this.resource = input.resource;
|
|
32
|
+
this.sessionId = input.sessionId;
|
|
33
|
+
this.path = input.path;
|
|
34
|
+
this.current = input.current;
|
|
35
|
+
this.requested = input.requested;
|
|
36
|
+
this.limit = input.limit;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const SESSION_ARTIFACT_ENVELOPE_OVERHEAD_BYTES = 1024 * 1024;
|
|
40
|
+
const WAL_ARTIFACT_ENCODING_MULTIPLIER = 2;
|
|
41
|
+
const DEFAULT_MAX_HEAD_BYTES = 64 * 1024;
|
|
42
|
+
let artifactLimitOverrides = {};
|
|
43
|
+
function defaultArtifactLimits() {
|
|
44
|
+
const capacity = getSessionCapacityLimits();
|
|
45
|
+
const maxSnapshotBytes = capacity.maxLogicalBytesPerSession + capacity.maxEntriesPerSession + SESSION_ARTIFACT_ENVELOPE_OVERHEAD_BYTES;
|
|
46
|
+
const maxWalBytes = capacity.maxLogicalBytesPerSession * WAL_ARTIFACT_ENCODING_MULTIPLIER +
|
|
47
|
+
capacity.maxEntriesPerSession * WAL_ARTIFACT_ENCODING_MULTIPLIER +
|
|
48
|
+
SESSION_ARTIFACT_ENVELOPE_OVERHEAD_BYTES * WAL_ARTIFACT_ENCODING_MULTIPLIER;
|
|
49
|
+
if (!Number.isSafeInteger(maxSnapshotBytes) || !Number.isSafeInteger(maxWalBytes)) {
|
|
50
|
+
throw new RangeError("Session capacity limits are too large to derive safe persistence artifact limits");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
maxSnapshotBytes,
|
|
54
|
+
maxWalBytes,
|
|
55
|
+
maxHeadBytes: DEFAULT_MAX_HEAD_BYTES,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function getSessionPersistenceArtifactLimits() {
|
|
59
|
+
return { ...defaultArtifactLimits(), ...artifactLimitOverrides };
|
|
60
|
+
}
|
|
61
|
+
export function configureSessionPersistenceArtifactLimits(overrides) {
|
|
62
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
63
|
+
if (!Number.isSafeInteger(value) || (value ?? 0) <= 0) {
|
|
64
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
artifactLimitOverrides = { ...artifactLimitOverrides, ...overrides };
|
|
68
|
+
return getSessionPersistenceArtifactLimits();
|
|
69
|
+
}
|
|
70
|
+
export function resetSessionPersistenceArtifactLimits() {
|
|
71
|
+
artifactLimitOverrides = {};
|
|
72
|
+
}
|
|
8
73
|
const persistedSessions = new Map();
|
|
74
|
+
const sessionsNeedingRecovery = new Set();
|
|
75
|
+
function persistenceCapacityError(resource, sessionId, path, current, requested, limit) {
|
|
76
|
+
return new SessionPersistenceCapacityError({ resource, sessionId, path, current, requested, limit });
|
|
77
|
+
}
|
|
78
|
+
function artifactLimit(kind) {
|
|
79
|
+
const limits = getSessionPersistenceArtifactLimits();
|
|
80
|
+
switch (kind) {
|
|
81
|
+
case "snapshot":
|
|
82
|
+
return { resource: "snapshot_artifact_bytes", limit: limits.maxSnapshotBytes };
|
|
83
|
+
case "wal":
|
|
84
|
+
return { resource: "wal_artifact_bytes", limit: limits.maxWalBytes };
|
|
85
|
+
case "head":
|
|
86
|
+
return { resource: "head_artifact_bytes", limit: limits.maxHeadBytes };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function readBoundedArtifactSync(path, kind, sessionId) {
|
|
90
|
+
const { resource, limit } = artifactLimit(kind);
|
|
91
|
+
const size = statSync(path).size;
|
|
92
|
+
if (size > limit) {
|
|
93
|
+
throw persistenceCapacityError(resource, sessionId, path, 0, size, limit);
|
|
94
|
+
}
|
|
95
|
+
const encoded = readFileSync(path);
|
|
96
|
+
if (encoded.byteLength > limit) {
|
|
97
|
+
throw persistenceCapacityError(resource, sessionId, path, 0, encoded.byteLength, limit);
|
|
98
|
+
}
|
|
99
|
+
return encoded;
|
|
100
|
+
}
|
|
101
|
+
function assertArtifactWriteSize(path, kind, sessionId, current, requested) {
|
|
102
|
+
const { resource, limit } = artifactLimit(kind);
|
|
103
|
+
if (requested > limit) {
|
|
104
|
+
throw persistenceCapacityError(resource, sessionId, path, current, requested, limit);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function assertStagedSessionCapacity(sessionId, path, current, next) {
|
|
108
|
+
const limits = getSessionCapacityLimits();
|
|
109
|
+
const usage = getSessionCapacityUsage();
|
|
110
|
+
const existing = getSession(sessionId);
|
|
111
|
+
if (!existing && usage.loadedSessions + 1 > limits.maxLoadedSessions) {
|
|
112
|
+
throw persistenceCapacityError("loaded_sessions", sessionId, path, usage.loadedSessions, usage.loadedSessions + 1, limits.maxLoadedSessions);
|
|
113
|
+
}
|
|
114
|
+
if (next.entryCount > limits.maxEntriesPerSession) {
|
|
115
|
+
throw persistenceCapacityError("session_entries", sessionId, path, current.entryCount, next.entryCount, limits.maxEntriesPerSession);
|
|
116
|
+
}
|
|
117
|
+
if (next.logicalBytes > limits.maxLogicalBytesPerSession) {
|
|
118
|
+
throw persistenceCapacityError("session_logical_bytes", sessionId, path, current.logicalBytes, next.logicalBytes, limits.maxLogicalBytesPerSession);
|
|
119
|
+
}
|
|
120
|
+
const requestedAggregateEntries = usage.entryCount - (existing?.entries.length ?? 0) + next.entryCount;
|
|
121
|
+
if (requestedAggregateEntries > limits.maxAggregateEntries) {
|
|
122
|
+
throw persistenceCapacityError("aggregate_entries", sessionId, path, usage.entryCount, requestedAggregateEntries, limits.maxAggregateEntries);
|
|
123
|
+
}
|
|
124
|
+
const requestedAggregateLogicalBytes = usage.logicalBytes - (existing?.logicalBytes ?? 0) + next.logicalBytes;
|
|
125
|
+
if (requestedAggregateLogicalBytes > limits.maxAggregateLogicalBytes) {
|
|
126
|
+
throw persistenceCapacityError("aggregate_logical_bytes", sessionId, path, usage.logicalBytes, requestedAggregateLogicalBytes, limits.maxAggregateLogicalBytes);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function createStagedSessionCapacity(session, path) {
|
|
130
|
+
const staged = {
|
|
131
|
+
entryCount: session.entries.length,
|
|
132
|
+
logicalBytes: calculateSessionLogicalBytes(session.staticContext, session.entries),
|
|
133
|
+
};
|
|
134
|
+
assertStagedSessionCapacity(session.sessionId, path, { entryCount: 0, logicalBytes: 0 }, staged);
|
|
135
|
+
return staged;
|
|
136
|
+
}
|
|
137
|
+
function nextWalCapacity(session, record, current) {
|
|
138
|
+
let nextStaticContext = session.staticContext;
|
|
139
|
+
if (record.version === 1) {
|
|
140
|
+
nextStaticContext = record.staticContext;
|
|
141
|
+
}
|
|
142
|
+
else if (record.version === 2 || record.version === 3) {
|
|
143
|
+
if (Object.hasOwn(record, "staticContext"))
|
|
144
|
+
nextStaticContext = record.staticContext;
|
|
145
|
+
}
|
|
146
|
+
else if (record.staticContextChanged) {
|
|
147
|
+
nextStaticContext = record.staticContext === null ? undefined : record.staticContext;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
entryCount: current.entryCount + record.entries.length,
|
|
151
|
+
logicalBytes: current.logicalBytes -
|
|
152
|
+
calculateSessionLogicalBytes(session.staticContext, []) +
|
|
153
|
+
calculateSessionLogicalBytes(nextStaticContext, []) +
|
|
154
|
+
calculateSessionLogicalBytes(undefined, record.entries),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function sha256(value) {
|
|
158
|
+
return createHash("sha256").update(value).digest("hex");
|
|
159
|
+
}
|
|
160
|
+
function stateDigest(session) {
|
|
161
|
+
return sha256(JSON.stringify(session));
|
|
162
|
+
}
|
|
163
|
+
function walTransitionStateDigest(record) {
|
|
164
|
+
return sha256(JSON.stringify({
|
|
165
|
+
previousStateDigest: record.previousStateDigest,
|
|
166
|
+
sessionId: record.sessionId,
|
|
167
|
+
baseRevision: record.baseRevision,
|
|
168
|
+
revision: record.revision,
|
|
169
|
+
baseEntryCount: record.baseEntryCount,
|
|
170
|
+
entries: record.entries,
|
|
171
|
+
leafId: record.leafId,
|
|
172
|
+
updatedAt: record.updatedAt,
|
|
173
|
+
staticContextChanged: record.staticContextChanged,
|
|
174
|
+
...(record.staticContextChanged ? { staticContext: record.staticContext ?? null } : {}),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
9
177
|
function sessionFileName(sessionId) {
|
|
10
|
-
return `${
|
|
178
|
+
return `${sha256(sessionId)}.json`;
|
|
11
179
|
}
|
|
12
180
|
function sessionPath(sessionStoreDir, sessionId) {
|
|
13
181
|
return join(sessionStoreDir, sessionFileName(sessionId));
|
|
14
182
|
}
|
|
15
|
-
function
|
|
16
|
-
|
|
183
|
+
function snapshotPath(sessionStoreDir, sessionId, generation) {
|
|
184
|
+
const basePath = sessionPath(sessionStoreDir, sessionId);
|
|
185
|
+
return generation === 0 ? basePath : `${basePath}.snapshot.${generation % 2}`;
|
|
186
|
+
}
|
|
187
|
+
function walPath(sessionStoreDir, sessionId, generation) {
|
|
188
|
+
const basePath = sessionPath(sessionStoreDir, sessionId);
|
|
189
|
+
return generation === 0 ? `${basePath}.wal` : `${basePath}.wal.${generation % 2}`;
|
|
190
|
+
}
|
|
191
|
+
function headPath(sessionStoreDir, sessionId, sequence) {
|
|
192
|
+
return `${sessionPath(sessionStoreDir, sessionId)}.head.${sequence % 2}`;
|
|
17
193
|
}
|
|
18
194
|
function persistedSessionKey(sessionStoreDir, sessionId) {
|
|
19
195
|
return `${sessionStoreDir}\0${sessionId}`;
|
|
@@ -41,6 +217,97 @@ function replaceFileSync(sourcePath, targetPath) {
|
|
|
41
217
|
}
|
|
42
218
|
}
|
|
43
219
|
}
|
|
220
|
+
function writeAllSync(handle, encoded) {
|
|
221
|
+
let offset = 0;
|
|
222
|
+
while (offset < encoded.byteLength) {
|
|
223
|
+
const written = writeSync(handle, encoded, offset, encoded.byteLength - offset);
|
|
224
|
+
if (written === 0) {
|
|
225
|
+
throw new Error("Filesystem write made no progress");
|
|
226
|
+
}
|
|
227
|
+
offset += written;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function syncDirectorySync(path) {
|
|
231
|
+
if (process.platform === "win32")
|
|
232
|
+
return;
|
|
233
|
+
const handle = openSync(path, "r");
|
|
234
|
+
try {
|
|
235
|
+
fsyncSync(handle);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
closeSync(handle);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function ensureDurableDirectorySync(path) {
|
|
242
|
+
if (existsSync(path))
|
|
243
|
+
return;
|
|
244
|
+
const missingDirectories = [];
|
|
245
|
+
let current = path;
|
|
246
|
+
while (!existsSync(current)) {
|
|
247
|
+
missingDirectories.push(current);
|
|
248
|
+
const parent = dirname(current);
|
|
249
|
+
if (parent === current)
|
|
250
|
+
break;
|
|
251
|
+
current = parent;
|
|
252
|
+
}
|
|
253
|
+
mkdirSync(path, { recursive: true });
|
|
254
|
+
for (const directory of missingDirectories.reverse()) {
|
|
255
|
+
syncDirectorySync(dirname(directory));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function writeDurableFileSync(path, encoded) {
|
|
259
|
+
const handle = openSync(path, "wx", 0o600);
|
|
260
|
+
try {
|
|
261
|
+
writeAllSync(handle, encoded);
|
|
262
|
+
fsyncSync(handle);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
closeSync(handle);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function replaceDurableFileSync(path, encoded) {
|
|
269
|
+
const tempPath = `${path}.${randomUUID()}.tmp`;
|
|
270
|
+
try {
|
|
271
|
+
writeDurableFileSync(tempPath, encoded);
|
|
272
|
+
replaceFileSync(tempPath, path);
|
|
273
|
+
syncDirectorySync(dirname(path));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
rmSync(tempPath, { force: true });
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function appendDurableFileSync(path, encoded) {
|
|
281
|
+
const existed = existsSync(path);
|
|
282
|
+
const handle = openSync(path, "a", 0o600);
|
|
283
|
+
try {
|
|
284
|
+
writeAllSync(handle, encoded);
|
|
285
|
+
fsyncSync(handle);
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
closeSync(handle);
|
|
289
|
+
}
|
|
290
|
+
if (!existed)
|
|
291
|
+
syncDirectorySync(dirname(path));
|
|
292
|
+
}
|
|
293
|
+
function truncateDurableFileSync(path, length) {
|
|
294
|
+
truncateSync(path, length);
|
|
295
|
+
const handle = openSync(path, "r+");
|
|
296
|
+
try {
|
|
297
|
+
fsyncSync(handle);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
closeSync(handle);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function performOrDeferRecovery(action, recoveryActions) {
|
|
304
|
+
if (recoveryActions) {
|
|
305
|
+
recoveryActions.push(action);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
action();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
44
311
|
function assertRecord(value, path) {
|
|
45
312
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
46
313
|
throw new Error(`${path} must be an object`);
|
|
@@ -51,9 +318,19 @@ function assertString(value, path) {
|
|
|
51
318
|
throw new Error(`${path} must be a string`);
|
|
52
319
|
}
|
|
53
320
|
}
|
|
54
|
-
function
|
|
55
|
-
if (typeof value !== "
|
|
56
|
-
throw new Error(`${path} must be a
|
|
321
|
+
function assertBoolean(value, path) {
|
|
322
|
+
if (typeof value !== "boolean") {
|
|
323
|
+
throw new Error(`${path} must be a boolean`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function assertDigest(value, path) {
|
|
327
|
+
if (typeof value !== "string" || !SHA256_PATTERN.test(value)) {
|
|
328
|
+
throw new Error(`${path} must be a 64-character lowercase hex digest`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function assertNonNegativeSafeInteger(value, path) {
|
|
332
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
333
|
+
throw new Error(`${path} must be a non-negative safe integer`);
|
|
57
334
|
}
|
|
58
335
|
}
|
|
59
336
|
function assertNullableString(value, path) {
|
|
@@ -61,199 +338,938 @@ function assertNullableString(value, path) {
|
|
|
61
338
|
throw new Error(`${path} must be a string or null`);
|
|
62
339
|
}
|
|
63
340
|
}
|
|
64
|
-
function assertStaticContext(value) {
|
|
341
|
+
function assertStaticContext(value, path = "session.staticContext") {
|
|
65
342
|
if (value === undefined)
|
|
66
343
|
return;
|
|
67
|
-
assertRecord(value,
|
|
344
|
+
assertRecord(value, path);
|
|
68
345
|
const systemPrompt = value.systemPrompt;
|
|
69
346
|
if (systemPrompt !== undefined && typeof systemPrompt !== "string") {
|
|
70
|
-
throw new Error(
|
|
347
|
+
throw new Error(`${path}.systemPrompt must be a string`);
|
|
71
348
|
}
|
|
72
349
|
const tools = value.tools;
|
|
73
350
|
if (tools !== undefined) {
|
|
74
351
|
if (!Array.isArray(tools)) {
|
|
75
|
-
throw new Error(
|
|
352
|
+
throw new Error(`${path}.tools must be an array`);
|
|
76
353
|
}
|
|
77
354
|
for (const [index, tool] of tools.entries()) {
|
|
78
|
-
assertRecord(tool,
|
|
79
|
-
assertString(tool.name,
|
|
80
|
-
assertString(tool.description,
|
|
355
|
+
assertRecord(tool, `${path}.tools[${index}]`);
|
|
356
|
+
assertString(tool.name, `${path}.tools[${index}].name`);
|
|
357
|
+
assertString(tool.description, `${path}.tools[${index}].description`);
|
|
81
358
|
if (tool.parameters === undefined) {
|
|
82
|
-
throw new Error(
|
|
359
|
+
throw new Error(`${path}.tools[${index}].parameters is required`);
|
|
83
360
|
}
|
|
84
361
|
}
|
|
85
362
|
}
|
|
86
363
|
}
|
|
87
|
-
function assertSessionTreeEntries(value) {
|
|
364
|
+
function assertSessionTreeEntries(value, path = "session.entries") {
|
|
88
365
|
if (!Array.isArray(value)) {
|
|
89
|
-
throw new Error(
|
|
366
|
+
throw new Error(`${path} must be an array`);
|
|
90
367
|
}
|
|
91
368
|
for (const [index, entry] of value.entries()) {
|
|
92
|
-
assertRecord(entry,
|
|
93
|
-
assertString(entry.type,
|
|
94
|
-
assertString(entry.id,
|
|
95
|
-
assertNullableString(entry.parentId,
|
|
96
|
-
assertString(entry.timestamp,
|
|
369
|
+
assertRecord(entry, `${path}[${index}]`);
|
|
370
|
+
assertString(entry.type, `${path}[${index}].type`);
|
|
371
|
+
assertString(entry.id, `${path}[${index}].id`);
|
|
372
|
+
assertNullableString(entry.parentId, `${path}[${index}].parentId`);
|
|
373
|
+
assertString(entry.timestamp, `${path}[${index}].timestamp`);
|
|
97
374
|
if (entry.type === "message") {
|
|
98
|
-
assertRecord(entry.message,
|
|
375
|
+
assertRecord(entry.message, `${path}[${index}].message`);
|
|
99
376
|
}
|
|
100
377
|
}
|
|
101
378
|
}
|
|
102
|
-
function assertPersistedSessionState(value) {
|
|
103
|
-
assertRecord(value, "session");
|
|
104
|
-
assertString(value.sessionId, "session.sessionId");
|
|
105
|
-
assertStaticContext(value.staticContext);
|
|
106
|
-
assertSessionTreeEntries(value.entries);
|
|
107
|
-
assertNullableString(value.leafId, "session.leafId");
|
|
108
|
-
assertNumber(value.revision, "session.revision");
|
|
109
|
-
assertNumber(value.createdAt, "session.createdAt");
|
|
110
|
-
assertNumber(value.updatedAt, "session.updatedAt");
|
|
111
|
-
}
|
|
112
|
-
function assertPersistedWalRecord(value, sourcePath, lineNumber) {
|
|
113
|
-
const path = `${sourcePath}:${lineNumber}`;
|
|
379
|
+
function assertPersistedSessionState(value, path = "session") {
|
|
114
380
|
assertRecord(value, path);
|
|
115
|
-
if (value.version !== PERSISTED_SESSION_VERSION) {
|
|
116
|
-
throw new Error(`Unsupported persisted session WAL version in ${path}`);
|
|
117
|
-
}
|
|
118
381
|
assertString(value.sessionId, `${path}.sessionId`);
|
|
119
|
-
|
|
120
|
-
assertSessionTreeEntries(value.entries);
|
|
382
|
+
assertStaticContext(value.staticContext, `${path}.staticContext`);
|
|
383
|
+
assertSessionTreeEntries(value.entries, `${path}.entries`);
|
|
121
384
|
assertNullableString(value.leafId, `${path}.leafId`);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
385
|
+
assertNonNegativeSafeInteger(value.revision, `${path}.revision`);
|
|
386
|
+
assertNonNegativeSafeInteger(value.createdAt, `${path}.createdAt`);
|
|
387
|
+
assertNonNegativeSafeInteger(value.updatedAt, `${path}.updatedAt`);
|
|
125
388
|
}
|
|
126
|
-
function
|
|
127
|
-
let parsed;
|
|
389
|
+
function parseJson(raw, message) {
|
|
128
390
|
try {
|
|
129
|
-
|
|
391
|
+
return JSON.parse(raw);
|
|
130
392
|
}
|
|
131
393
|
catch (error) {
|
|
132
|
-
throw new Error(
|
|
394
|
+
throw new Error(message, { cause: error });
|
|
133
395
|
}
|
|
396
|
+
}
|
|
397
|
+
function parsePersistedSessionFile(raw, sourcePath) {
|
|
398
|
+
const parsed = parseJson(raw, `Persisted session file contains invalid JSON: ${sourcePath}`);
|
|
134
399
|
assertRecord(parsed, "persisted session file");
|
|
135
|
-
if (parsed.version !==
|
|
400
|
+
if (parsed.version !== LEGACY_SESSION_VERSION && parsed.version !== DURABLE_SESSION_VERSION) {
|
|
136
401
|
throw new Error(`Unsupported persisted session version in ${sourcePath}`);
|
|
137
402
|
}
|
|
138
403
|
assertPersistedSessionState(parsed.session);
|
|
404
|
+
if (parsed.version === LEGACY_SESSION_VERSION) {
|
|
405
|
+
return { version: LEGACY_SESSION_VERSION, session: parsed.session };
|
|
406
|
+
}
|
|
407
|
+
assertNonNegativeSafeInteger(parsed.generation, `${sourcePath}.generation`);
|
|
408
|
+
assertDigest(parsed.stateDigest, `${sourcePath}.stateDigest`);
|
|
409
|
+
const actualStateDigest = stateDigest(parsed.session);
|
|
410
|
+
if (parsed.stateDigest !== actualStateDigest) {
|
|
411
|
+
throw new Error(`Persisted session snapshot state digest mismatch: ${sourcePath}`);
|
|
412
|
+
}
|
|
139
413
|
return {
|
|
140
|
-
version:
|
|
414
|
+
version: DURABLE_SESSION_VERSION,
|
|
415
|
+
generation: parsed.generation,
|
|
416
|
+
stateDigest: parsed.stateDigest,
|
|
141
417
|
session: parsed.session,
|
|
142
418
|
};
|
|
143
419
|
}
|
|
144
|
-
function
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
420
|
+
function assertLegacyWalRecord(value, sourcePath, lineNumber) {
|
|
421
|
+
const path = `${sourcePath}:${lineNumber}`;
|
|
422
|
+
assertRecord(value, path);
|
|
423
|
+
if (value.version !== 1 && value.version !== 2 && value.version !== 3) {
|
|
424
|
+
throw new Error(`Unsupported persisted session WAL version in ${path}`);
|
|
148
425
|
}
|
|
149
|
-
|
|
150
|
-
|
|
426
|
+
assertString(value.sessionId, `${path}.sessionId`);
|
|
427
|
+
assertNonNegativeSafeInteger(value.baseEntryCount, `${path}.baseEntryCount`);
|
|
428
|
+
assertSessionTreeEntries(value.entries, `${path}.entries`);
|
|
429
|
+
assertNullableString(value.leafId, `${path}.leafId`);
|
|
430
|
+
assertNonNegativeSafeInteger(value.revision, `${path}.revision`);
|
|
431
|
+
assertNonNegativeSafeInteger(value.updatedAt, `${path}.updatedAt`);
|
|
432
|
+
if (value.version === 1 || Object.hasOwn(value, "staticContext")) {
|
|
433
|
+
assertStaticContext(value.staticContext, `${path}.staticContext`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function parseLegacyWalLine(raw, sourcePath, lineNumber) {
|
|
437
|
+
const parsed = parseJson(raw, `Persisted session WAL contains invalid JSON: ${sourcePath}:${lineNumber}`);
|
|
438
|
+
assertRecord(parsed, `${sourcePath}:${lineNumber}`);
|
|
439
|
+
if (parsed.version !== 3 || typeof parsed.payload !== "string" || typeof parsed.sha256 !== "string") {
|
|
440
|
+
assertLegacyWalRecord(parsed, sourcePath, lineNumber);
|
|
441
|
+
return parsed;
|
|
442
|
+
}
|
|
443
|
+
assertDigest(parsed.sha256, `${sourcePath}:${lineNumber}.sha256`);
|
|
444
|
+
if (sha256(parsed.payload) !== parsed.sha256) {
|
|
445
|
+
throw new Error(`Persisted session WAL checksum mismatch: ${sourcePath}:${lineNumber}`);
|
|
446
|
+
}
|
|
447
|
+
const payload = parseJson(parsed.payload, `Persisted session WAL payload contains invalid JSON: ${sourcePath}:${lineNumber}`);
|
|
448
|
+
assertLegacyWalRecord(payload, sourcePath, lineNumber);
|
|
449
|
+
if (payload.version !== 3) {
|
|
450
|
+
throw new Error(`Persisted session WAL envelope payload version mismatch: ${sourcePath}:${lineNumber}`);
|
|
151
451
|
}
|
|
152
|
-
|
|
153
|
-
return parsed;
|
|
452
|
+
return payload;
|
|
154
453
|
}
|
|
155
|
-
function
|
|
454
|
+
function assertDurableWalRecord(value, sourcePath, lineNumber) {
|
|
455
|
+
const path = `${sourcePath}:${lineNumber}`;
|
|
456
|
+
assertRecord(value, path);
|
|
457
|
+
if (value.version !== LEGACY_DURABLE_WAL_VERSION && value.version !== DURABLE_WAL_VERSION) {
|
|
458
|
+
throw new Error(`Unsupported durable session WAL version in ${path}`);
|
|
459
|
+
}
|
|
460
|
+
assertString(value.sessionId, `${path}.sessionId`);
|
|
461
|
+
assertNonNegativeSafeInteger(value.generation, `${path}.generation`);
|
|
462
|
+
assertNonNegativeSafeInteger(value.sequence, `${path}.sequence`);
|
|
463
|
+
assertNonNegativeSafeInteger(value.baseRevision, `${path}.baseRevision`);
|
|
464
|
+
assertNonNegativeSafeInteger(value.revision, `${path}.revision`);
|
|
465
|
+
assertNonNegativeSafeInteger(value.baseEntryCount, `${path}.baseEntryCount`);
|
|
466
|
+
assertSessionTreeEntries(value.entries, `${path}.entries`);
|
|
467
|
+
assertNullableString(value.leafId, `${path}.leafId`);
|
|
468
|
+
assertNonNegativeSafeInteger(value.updatedAt, `${path}.updatedAt`);
|
|
469
|
+
assertDigest(value.previousStateDigest, `${path}.previousStateDigest`);
|
|
470
|
+
assertDigest(value.stateDigest, `${path}.stateDigest`);
|
|
471
|
+
assertDigest(value.previousWalDigest, `${path}.previousWalDigest`);
|
|
472
|
+
assertBoolean(value.staticContextChanged, `${path}.staticContextChanged`);
|
|
473
|
+
if (value.staticContextChanged) {
|
|
474
|
+
if (!Object.hasOwn(value, "staticContext")) {
|
|
475
|
+
throw new Error(`${path}.staticContext is required when staticContextChanged is true`);
|
|
476
|
+
}
|
|
477
|
+
if (value.staticContext !== null) {
|
|
478
|
+
assertStaticContext(value.staticContext, `${path}.staticContext`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else if (Object.hasOwn(value, "staticContext")) {
|
|
482
|
+
throw new Error(`${path}.staticContext must be omitted when staticContextChanged is false`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function parseDurableWalLine(raw, sourcePath, lineNumber) {
|
|
486
|
+
const parsed = parseJson(raw, `Persisted session WAL contains invalid JSON: ${sourcePath}:${lineNumber}`);
|
|
487
|
+
assertRecord(parsed, `${sourcePath}:${lineNumber}`);
|
|
488
|
+
if (parsed.version !== LEGACY_DURABLE_WAL_VERSION && parsed.version !== DURABLE_WAL_VERSION) {
|
|
489
|
+
throw new Error(`Expected durable session WAL envelope in ${sourcePath}:${lineNumber}`);
|
|
490
|
+
}
|
|
491
|
+
assertString(parsed.payload, `${sourcePath}:${lineNumber}.payload`);
|
|
492
|
+
assertDigest(parsed.sha256, `${sourcePath}:${lineNumber}.sha256`);
|
|
493
|
+
const digest = sha256(parsed.payload);
|
|
494
|
+
if (digest !== parsed.sha256) {
|
|
495
|
+
throw new Error(`Persisted session WAL checksum mismatch: ${sourcePath}:${lineNumber}`);
|
|
496
|
+
}
|
|
497
|
+
const payload = parseJson(parsed.payload, `Persisted session WAL payload contains invalid JSON: ${sourcePath}:${lineNumber}`);
|
|
498
|
+
assertDurableWalRecord(payload, sourcePath, lineNumber);
|
|
499
|
+
if (payload.version !== parsed.version) {
|
|
500
|
+
throw new Error(`Persisted session WAL envelope payload version mismatch: ${sourcePath}:${lineNumber}`);
|
|
501
|
+
}
|
|
502
|
+
return { record: payload, digest };
|
|
503
|
+
}
|
|
504
|
+
function assertHeadRecord(value, sourcePath) {
|
|
505
|
+
assertRecord(value, sourcePath);
|
|
506
|
+
if (value.version !== HEAD_VERSION) {
|
|
507
|
+
throw new Error(`Unsupported persisted session head version in ${sourcePath}`);
|
|
508
|
+
}
|
|
509
|
+
assertString(value.sessionId, `${sourcePath}.sessionId`);
|
|
510
|
+
assertNonNegativeSafeInteger(value.sequence, `${sourcePath}.sequence`);
|
|
511
|
+
assertNonNegativeSafeInteger(value.generation, `${sourcePath}.generation`);
|
|
512
|
+
assertDigest(value.previousHeadDigest, `${sourcePath}.previousHeadDigest`);
|
|
513
|
+
assertDigest(value.snapshotSha256, `${sourcePath}.snapshotSha256`);
|
|
514
|
+
assertDigest(value.snapshotStateDigest, `${sourcePath}.snapshotStateDigest`);
|
|
515
|
+
assertNonNegativeSafeInteger(value.snapshotRevision, `${sourcePath}.snapshotRevision`);
|
|
516
|
+
assertNonNegativeSafeInteger(value.snapshotEntryCount, `${sourcePath}.snapshotEntryCount`);
|
|
517
|
+
assertNonNegativeSafeInteger(value.walRecordCount, `${sourcePath}.walRecordCount`);
|
|
518
|
+
assertNonNegativeSafeInteger(value.walByteLength, `${sourcePath}.walByteLength`);
|
|
519
|
+
assertDigest(value.walTailDigest, `${sourcePath}.walTailDigest`);
|
|
520
|
+
assertDigest(value.stateDigest, `${sourcePath}.stateDigest`);
|
|
521
|
+
assertNonNegativeSafeInteger(value.revision, `${sourcePath}.revision`);
|
|
522
|
+
assertNonNegativeSafeInteger(value.entryCount, `${sourcePath}.entryCount`);
|
|
523
|
+
}
|
|
524
|
+
function parseHeadFile(path, slot, sessionId) {
|
|
525
|
+
const parsed = parseJson(readBoundedArtifactSync(path, "head", sessionId).toString("utf-8"), `Persisted session head contains invalid JSON: ${path}`);
|
|
526
|
+
assertRecord(parsed, path);
|
|
527
|
+
if (parsed.version !== HEAD_VERSION) {
|
|
528
|
+
throw new Error(`Unsupported persisted session head envelope version in ${path}`);
|
|
529
|
+
}
|
|
530
|
+
assertString(parsed.payload, `${path}.payload`);
|
|
531
|
+
assertDigest(parsed.sha256, `${path}.sha256`);
|
|
532
|
+
const digest = sha256(parsed.payload);
|
|
533
|
+
if (digest !== parsed.sha256) {
|
|
534
|
+
throw new Error(`Persisted session head checksum mismatch: ${path}`);
|
|
535
|
+
}
|
|
536
|
+
const payload = parseJson(parsed.payload, `Persisted session head payload contains invalid JSON: ${path}`);
|
|
537
|
+
assertHeadRecord(payload, path);
|
|
538
|
+
if (payload.sequence % 2 !== slot) {
|
|
539
|
+
throw new Error(`Persisted session head is stored in the wrong slot: ${path}`);
|
|
540
|
+
}
|
|
541
|
+
return { record: payload, digest };
|
|
542
|
+
}
|
|
543
|
+
function readLatestHead(sessionStoreDir, sessionId) {
|
|
544
|
+
const heads = [];
|
|
545
|
+
for (const slot of [0, 1]) {
|
|
546
|
+
const path = headPath(sessionStoreDir, sessionId, slot);
|
|
547
|
+
if (existsSync(path))
|
|
548
|
+
heads.push(parseHeadFile(path, slot, sessionId));
|
|
549
|
+
}
|
|
550
|
+
if (heads.length === 0)
|
|
551
|
+
return undefined;
|
|
552
|
+
heads.sort((left, right) => left.record.sequence - right.record.sequence);
|
|
553
|
+
if (heads.length === 1) {
|
|
554
|
+
const only = heads[0];
|
|
555
|
+
if (only.record.sequence !== 0 || only.record.previousHeadDigest !== ZERO_DIGEST) {
|
|
556
|
+
throw new Error(`Persisted session head chain is incomplete for ${sessionId}`);
|
|
557
|
+
}
|
|
558
|
+
return only;
|
|
559
|
+
}
|
|
560
|
+
const [previous, latest] = heads;
|
|
561
|
+
if (latest.record.sequence !== previous.record.sequence + 1) {
|
|
562
|
+
throw new Error(`Persisted session head sequence is not contiguous for ${sessionId}`);
|
|
563
|
+
}
|
|
564
|
+
if (latest.record.previousHeadDigest !== previous.digest) {
|
|
565
|
+
throw new Error(`Persisted session head digest chain mismatch for ${sessionId}`);
|
|
566
|
+
}
|
|
567
|
+
if (latest.record.sessionId !== previous.record.sessionId) {
|
|
568
|
+
throw new Error(`Persisted session head sessionId chain mismatch for ${sessionId}`);
|
|
569
|
+
}
|
|
570
|
+
return latest;
|
|
571
|
+
}
|
|
572
|
+
function applyLegacyWalRecord(session, record, capacity, sourcePath) {
|
|
156
573
|
if (record.sessionId !== session.sessionId) {
|
|
157
574
|
throw new Error(`Persisted session WAL sessionId does not match snapshot: ${record.sessionId}`);
|
|
158
575
|
}
|
|
159
576
|
if (record.baseEntryCount < session.entries.length) {
|
|
160
|
-
if (record.baseEntryCount + record.entries.length <= session.entries.length)
|
|
577
|
+
if (record.baseEntryCount + record.entries.length <= session.entries.length) {
|
|
578
|
+
const existingEntries = session.entries.slice(record.baseEntryCount, record.baseEntryCount + record.entries.length);
|
|
579
|
+
if (!isDeepStrictEqual(existingEntries, record.entries)) {
|
|
580
|
+
throw new Error(`Persisted session WAL diverges from snapshot for ${record.sessionId}`);
|
|
581
|
+
}
|
|
161
582
|
return;
|
|
583
|
+
}
|
|
162
584
|
throw new Error(`Persisted session WAL overlaps snapshot for ${record.sessionId}`);
|
|
163
585
|
}
|
|
164
586
|
if (record.baseEntryCount !== session.entries.length) {
|
|
165
587
|
throw new Error(`Persisted session WAL has a gap for ${record.sessionId}`);
|
|
166
588
|
}
|
|
167
|
-
|
|
589
|
+
if (record.revision < session.revision) {
|
|
590
|
+
throw new Error(`Persisted session WAL revision regresses for ${record.sessionId}`);
|
|
591
|
+
}
|
|
592
|
+
if (record.updatedAt < session.updatedAt) {
|
|
593
|
+
throw new Error(`Persisted session WAL updatedAt regresses for ${record.sessionId}`);
|
|
594
|
+
}
|
|
595
|
+
const nextCapacity = nextWalCapacity(session, record, capacity);
|
|
596
|
+
assertStagedSessionCapacity(session.sessionId, sourcePath, capacity, nextCapacity);
|
|
597
|
+
session.entries = session.entries.concat(structuredClone(record.entries));
|
|
168
598
|
session.leafId = record.leafId;
|
|
169
599
|
session.revision = record.revision;
|
|
170
600
|
session.updatedAt = record.updatedAt;
|
|
171
|
-
|
|
601
|
+
if (record.version === 1 || Object.hasOwn(record, "staticContext")) {
|
|
602
|
+
session.staticContext = record.staticContext === undefined ? undefined : structuredClone(record.staticContext);
|
|
603
|
+
}
|
|
604
|
+
capacity.entryCount = nextCapacity.entryCount;
|
|
605
|
+
capacity.logicalBytes = nextCapacity.logicalBytes;
|
|
606
|
+
}
|
|
607
|
+
function readCommittedLegacyWal(path, sessionId, recoveryActions) {
|
|
608
|
+
const encoded = readBoundedArtifactSync(path, "wal", sessionId);
|
|
609
|
+
const lastNewline = encoded.lastIndexOf(0x0a);
|
|
610
|
+
const committedBytes = lastNewline + 1;
|
|
611
|
+
if (committedBytes !== encoded.byteLength) {
|
|
612
|
+
performOrDeferRecovery(() => truncateDurableFileSync(path, committedBytes), recoveryActions);
|
|
613
|
+
}
|
|
614
|
+
const lines = encoded.subarray(0, committedBytes).toString("utf-8").split("\n").filter(Boolean);
|
|
615
|
+
return { encoded: encoded.subarray(0, committedBytes), lines };
|
|
172
616
|
}
|
|
173
|
-
function
|
|
174
|
-
const
|
|
175
|
-
if (!existsSync(
|
|
617
|
+
function applyLegacyWal(sessionStoreDir, session, capacity, recoveryActions) {
|
|
618
|
+
const path = walPath(sessionStoreDir, session.sessionId, 0);
|
|
619
|
+
if (!existsSync(path))
|
|
176
620
|
return 0;
|
|
177
|
-
const lines =
|
|
178
|
-
|
|
621
|
+
const { lines } = readCommittedLegacyWal(path, session.sessionId, recoveryActions);
|
|
622
|
+
for (const [index, line] of lines.entries()) {
|
|
623
|
+
applyLegacyWalRecord(session, parseLegacyWalLine(line, path, index + 1), capacity, path);
|
|
624
|
+
}
|
|
625
|
+
return lines.length;
|
|
626
|
+
}
|
|
627
|
+
function applyDurableWalRecord(session, record, recordDigest, expectedGeneration, expectedSequence, expectedStateDigest, expectedWalDigest, capacity, sourcePath) {
|
|
628
|
+
if (record.sessionId !== session.sessionId) {
|
|
629
|
+
throw new Error(`Persisted session WAL sessionId does not match snapshot: ${record.sessionId}`);
|
|
630
|
+
}
|
|
631
|
+
if (record.generation !== expectedGeneration) {
|
|
632
|
+
throw new Error(`Persisted session WAL generation mismatch for ${record.sessionId}`);
|
|
633
|
+
}
|
|
634
|
+
if (record.sequence !== expectedSequence) {
|
|
635
|
+
throw new Error(`Persisted session WAL sequence mismatch for ${record.sessionId}`);
|
|
636
|
+
}
|
|
637
|
+
if (record.baseRevision !== session.revision || record.revision <= record.baseRevision) {
|
|
638
|
+
throw new Error(`Persisted session WAL revision chain mismatch for ${record.sessionId}`);
|
|
639
|
+
}
|
|
640
|
+
if (record.baseEntryCount !== session.entries.length) {
|
|
641
|
+
throw new Error(`Persisted session WAL entry chain mismatch for ${record.sessionId}`);
|
|
642
|
+
}
|
|
643
|
+
if (record.previousStateDigest !== expectedStateDigest) {
|
|
644
|
+
throw new Error(`Persisted session WAL previous state digest mismatch for ${record.sessionId}`);
|
|
645
|
+
}
|
|
646
|
+
if (record.previousWalDigest !== expectedWalDigest) {
|
|
647
|
+
throw new Error(`Persisted session WAL digest chain mismatch for ${record.sessionId}`);
|
|
648
|
+
}
|
|
649
|
+
if (record.updatedAt < session.updatedAt) {
|
|
650
|
+
throw new Error(`Persisted session WAL updatedAt regresses for ${record.sessionId}`);
|
|
651
|
+
}
|
|
652
|
+
const nextCapacity = nextWalCapacity(session, record, capacity);
|
|
653
|
+
assertStagedSessionCapacity(session.sessionId, sourcePath, capacity, nextCapacity);
|
|
654
|
+
session.entries = session.entries.concat(structuredClone(record.entries));
|
|
655
|
+
session.leafId = record.leafId;
|
|
656
|
+
session.revision = record.revision;
|
|
657
|
+
session.updatedAt = record.updatedAt;
|
|
658
|
+
if (record.staticContextChanged) {
|
|
659
|
+
session.staticContext =
|
|
660
|
+
record.staticContext === null || record.staticContext === undefined
|
|
661
|
+
? undefined
|
|
662
|
+
: structuredClone(record.staticContext);
|
|
663
|
+
}
|
|
664
|
+
const actualStateDigest = record.version === LEGACY_DURABLE_WAL_VERSION ? stateDigest(session) : walTransitionStateDigest(record);
|
|
665
|
+
if (record.stateDigest !== actualStateDigest) {
|
|
666
|
+
throw new Error(`Persisted session WAL post state digest mismatch for ${record.sessionId}`);
|
|
667
|
+
}
|
|
668
|
+
capacity.entryCount = nextCapacity.entryCount;
|
|
669
|
+
capacity.logicalBytes = nextCapacity.logicalBytes;
|
|
670
|
+
return { stateDigest: actualStateDigest, walDigest: recordDigest };
|
|
671
|
+
}
|
|
672
|
+
function readDurableWal(sessionStoreDir, session, head, capacity, recoveryActions) {
|
|
673
|
+
const path = walPath(sessionStoreDir, session.sessionId, head.generation);
|
|
674
|
+
if (!existsSync(path)) {
|
|
675
|
+
throw new Error(`Persisted session WAL is missing: ${path}`);
|
|
676
|
+
}
|
|
677
|
+
const encoded = readBoundedArtifactSync(path, "wal", session.sessionId);
|
|
678
|
+
if (encoded.byteLength < head.walByteLength) {
|
|
679
|
+
throw new Error(`Persisted session WAL is shorter than its durable head: ${path}`);
|
|
680
|
+
}
|
|
681
|
+
if (encoded.byteLength > head.walByteLength) {
|
|
682
|
+
performOrDeferRecovery(() => truncateDurableFileSync(path, head.walByteLength), recoveryActions);
|
|
683
|
+
}
|
|
684
|
+
const committed = encoded.subarray(0, head.walByteLength);
|
|
685
|
+
if (committed.byteLength > 0 && committed.at(-1) !== 0x0a) {
|
|
686
|
+
throw new Error(`Persisted session WAL durable head does not end at a record boundary: ${path}`);
|
|
687
|
+
}
|
|
688
|
+
const lines = committed.toString("utf-8").split("\n").filter(Boolean);
|
|
689
|
+
if (lines.length !== head.walRecordCount) {
|
|
690
|
+
throw new Error(`Persisted session WAL record count does not match its durable head: ${path}`);
|
|
691
|
+
}
|
|
692
|
+
let expectedStateDigest = head.snapshotStateDigest;
|
|
693
|
+
let expectedWalDigest = head.snapshotSha256;
|
|
179
694
|
for (const [index, line] of lines.entries()) {
|
|
180
|
-
|
|
695
|
+
const parsed = parseDurableWalLine(line, path, index + 1);
|
|
696
|
+
const applied = applyDurableWalRecord(session, parsed.record, parsed.digest, head.generation, index, expectedStateDigest, expectedWalDigest, capacity, path);
|
|
697
|
+
expectedStateDigest = applied.stateDigest;
|
|
698
|
+
expectedWalDigest = applied.walDigest;
|
|
699
|
+
}
|
|
700
|
+
if (expectedStateDigest !== head.stateDigest ||
|
|
701
|
+
expectedWalDigest !== head.walTailDigest ||
|
|
702
|
+
session.revision !== head.revision ||
|
|
703
|
+
session.entries.length !== head.entryCount) {
|
|
704
|
+
throw new Error(`Persisted session WAL final state does not match its durable head: ${path}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function rollbackUncommittedFutureGeneration(sessionStoreDir, sessionId, headGeneration, recoveryActions) {
|
|
708
|
+
const basePath = sessionPath(sessionStoreDir, sessionId);
|
|
709
|
+
let removed = false;
|
|
710
|
+
let deferredRemoval = false;
|
|
711
|
+
for (const slot of [0, 1]) {
|
|
712
|
+
const path = `${basePath}.snapshot.${slot}`;
|
|
713
|
+
if (!existsSync(path))
|
|
714
|
+
continue;
|
|
715
|
+
const parsed = parsePersistedSessionFile(readBoundedArtifactSync(path, "snapshot", sessionId).toString("utf-8"), path);
|
|
716
|
+
if (parsed.version !== DURABLE_SESSION_VERSION) {
|
|
717
|
+
throw new Error(`Persisted session snapshot slot uses a legacy format: ${path}`);
|
|
718
|
+
}
|
|
719
|
+
createStagedSessionCapacity(parsed.session, path);
|
|
720
|
+
if (parsed.generation % 2 !== slot) {
|
|
721
|
+
throw new Error(`Persisted session snapshot is stored in the wrong slot: ${path}`);
|
|
722
|
+
}
|
|
723
|
+
if (parsed.generation <= headGeneration)
|
|
181
724
|
continue;
|
|
182
|
-
|
|
183
|
-
|
|
725
|
+
if (parsed.generation !== headGeneration + 1) {
|
|
726
|
+
throw new Error(`Persisted session snapshot generation is ahead of its durable head: ${path}`);
|
|
727
|
+
}
|
|
728
|
+
performOrDeferRecovery(() => {
|
|
729
|
+
rmSync(path);
|
|
730
|
+
rmSync(`${basePath}.wal.${slot}`, { force: true });
|
|
731
|
+
}, recoveryActions);
|
|
732
|
+
removed = recoveryActions === undefined;
|
|
733
|
+
deferredRemoval = recoveryActions !== undefined;
|
|
734
|
+
}
|
|
735
|
+
if (removed) {
|
|
736
|
+
syncDirectorySync(sessionStoreDir);
|
|
737
|
+
}
|
|
738
|
+
else if (recoveryActions && deferredRemoval) {
|
|
739
|
+
recoveryActions.push(() => syncDirectorySync(sessionStoreDir));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function loadDurableSession(sessionStoreDir, sessionId, head, recoveryActions) {
|
|
743
|
+
const record = head.record;
|
|
744
|
+
if (record.sessionId !== sessionId) {
|
|
745
|
+
throw new Error(`Persisted session head sessionId does not match snapshot: ${record.sessionId}`);
|
|
746
|
+
}
|
|
747
|
+
rollbackUncommittedFutureGeneration(sessionStoreDir, sessionId, record.generation, recoveryActions);
|
|
748
|
+
const path = snapshotPath(sessionStoreDir, sessionId, record.generation);
|
|
749
|
+
if (!existsSync(path)) {
|
|
750
|
+
throw new Error(`Persisted session snapshot referenced by durable head is missing: ${path}`);
|
|
751
|
+
}
|
|
752
|
+
const encoded = readBoundedArtifactSync(path, "snapshot", sessionId);
|
|
753
|
+
if (sha256(encoded) !== record.snapshotSha256) {
|
|
754
|
+
throw new Error(`Persisted session snapshot checksum does not match its durable head: ${path}`);
|
|
755
|
+
}
|
|
756
|
+
const persisted = parsePersistedSessionFile(encoded.toString("utf-8"), path);
|
|
757
|
+
if (persisted.version !== DURABLE_SESSION_VERSION || persisted.generation !== record.generation) {
|
|
758
|
+
throw new Error(`Persisted session snapshot generation does not match its durable head: ${path}`);
|
|
759
|
+
}
|
|
760
|
+
if (persisted.stateDigest !== record.snapshotStateDigest ||
|
|
761
|
+
persisted.session.revision !== record.snapshotRevision ||
|
|
762
|
+
persisted.session.entries.length !== record.snapshotEntryCount) {
|
|
763
|
+
throw new Error(`Persisted session snapshot state does not match its durable head: ${path}`);
|
|
764
|
+
}
|
|
765
|
+
const capacity = createStagedSessionCapacity(persisted.session, path);
|
|
766
|
+
readDurableWal(sessionStoreDir, persisted.session, record, capacity, recoveryActions);
|
|
767
|
+
return {
|
|
768
|
+
session: persisted.session,
|
|
769
|
+
meta: {
|
|
770
|
+
format: "durable",
|
|
771
|
+
generation: record.generation,
|
|
772
|
+
headSequence: record.sequence,
|
|
773
|
+
headDigest: head.digest,
|
|
774
|
+
snapshotSha256: record.snapshotSha256,
|
|
775
|
+
snapshotStateDigest: record.snapshotStateDigest,
|
|
776
|
+
snapshotRevision: record.snapshotRevision,
|
|
777
|
+
snapshotEntryCount: record.snapshotEntryCount,
|
|
778
|
+
snapshotByteLength: encoded.byteLength,
|
|
779
|
+
entryCount: record.entryCount,
|
|
780
|
+
walRecords: record.walRecordCount,
|
|
781
|
+
walByteLength: record.walByteLength,
|
|
782
|
+
walTailDigest: record.walTailDigest,
|
|
783
|
+
stateDigest: record.stateDigest,
|
|
784
|
+
revision: record.revision,
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
function assertAnchoredArtifacts(sessionStoreDir, entries) {
|
|
789
|
+
const names = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
|
|
790
|
+
for (const name of names) {
|
|
791
|
+
const match = SESSION_ARTIFACT_PATTERN.exec(name) ?? LEGACY_WAL_PATTERN.exec(name);
|
|
792
|
+
if (match && !names.has(match[1])) {
|
|
793
|
+
throw new Error(`Persisted session artifact has no anchor snapshot: ${join(sessionStoreDir, name)}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function assertStagedSessionBatchCapacity(stagedSessions) {
|
|
798
|
+
const limits = getSessionCapacityLimits();
|
|
799
|
+
const usage = getSessionCapacityUsage();
|
|
800
|
+
let requestedLoadedSessions = usage.loadedSessions;
|
|
801
|
+
let requestedEntries = usage.entryCount;
|
|
802
|
+
let requestedLogicalBytes = usage.logicalBytes;
|
|
803
|
+
const stagedIds = new Set();
|
|
804
|
+
for (const staged of stagedSessions) {
|
|
805
|
+
if (stagedIds.has(staged.session.sessionId)) {
|
|
806
|
+
throw new Error(`Persisted session ${staged.session.sessionId} is staged more than once`);
|
|
807
|
+
}
|
|
808
|
+
stagedIds.add(staged.session.sessionId);
|
|
809
|
+
const existing = getSession(staged.session.sessionId);
|
|
810
|
+
if (!existing)
|
|
811
|
+
requestedLoadedSessions++;
|
|
812
|
+
requestedEntries += staged.session.entries.length - (existing?.entries.length ?? 0);
|
|
813
|
+
requestedLogicalBytes +=
|
|
814
|
+
calculateSessionLogicalBytes(staged.session.staticContext, staged.session.entries) -
|
|
815
|
+
(existing?.logicalBytes ?? 0);
|
|
816
|
+
if (requestedLoadedSessions > limits.maxLoadedSessions) {
|
|
817
|
+
throw persistenceCapacityError("loaded_sessions", staged.session.sessionId, staged.path, usage.loadedSessions, requestedLoadedSessions, limits.maxLoadedSessions);
|
|
818
|
+
}
|
|
819
|
+
if (requestedEntries > limits.maxAggregateEntries) {
|
|
820
|
+
throw persistenceCapacityError("aggregate_entries", staged.session.sessionId, staged.path, usage.entryCount, requestedEntries, limits.maxAggregateEntries);
|
|
821
|
+
}
|
|
822
|
+
if (requestedLogicalBytes > limits.maxAggregateLogicalBytes) {
|
|
823
|
+
throw persistenceCapacityError("aggregate_logical_bytes", staged.session.sessionId, staged.path, usage.logicalBytes, requestedLogicalBytes, limits.maxAggregateLogicalBytes);
|
|
824
|
+
}
|
|
184
825
|
}
|
|
185
|
-
return applied;
|
|
186
826
|
}
|
|
187
827
|
export function loadPersistedSessions(sessionStoreDir) {
|
|
188
828
|
if (!existsSync(sessionStoreDir))
|
|
189
829
|
return;
|
|
190
830
|
const entries = readdirSync(sessionStoreDir, { withFileTypes: true });
|
|
831
|
+
assertAnchoredArtifacts(sessionStoreDir, entries);
|
|
832
|
+
const stagedSessions = [];
|
|
833
|
+
const recoveryActions = [];
|
|
191
834
|
for (const entry of entries) {
|
|
192
|
-
if (!entry.isFile() || !entry.name
|
|
835
|
+
if (!entry.isFile() || !SESSION_FILE_PATTERN.test(entry.name))
|
|
193
836
|
continue;
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
837
|
+
const anchorPath = join(sessionStoreDir, entry.name);
|
|
838
|
+
const unresolvedSessionId = `<unresolved:${entry.name}>`;
|
|
839
|
+
const anchor = parsePersistedSessionFile(readBoundedArtifactSync(anchorPath, "snapshot", unresolvedSessionId).toString("utf-8"), anchorPath);
|
|
840
|
+
const expectedFileName = sessionFileName(anchor.session.sessionId);
|
|
197
841
|
if (entry.name !== expectedFileName) {
|
|
198
|
-
throw new Error(`Persisted session file name does not match sessionId: ${
|
|
842
|
+
throw new Error(`Persisted session file name does not match sessionId: ${anchorPath}`);
|
|
843
|
+
}
|
|
844
|
+
const sessionId = anchor.session.sessionId;
|
|
845
|
+
const capacity = createStagedSessionCapacity(anchor.session, anchorPath);
|
|
846
|
+
const head = readLatestHead(sessionStoreDir, sessionId);
|
|
847
|
+
if (head) {
|
|
848
|
+
const loaded = loadDurableSession(sessionStoreDir, sessionId, head, recoveryActions);
|
|
849
|
+
const key = persistedSessionKey(sessionStoreDir, sessionId);
|
|
850
|
+
stagedSessions.push({ session: loaded.session, meta: loaded.meta, key, path: anchorPath });
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
const basePath = sessionPath(sessionStoreDir, sessionId);
|
|
854
|
+
if (anchor.version === DURABLE_SESSION_VERSION ||
|
|
855
|
+
existsSync(`${basePath}.snapshot.0`) ||
|
|
856
|
+
existsSync(`${basePath}.snapshot.1`) ||
|
|
857
|
+
existsSync(`${basePath}.wal.0`) ||
|
|
858
|
+
existsSync(`${basePath}.wal.1`)) {
|
|
859
|
+
throw new Error(`Persisted session durable artifacts are missing their head for ${sessionId}`);
|
|
199
860
|
}
|
|
200
|
-
const walRecords =
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
861
|
+
const walRecords = applyLegacyWal(sessionStoreDir, anchor.session, capacity, recoveryActions);
|
|
862
|
+
const key = persistedSessionKey(sessionStoreDir, sessionId);
|
|
863
|
+
stagedSessions.push({
|
|
864
|
+
session: anchor.session,
|
|
865
|
+
meta: {
|
|
866
|
+
format: "legacy",
|
|
867
|
+
entryCount: anchor.session.entries.length,
|
|
868
|
+
walRecords,
|
|
869
|
+
revision: anchor.session.revision,
|
|
870
|
+
},
|
|
871
|
+
key,
|
|
872
|
+
path: anchorPath,
|
|
205
873
|
});
|
|
206
874
|
}
|
|
875
|
+
assertStagedSessionBatchCapacity(stagedSessions);
|
|
876
|
+
for (const staged of stagedSessions) {
|
|
877
|
+
validatePersistedSessionState(staged.session);
|
|
878
|
+
}
|
|
879
|
+
for (const recoveryAction of recoveryActions)
|
|
880
|
+
recoveryAction();
|
|
881
|
+
for (const staged of stagedSessions) {
|
|
882
|
+
restoreSessionState(staged.session);
|
|
883
|
+
}
|
|
884
|
+
for (const staged of stagedSessions) {
|
|
885
|
+
persistedSessions.set(staged.key, staged.meta);
|
|
886
|
+
sessionsNeedingRecovery.delete(staged.key);
|
|
887
|
+
}
|
|
207
888
|
}
|
|
208
|
-
function
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
const tempPath = `${filePath}.${randomUUID()}.tmp`;
|
|
889
|
+
function encodeSnapshot(session, generation) {
|
|
890
|
+
const state = exportSessionState(session);
|
|
891
|
+
const digest = stateDigest(state);
|
|
212
892
|
const body = {
|
|
213
|
-
version:
|
|
214
|
-
|
|
893
|
+
version: DURABLE_SESSION_VERSION,
|
|
894
|
+
generation,
|
|
895
|
+
stateDigest: digest,
|
|
896
|
+
session: state,
|
|
215
897
|
};
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
898
|
+
const encoded = Buffer.from(JSON.stringify(body), "utf-8");
|
|
899
|
+
return { encoded, state, stateDigest: digest, snapshotDigest: sha256(encoded) };
|
|
900
|
+
}
|
|
901
|
+
function encodeHead(record) {
|
|
902
|
+
const payload = JSON.stringify(record);
|
|
903
|
+
const envelope = {
|
|
904
|
+
version: HEAD_VERSION,
|
|
905
|
+
payload,
|
|
906
|
+
sha256: sha256(payload),
|
|
907
|
+
};
|
|
908
|
+
return Buffer.from(JSON.stringify(envelope), "utf-8");
|
|
909
|
+
}
|
|
910
|
+
function prepareHeadWrite(sessionStoreDir, sessionId, record) {
|
|
911
|
+
const path = headPath(sessionStoreDir, sessionId, record.sequence);
|
|
912
|
+
const encoded = encodeHead(record);
|
|
913
|
+
assertArtifactWriteSize(path, "head", sessionId, existsSync(path) ? statSync(path).size : 0, encoded.byteLength);
|
|
914
|
+
const latest = readLatestHead(sessionStoreDir, sessionId);
|
|
915
|
+
if (record.sequence === 0) {
|
|
916
|
+
if (latest) {
|
|
917
|
+
throw new Error(`Persisted session already has a durable head for ${sessionId}`);
|
|
918
|
+
}
|
|
220
919
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
920
|
+
else if (!latest ||
|
|
921
|
+
latest.record.sequence !== record.sequence - 1 ||
|
|
922
|
+
latest.digest !== record.previousHeadDigest) {
|
|
923
|
+
throw new Error(`Persisted session head changed before commit for ${sessionId}`);
|
|
224
924
|
}
|
|
925
|
+
if (existsSync(path)) {
|
|
926
|
+
const existing = parseHeadFile(path, record.sequence % 2, sessionId);
|
|
927
|
+
if (record.sequence < 2 || existing.record.sequence !== record.sequence - 2) {
|
|
928
|
+
throw new Error(`Persisted session head slot contains an unexpected generation: ${path}`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return { path, encoded, digest: sha256(JSON.stringify(record)) };
|
|
225
932
|
}
|
|
226
|
-
function
|
|
227
|
-
const
|
|
228
|
-
|
|
933
|
+
function writeHeadRecord(sessionStoreDir, sessionId, record) {
|
|
934
|
+
const prepared = prepareHeadWrite(sessionStoreDir, sessionId, record);
|
|
935
|
+
replaceDurableFileSync(prepared.path, prepared.encoded);
|
|
936
|
+
return prepared.digest;
|
|
937
|
+
}
|
|
938
|
+
function assertSnapshotSlotCanBeReused(sessionStoreDir, sessionId, generation) {
|
|
939
|
+
const path = snapshotPath(sessionStoreDir, sessionId, generation);
|
|
940
|
+
const wal = walPath(sessionStoreDir, sessionId, generation);
|
|
941
|
+
if (generation === 0) {
|
|
942
|
+
if (existsSync(path) || existsSync(wal)) {
|
|
943
|
+
throw new Error(`Persisted session initial durable slot already exists for ${sessionId}`);
|
|
944
|
+
}
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (!existsSync(path)) {
|
|
948
|
+
if (existsSync(wal)) {
|
|
949
|
+
throw new Error(`Persisted session WAL slot exists without its snapshot: ${wal}`);
|
|
950
|
+
}
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const existing = parsePersistedSessionFile(readBoundedArtifactSync(path, "snapshot", sessionId).toString("utf-8"), path);
|
|
954
|
+
if (existing.version !== DURABLE_SESSION_VERSION || existing.generation !== generation - 2) {
|
|
955
|
+
throw new Error(`Persisted session snapshot slot contains an unexpected generation: ${path}`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function prepareSnapshotWrite(sessionStoreDir, session, meta) {
|
|
959
|
+
const generation = meta?.format === "durable" ? meta.generation + 1 : meta?.format === "legacy" ? 1 : 0;
|
|
960
|
+
const headSequence = meta?.format === "durable" ? meta.headSequence + 1 : 0;
|
|
961
|
+
const previousHeadDigest = meta?.format === "durable" ? meta.headDigest : ZERO_DIGEST;
|
|
962
|
+
assertSnapshotSlotCanBeReused(sessionStoreDir, session.sessionId, generation);
|
|
963
|
+
const snapshot = encodeSnapshot(session, generation);
|
|
964
|
+
const headRecord = {
|
|
965
|
+
version: HEAD_VERSION,
|
|
229
966
|
sessionId: session.sessionId,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
967
|
+
sequence: headSequence,
|
|
968
|
+
generation,
|
|
969
|
+
previousHeadDigest,
|
|
970
|
+
snapshotSha256: snapshot.snapshotDigest,
|
|
971
|
+
snapshotStateDigest: snapshot.stateDigest,
|
|
972
|
+
snapshotRevision: snapshot.state.revision,
|
|
973
|
+
snapshotEntryCount: snapshot.state.entries.length,
|
|
974
|
+
walRecordCount: 0,
|
|
975
|
+
walByteLength: 0,
|
|
976
|
+
walTailDigest: snapshot.snapshotDigest,
|
|
977
|
+
stateDigest: snapshot.stateDigest,
|
|
978
|
+
revision: snapshot.state.revision,
|
|
979
|
+
entryCount: snapshot.state.entries.length,
|
|
980
|
+
};
|
|
981
|
+
const nextSnapshotPath = snapshotPath(sessionStoreDir, session.sessionId, generation);
|
|
982
|
+
const nextWalPath = walPath(sessionStoreDir, session.sessionId, generation);
|
|
983
|
+
assertArtifactWriteSize(nextSnapshotPath, "snapshot", session.sessionId, existsSync(nextSnapshotPath) ? statSync(nextSnapshotPath).size : 0, snapshot.encoded.byteLength);
|
|
984
|
+
assertArtifactWriteSize(nextWalPath, "wal", session.sessionId, 0, 0);
|
|
985
|
+
const preparedHead = prepareHeadWrite(sessionStoreDir, session.sessionId, headRecord);
|
|
986
|
+
return {
|
|
987
|
+
generation,
|
|
988
|
+
headSequence,
|
|
989
|
+
snapshot,
|
|
990
|
+
headRecord,
|
|
991
|
+
nextSnapshotPath,
|
|
992
|
+
nextWalPath,
|
|
993
|
+
headArtifactBytes: preparedHead.encoded.byteLength,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
function writeSnapshot(sessionStoreDir, session, meta, faultInjector) {
|
|
997
|
+
ensureDurableDirectorySync(sessionStoreDir);
|
|
998
|
+
const plan = prepareSnapshotWrite(sessionStoreDir, session, meta);
|
|
999
|
+
replaceDurableFileSync(plan.nextSnapshotPath, plan.snapshot.encoded);
|
|
1000
|
+
replaceDurableFileSync(plan.nextWalPath, Buffer.alloc(0));
|
|
1001
|
+
faultInjector?.("snapshot_after_sync_before_head");
|
|
1002
|
+
const headDigest = writeHeadRecord(sessionStoreDir, session.sessionId, plan.headRecord);
|
|
1003
|
+
return {
|
|
1004
|
+
format: "durable",
|
|
1005
|
+
generation: plan.generation,
|
|
1006
|
+
headSequence: plan.headSequence,
|
|
1007
|
+
headDigest,
|
|
1008
|
+
snapshotSha256: plan.snapshot.snapshotDigest,
|
|
1009
|
+
snapshotStateDigest: plan.snapshot.stateDigest,
|
|
1010
|
+
snapshotRevision: plan.snapshot.state.revision,
|
|
1011
|
+
snapshotEntryCount: plan.snapshot.state.entries.length,
|
|
1012
|
+
snapshotByteLength: plan.snapshot.encoded.byteLength,
|
|
1013
|
+
entryCount: plan.snapshot.state.entries.length,
|
|
1014
|
+
walRecords: 0,
|
|
1015
|
+
walByteLength: 0,
|
|
1016
|
+
walTailDigest: plan.snapshot.snapshotDigest,
|
|
1017
|
+
stateDigest: plan.snapshot.stateDigest,
|
|
1018
|
+
revision: plan.snapshot.state.revision,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
function encodeWalRecord(record) {
|
|
1022
|
+
const payload = JSON.stringify(record);
|
|
1023
|
+
const digest = sha256(payload);
|
|
1024
|
+
const envelope = {
|
|
1025
|
+
version: DURABLE_WAL_VERSION,
|
|
1026
|
+
payload,
|
|
1027
|
+
sha256: digest,
|
|
1028
|
+
};
|
|
1029
|
+
return { encoded: Buffer.from(`${JSON.stringify(envelope)}\n`, "utf-8"), digest };
|
|
1030
|
+
}
|
|
1031
|
+
function prepareWalWrite(sessionStoreDir, session, meta) {
|
|
1032
|
+
if (session.persistenceChange?.kind !== "wal") {
|
|
1033
|
+
throw new Error(`Session ${session.sessionId} does not have a WAL persistence change`);
|
|
1034
|
+
}
|
|
1035
|
+
if (session.revision <= meta.revision) {
|
|
1036
|
+
throw new Error(`Session ${session.sessionId} revision did not advance before WAL persistence`);
|
|
1037
|
+
}
|
|
1038
|
+
const path = walPath(sessionStoreDir, session.sessionId, meta.generation);
|
|
1039
|
+
if (!existsSync(path) || statSync(path).size !== meta.walByteLength) {
|
|
1040
|
+
throw new Error(`Session ${session.sessionId} WAL does not match its in-memory durable head`);
|
|
1041
|
+
}
|
|
1042
|
+
const recordWithoutStateDigest = {
|
|
1043
|
+
version: DURABLE_WAL_VERSION,
|
|
1044
|
+
sessionId: session.sessionId,
|
|
1045
|
+
generation: meta.generation,
|
|
1046
|
+
sequence: meta.walRecords,
|
|
1047
|
+
baseRevision: meta.revision,
|
|
233
1048
|
revision: session.revision,
|
|
1049
|
+
baseEntryCount: meta.entryCount,
|
|
1050
|
+
entries: session.entries.slice(meta.entryCount),
|
|
1051
|
+
leafId: session.leafId,
|
|
234
1052
|
updatedAt: session.updatedAt,
|
|
235
|
-
|
|
1053
|
+
previousStateDigest: meta.stateDigest,
|
|
1054
|
+
previousWalDigest: meta.walTailDigest,
|
|
1055
|
+
staticContextChanged: session.persistenceChange.staticContextChanged,
|
|
1056
|
+
};
|
|
1057
|
+
if (session.persistenceChange.staticContextChanged) {
|
|
1058
|
+
recordWithoutStateDigest.staticContext = session.staticContext ?? null;
|
|
1059
|
+
}
|
|
1060
|
+
const record = {
|
|
1061
|
+
...recordWithoutStateDigest,
|
|
1062
|
+
stateDigest: walTransitionStateDigest(recordWithoutStateDigest),
|
|
1063
|
+
};
|
|
1064
|
+
const encodedRecord = encodeWalRecord(record);
|
|
1065
|
+
const headRecord = {
|
|
1066
|
+
version: HEAD_VERSION,
|
|
1067
|
+
sessionId: session.sessionId,
|
|
1068
|
+
sequence: meta.headSequence + 1,
|
|
1069
|
+
generation: meta.generation,
|
|
1070
|
+
previousHeadDigest: meta.headDigest,
|
|
1071
|
+
snapshotSha256: meta.snapshotSha256,
|
|
1072
|
+
snapshotStateDigest: meta.snapshotStateDigest,
|
|
1073
|
+
snapshotRevision: meta.snapshotRevision,
|
|
1074
|
+
snapshotEntryCount: meta.snapshotEntryCount,
|
|
1075
|
+
walRecordCount: meta.walRecords + 1,
|
|
1076
|
+
walByteLength: meta.walByteLength + encodedRecord.encoded.byteLength,
|
|
1077
|
+
walTailDigest: encodedRecord.digest,
|
|
1078
|
+
stateDigest: record.stateDigest,
|
|
1079
|
+
revision: session.revision,
|
|
1080
|
+
entryCount: session.entries.length,
|
|
1081
|
+
};
|
|
1082
|
+
assertArtifactWriteSize(path, "wal", session.sessionId, meta.walByteLength, headRecord.walByteLength);
|
|
1083
|
+
const preparedHead = prepareHeadWrite(sessionStoreDir, session.sessionId, headRecord);
|
|
1084
|
+
return {
|
|
1085
|
+
path,
|
|
1086
|
+
encodedRecord,
|
|
1087
|
+
headRecord,
|
|
1088
|
+
headArtifactBytes: preparedHead.encoded.byteLength,
|
|
236
1089
|
};
|
|
237
|
-
appendFileSync(walPath(sessionStoreDir, session.sessionId), `${JSON.stringify(record)}\n`, "utf-8");
|
|
238
1090
|
}
|
|
239
|
-
|
|
1091
|
+
function appendWalRecord(sessionStoreDir, session, meta, faultInjector) {
|
|
1092
|
+
const plan = prepareWalWrite(sessionStoreDir, session, meta);
|
|
1093
|
+
appendDurableFileSync(plan.path, plan.encodedRecord.encoded);
|
|
1094
|
+
faultInjector?.("wal_after_sync_before_head");
|
|
1095
|
+
const headDigest = writeHeadRecord(sessionStoreDir, session.sessionId, plan.headRecord);
|
|
1096
|
+
return {
|
|
1097
|
+
...meta,
|
|
1098
|
+
headSequence: plan.headRecord.sequence,
|
|
1099
|
+
headDigest,
|
|
1100
|
+
entryCount: plan.headRecord.entryCount,
|
|
1101
|
+
walRecords: plan.headRecord.walRecordCount,
|
|
1102
|
+
walByteLength: plan.headRecord.walByteLength,
|
|
1103
|
+
walTailDigest: plan.headRecord.walTailDigest,
|
|
1104
|
+
stateDigest: plan.headRecord.stateDigest,
|
|
1105
|
+
revision: plan.headRecord.revision,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function recoverFailedCommit(sessionStoreDir, session, key, meta) {
|
|
1109
|
+
if (!existsSync(sessionStoreDir)) {
|
|
1110
|
+
if (meta) {
|
|
1111
|
+
persistedSessions.set(key, meta);
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
persistedSessions.delete(key);
|
|
1115
|
+
}
|
|
1116
|
+
sessionsNeedingRecovery.delete(key);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
syncDirectorySync(sessionStoreDir);
|
|
1120
|
+
const head = readLatestHead(sessionStoreDir, session.sessionId);
|
|
1121
|
+
if (!head) {
|
|
1122
|
+
if (meta?.format === "durable") {
|
|
1123
|
+
throw new Error(`Persisted session durable head disappeared during recovery for ${session.sessionId}`);
|
|
1124
|
+
}
|
|
1125
|
+
const uncommittedGeneration = meta?.format === "legacy" ? 1 : 0;
|
|
1126
|
+
rmSync(snapshotPath(sessionStoreDir, session.sessionId, uncommittedGeneration), { force: true });
|
|
1127
|
+
rmSync(walPath(sessionStoreDir, session.sessionId, uncommittedGeneration), { force: true });
|
|
1128
|
+
syncDirectorySync(sessionStoreDir);
|
|
1129
|
+
if (meta) {
|
|
1130
|
+
persistedSessions.set(key, meta);
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
persistedSessions.delete(key);
|
|
1134
|
+
}
|
|
1135
|
+
sessionsNeedingRecovery.delete(key);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const loaded = loadDurableSession(sessionStoreDir, session.sessionId, head);
|
|
1139
|
+
persistedSessions.set(key, loaded.meta);
|
|
1140
|
+
const current = exportSessionState(session);
|
|
1141
|
+
if (isDeepStrictEqual(current, loaded.session)) {
|
|
1142
|
+
markSessionPersisted(session);
|
|
1143
|
+
sessionsNeedingRecovery.delete(key);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (session.persistenceChange?.kind === "wal") {
|
|
1147
|
+
if (loaded.session.entries.length > current.entries.length ||
|
|
1148
|
+
!isDeepStrictEqual(current.entries.slice(0, loaded.session.entries.length), loaded.session.entries)) {
|
|
1149
|
+
throw new Error(`Session ${session.sessionId} diverged from its recovered durable tree`);
|
|
1150
|
+
}
|
|
1151
|
+
session.persistenceChange = {
|
|
1152
|
+
kind: "wal",
|
|
1153
|
+
entries: current.entries.slice(loaded.session.entries.length),
|
|
1154
|
+
staticContextChanged: !isDeepStrictEqual(current.staticContext, loaded.session.staticContext),
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
sessionsNeedingRecovery.delete(key);
|
|
1158
|
+
}
|
|
1159
|
+
function shouldWriteSnapshot(session, meta) {
|
|
1160
|
+
return (!meta ||
|
|
1161
|
+
meta.format === "legacy" ||
|
|
1162
|
+
session.persistenceChange?.kind === "snapshot" ||
|
|
1163
|
+
// Keep replay proportional to the checkpoint while amortizing full serialization over equivalent growth.
|
|
1164
|
+
(meta.format === "durable" &&
|
|
1165
|
+
(meta.walRecords >= Math.max(MIN_WAL_RECORDS_PER_SNAPSHOT, meta.snapshotEntryCount) ||
|
|
1166
|
+
meta.walByteLength >= Math.max(MIN_WAL_BYTES_PER_SNAPSHOT, meta.snapshotByteLength))));
|
|
1167
|
+
}
|
|
1168
|
+
export function preflightPersistedSession(sessionStoreDir, session) {
|
|
1169
|
+
const key = persistedSessionKey(sessionStoreDir, session.sessionId);
|
|
1170
|
+
if (sessionsNeedingRecovery.has(key)) {
|
|
1171
|
+
throw new Error(`Session ${session.sessionId} requires persistence recovery before it can be preflighted`);
|
|
1172
|
+
}
|
|
1173
|
+
const meta = persistedSessions.get(key);
|
|
1174
|
+
if (meta && !session.persistenceChange)
|
|
1175
|
+
return { strategy: "none" };
|
|
1176
|
+
if (shouldWriteSnapshot(session, meta)) {
|
|
1177
|
+
const plan = prepareSnapshotWrite(sessionStoreDir, session, meta);
|
|
1178
|
+
return {
|
|
1179
|
+
strategy: "snapshot",
|
|
1180
|
+
snapshotArtifactBytes: plan.snapshot.encoded.byteLength,
|
|
1181
|
+
walArtifactBytes: 0,
|
|
1182
|
+
headArtifactBytes: plan.headArtifactBytes,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (meta?.format !== "durable") {
|
|
1186
|
+
throw new Error(`Session ${session.sessionId} does not have durable persistence metadata`);
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
const plan = prepareWalWrite(sessionStoreDir, session, meta);
|
|
1190
|
+
return {
|
|
1191
|
+
strategy: "wal",
|
|
1192
|
+
walArtifactBytes: plan.headRecord.walByteLength,
|
|
1193
|
+
headArtifactBytes: plan.headArtifactBytes,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
catch (error) {
|
|
1197
|
+
if (!(error instanceof SessionPersistenceCapacityError) || error.resource !== "wal_artifact_bytes") {
|
|
1198
|
+
throw error;
|
|
1199
|
+
}
|
|
1200
|
+
const plan = prepareSnapshotWrite(sessionStoreDir, session, meta);
|
|
1201
|
+
return {
|
|
1202
|
+
strategy: "snapshot",
|
|
1203
|
+
snapshotArtifactBytes: plan.snapshot.encoded.byteLength,
|
|
1204
|
+
walArtifactBytes: 0,
|
|
1205
|
+
headArtifactBytes: plan.headArtifactBytes,
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
export function savePersistedSession(sessionStoreDir, session, options = {}) {
|
|
240
1210
|
const key = persistedSessionKey(sessionStoreDir, session.sessionId);
|
|
1211
|
+
if (sessionsNeedingRecovery.has(key)) {
|
|
1212
|
+
recoverFailedCommit(sessionStoreDir, session, key, persistedSessions.get(key));
|
|
1213
|
+
}
|
|
241
1214
|
const meta = persistedSessions.get(key);
|
|
242
1215
|
if (meta && !session.persistenceChange)
|
|
243
1216
|
return;
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
1217
|
+
if (shouldWriteSnapshot(session, meta)) {
|
|
1218
|
+
try {
|
|
1219
|
+
const nextMeta = writeSnapshot(sessionStoreDir, session, meta, options.faultInjector);
|
|
1220
|
+
persistedSessions.set(key, nextMeta);
|
|
1221
|
+
markSessionPersisted(session);
|
|
1222
|
+
}
|
|
1223
|
+
catch (error) {
|
|
1224
|
+
if (!(error instanceof SessionPersistenceCapacityError))
|
|
1225
|
+
sessionsNeedingRecovery.add(key);
|
|
1226
|
+
throw error;
|
|
1227
|
+
}
|
|
248
1228
|
return;
|
|
249
1229
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
1230
|
+
if (meta?.format !== "durable") {
|
|
1231
|
+
throw new Error(`Session ${session.sessionId} does not have durable persistence metadata`);
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
const nextMeta = appendWalRecord(sessionStoreDir, session, meta, options.faultInjector);
|
|
1235
|
+
persistedSessions.set(key, nextMeta);
|
|
1236
|
+
markSessionPersisted(session);
|
|
1237
|
+
}
|
|
1238
|
+
catch (error) {
|
|
1239
|
+
if (error instanceof SessionPersistenceCapacityError && error.resource === "wal_artifact_bytes") {
|
|
1240
|
+
try {
|
|
1241
|
+
const nextMeta = writeSnapshot(sessionStoreDir, session, meta, options.faultInjector);
|
|
1242
|
+
persistedSessions.set(key, nextMeta);
|
|
1243
|
+
markSessionPersisted(session);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
catch (snapshotError) {
|
|
1247
|
+
if (!(snapshotError instanceof SessionPersistenceCapacityError))
|
|
1248
|
+
sessionsNeedingRecovery.add(key);
|
|
1249
|
+
throw snapshotError;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
sessionsNeedingRecovery.add(key);
|
|
1253
|
+
throw error;
|
|
1254
|
+
}
|
|
253
1255
|
}
|
|
254
1256
|
export function deletePersistedSession(sessionStoreDir, sessionId) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
1257
|
+
const paths = [
|
|
1258
|
+
`${sessionPath(sessionStoreDir, sessionId)}.head.0`,
|
|
1259
|
+
`${sessionPath(sessionStoreDir, sessionId)}.head.1`,
|
|
1260
|
+
`${sessionPath(sessionStoreDir, sessionId)}.snapshot.0`,
|
|
1261
|
+
`${sessionPath(sessionStoreDir, sessionId)}.snapshot.1`,
|
|
1262
|
+
`${sessionPath(sessionStoreDir, sessionId)}.wal.0`,
|
|
1263
|
+
`${sessionPath(sessionStoreDir, sessionId)}.wal.1`,
|
|
1264
|
+
`${sessionPath(sessionStoreDir, sessionId)}.wal`,
|
|
1265
|
+
sessionPath(sessionStoreDir, sessionId),
|
|
1266
|
+
];
|
|
1267
|
+
for (const path of paths)
|
|
1268
|
+
rmSync(path, { force: true });
|
|
1269
|
+
if (existsSync(sessionStoreDir))
|
|
1270
|
+
syncDirectorySync(sessionStoreDir);
|
|
1271
|
+
const key = persistedSessionKey(sessionStoreDir, sessionId);
|
|
1272
|
+
persistedSessions.delete(key);
|
|
1273
|
+
sessionsNeedingRecovery.delete(key);
|
|
258
1274
|
}
|
|
259
1275
|
//# sourceMappingURL=session-persistence.js.map
|