@untangle-code/storage-local 0.0.0-stage → 0.1.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/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +226 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/abort.d.ts +18 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/abort.js +14 -0
- package/dist/abort.js.map +1 -0
- package/dist/analysis-cache.d.ts +81 -0
- package/dist/analysis-cache.d.ts.map +1 -0
- package/dist/analysis-cache.js +322 -0
- package/dist/analysis-cache.js.map +1 -0
- package/dist/checkpoint-store.d.ts +22 -0
- package/dist/checkpoint-store.d.ts.map +1 -0
- package/dist/checkpoint-store.js +123 -0
- package/dist/checkpoint-store.js.map +1 -0
- package/dist/embedding-cache.d.ts +38 -0
- package/dist/embedding-cache.d.ts.map +1 -0
- package/dist/embedding-cache.js +229 -0
- package/dist/embedding-cache.js.map +1 -0
- package/dist/fs-errors.d.ts +4 -0
- package/dist/fs-errors.d.ts.map +1 -0
- package/dist/fs-errors.js +14 -0
- package/dist/fs-errors.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +50 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +99 -0
- package/dist/layout.js.map +1 -0
- package/dist/local-file-system.d.ts +37 -0
- package/dist/local-file-system.d.ts.map +1 -0
- package/dist/local-file-system.js +193 -0
- package/dist/local-file-system.js.map +1 -0
- package/dist/lock-provider.d.ts +59 -0
- package/dist/lock-provider.d.ts.map +1 -0
- package/dist/lock-provider.js +318 -0
- package/dist/lock-provider.js.map +1 -0
- package/dist/real-time.d.ts +16 -0
- package/dist/real-time.d.ts.map +1 -0
- package/dist/real-time.js +19 -0
- package/dist/real-time.js.map +1 -0
- package/dist/search-journey-store.d.ts +34 -0
- package/dist/search-journey-store.d.ts.map +1 -0
- package/dist/search-journey-store.js +530 -0
- package/dist/search-journey-store.js.map +1 -0
- package/dist/storage.d.ts +42 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +34 -0
- package/dist/storage.js.map +1 -0
- package/package.json +60 -3
- package/src/abort.ts +26 -0
- package/src/analysis-cache.ts +404 -0
- package/src/checkpoint-store.ts +155 -0
- package/src/embedding-cache.ts +282 -0
- package/src/fs-errors.ts +19 -0
- package/src/index.ts +30 -0
- package/src/layout.ts +149 -0
- package/src/local-file-system.ts +232 -0
- package/src/lock-provider.ts +381 -0
- package/src/real-time.ts +18 -0
- package/src/search-journey-store.ts +638 -0
- package/src/storage.ts +98 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lokale, atomare Search-Journey-Persistenz unter
|
|
3
|
+
* `.untangle/product-complexity/sessions/`.
|
|
4
|
+
*
|
|
5
|
+
* Ein CAS-Revisionstest laeuft zusammen mit dem Schreiben unter demselben
|
|
6
|
+
* pro-Session-Dateilock. So verlieren mehrere lokale Agenten keine Schritte,
|
|
7
|
+
* obwohl sie dieselbe Session in getrennten Prozessen bedienen.
|
|
8
|
+
*/
|
|
9
|
+
import { Buffer } from "node:buffer";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
UntangleError,
|
|
14
|
+
compareCodepoints,
|
|
15
|
+
searchJourneyValidationIssue,
|
|
16
|
+
type Clock,
|
|
17
|
+
type FileSystemPort,
|
|
18
|
+
type LockHandle,
|
|
19
|
+
type LockProvider,
|
|
20
|
+
type PortCallOptions,
|
|
21
|
+
type Random,
|
|
22
|
+
type SearchJourneyStorePort,
|
|
23
|
+
type SearchJourneyV1,
|
|
24
|
+
} from "@untangle-code/domain";
|
|
25
|
+
|
|
26
|
+
import { assertNotAborted } from "./abort.js";
|
|
27
|
+
import { isNotFoundError } from "./fs-errors.js";
|
|
28
|
+
|
|
29
|
+
export const MAX_SEARCH_JOURNEY_RECORD_BYTES = 32 * 1024 * 1024;
|
|
30
|
+
export const MAX_SEARCH_JOURNEY_FILES = 10_000;
|
|
31
|
+
export const SEARCH_JOURNEY_LOCK_LEASE_MS = 30_000;
|
|
32
|
+
export const SEARCH_JOURNEY_LOCK_RENEW_INTERVAL_MS = Math.floor(SEARCH_JOURNEY_LOCK_LEASE_MS / 3);
|
|
33
|
+
/**
|
|
34
|
+
* Hard aggregate read budget for one report/list snapshot. Four maximum-size
|
|
35
|
+
* sessions fit, so a three-session evidence cohort remains representable,
|
|
36
|
+
* while one call can never intentionally retain the full 10,000-file disk cap.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_SEARCH_JOURNEY_LIST_BYTES = 128 * 1024 * 1024;
|
|
39
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
40
|
+
const FINGERPRINT_KEY = /^[0-9a-f]{64}$/u;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keeps one finite lock lease alive without overlapping renewal calls.
|
|
44
|
+
* Commit preparation pauses the heartbeat, waits for any in-flight renewal
|
|
45
|
+
* and keeps periodic scheduling stopped while the coordinated freshness gate
|
|
46
|
+
* below proves every required lock immediately before an atomic publish.
|
|
47
|
+
*/
|
|
48
|
+
class RenewingLease {
|
|
49
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
50
|
+
private inFlight: Promise<void> | undefined;
|
|
51
|
+
private failure: unknown;
|
|
52
|
+
private hasFailure = false;
|
|
53
|
+
private committing = false;
|
|
54
|
+
private closed = false;
|
|
55
|
+
|
|
56
|
+
public constructor(
|
|
57
|
+
private readonly handle: LockHandle,
|
|
58
|
+
private readonly options: PortCallOptions | undefined,
|
|
59
|
+
) {
|
|
60
|
+
this.schedule();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public get key(): string {
|
|
64
|
+
return this.handle.key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private schedule(): void {
|
|
68
|
+
if (this.closed || this.committing || this.hasFailure) return;
|
|
69
|
+
this.timer = setTimeout(() => {
|
|
70
|
+
this.timer = undefined;
|
|
71
|
+
if (this.closed || this.committing || this.hasFailure) return;
|
|
72
|
+
const renewal = this.handle
|
|
73
|
+
.renew(signalOptions(this.options))
|
|
74
|
+
.catch((error: unknown) => {
|
|
75
|
+
this.hasFailure = true;
|
|
76
|
+
this.failure = error;
|
|
77
|
+
})
|
|
78
|
+
.finally(() => {
|
|
79
|
+
if (this.inFlight === renewal) this.inFlight = undefined;
|
|
80
|
+
this.schedule();
|
|
81
|
+
});
|
|
82
|
+
this.inFlight = renewal;
|
|
83
|
+
}, SEARCH_JOURNEY_LOCK_RENEW_INTERVAL_MS);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public async prepareForCommit(): Promise<void> {
|
|
87
|
+
this.committing = true;
|
|
88
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
89
|
+
this.timer = undefined;
|
|
90
|
+
await this.inFlight;
|
|
91
|
+
if (this.hasFailure) throw this.failure;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public async renewForCommit(): Promise<void> {
|
|
95
|
+
await this.prepareForCommit();
|
|
96
|
+
const renewal = this.handle.renew(signalOptions(this.options)).catch((error: unknown) => {
|
|
97
|
+
this.hasFailure = true;
|
|
98
|
+
this.failure = error;
|
|
99
|
+
});
|
|
100
|
+
this.inFlight = renewal;
|
|
101
|
+
await renewal;
|
|
102
|
+
if (this.inFlight === renewal) this.inFlight = undefined;
|
|
103
|
+
if (this.hasFailure) throw this.failure;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public leaseExpiresAtEpochMs(): number {
|
|
107
|
+
const expiresAt = Date.parse(this.handle.record.leaseExpiresAt);
|
|
108
|
+
if (!Number.isFinite(expiresAt)) {
|
|
109
|
+
throw new UntangleError(
|
|
110
|
+
"LOCK_HELD",
|
|
111
|
+
`Lock "${this.handle.key}" has an invalid lease expiry`,
|
|
112
|
+
{
|
|
113
|
+
retryable: true,
|
|
114
|
+
operation: "lock_renew",
|
|
115
|
+
context: { key: this.handle.key, leaseExpiresAt: this.handle.record.leaseExpiresAt },
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return expiresAt;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public async release(): Promise<void> {
|
|
123
|
+
this.closed = true;
|
|
124
|
+
this.committing = true;
|
|
125
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
126
|
+
this.timer = undefined;
|
|
127
|
+
await this.inFlight;
|
|
128
|
+
await this.handle.release();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const MAX_COMMIT_LEASE_RENEWAL_ROUNDS = 8;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stops every heartbeat and establishes one overlapping freshness window for
|
|
136
|
+
* all commit locks. A slow renewal may make an earlier lease stale, so only
|
|
137
|
+
* the stale subset is renewed again until every lease has at least one
|
|
138
|
+
* heartbeat interval left at the same instant. If the windows cannot
|
|
139
|
+
* converge, publication fails closed.
|
|
140
|
+
*/
|
|
141
|
+
async function verifyLeasesForCommit(
|
|
142
|
+
leases: readonly RenewingLease[],
|
|
143
|
+
clock: Pick<Clock, "nowEpochMs">,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
await Promise.all(leases.map((lease) => lease.prepareForCommit()));
|
|
146
|
+
let renew = [...leases];
|
|
147
|
+
for (let round = 1; round <= MAX_COMMIT_LEASE_RENEWAL_ROUNDS; round += 1) {
|
|
148
|
+
await Promise.all(renew.map((lease) => lease.renewForCommit()));
|
|
149
|
+
const freshnessBoundary = clock.nowEpochMs() + SEARCH_JOURNEY_LOCK_RENEW_INTERVAL_MS;
|
|
150
|
+
renew = leases.filter((lease) => lease.leaseExpiresAtEpochMs() < freshnessBoundary);
|
|
151
|
+
if (renew.length === 0) return;
|
|
152
|
+
}
|
|
153
|
+
throw new UntangleError(
|
|
154
|
+
"LOCK_HELD",
|
|
155
|
+
"search journey commit locks could not be proven simultaneously fresh",
|
|
156
|
+
{
|
|
157
|
+
retryable: true,
|
|
158
|
+
operation: "search_journey_lock_commit",
|
|
159
|
+
context: {
|
|
160
|
+
keys: leases.map((lease) => lease.key),
|
|
161
|
+
minimumRemainingMs: SEARCH_JOURNEY_LOCK_RENEW_INTERVAL_MS,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function encode(text: string): Uint8Array {
|
|
168
|
+
return new TextEncoder().encode(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hex(bytes: Uint8Array): string {
|
|
172
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function decodeHex(value: string): Uint8Array {
|
|
176
|
+
return Uint8Array.from(value.match(/.{2}/gu) ?? [], (byte) => Number.parseInt(byte, 16));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function signalOptions(options: PortCallOptions | undefined): PortCallOptions | undefined {
|
|
180
|
+
return options?.signal === undefined ? undefined : { signal: options.signal };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertSessionId(sessionId: string): void {
|
|
184
|
+
if (!SESSION_ID.test(sessionId)) {
|
|
185
|
+
throw new UntangleError("SEARCH_SESSION_NOT_FOUND", "invalid search journey session id", {
|
|
186
|
+
operation: "search_journey_store",
|
|
187
|
+
context: { sessionId },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface SearchJourneyReadManifestEntry {
|
|
193
|
+
fileName: string;
|
|
194
|
+
path: string;
|
|
195
|
+
sessionId: string;
|
|
196
|
+
sizeBytes: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export class LocalSearchJourneyStore implements SearchJourneyStorePort {
|
|
200
|
+
private readonly fingerprintKeyPath: string;
|
|
201
|
+
|
|
202
|
+
public constructor(
|
|
203
|
+
private readonly fs: FileSystemPort,
|
|
204
|
+
private readonly sessionsDir: string,
|
|
205
|
+
private readonly random: Random,
|
|
206
|
+
private readonly locks: LockProvider,
|
|
207
|
+
private readonly clock: Pick<Clock, "nowEpochMs">,
|
|
208
|
+
) {
|
|
209
|
+
this.fingerprintKeyPath = path.join(path.dirname(sessionsDir), "fingerprint.key");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private pathFor(sessionId: string): string {
|
|
213
|
+
assertSessionId(sessionId);
|
|
214
|
+
return path.join(this.sessionsDir, `${sessionId}.json`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private async read(
|
|
218
|
+
sessionId: string,
|
|
219
|
+
options?: PortCallOptions,
|
|
220
|
+
): Promise<SearchJourneyV1 | null> {
|
|
221
|
+
const recordPath = this.pathFor(sessionId);
|
|
222
|
+
const recordStat = await this.fs.stat(recordPath, options);
|
|
223
|
+
if (recordStat === null) return null;
|
|
224
|
+
if (recordStat.kind !== "file" || recordStat.sizeBytes > MAX_SEARCH_JOURNEY_RECORD_BYTES) {
|
|
225
|
+
throw new UntangleError("INDEX_INCOMPLETE", `search journey "${sessionId}" is corrupted`, {
|
|
226
|
+
operation: "search_journey_read",
|
|
227
|
+
context: {
|
|
228
|
+
sessionId,
|
|
229
|
+
kind: recordStat.kind,
|
|
230
|
+
sizeBytes: recordStat.sizeBytes,
|
|
231
|
+
maximumBytes: MAX_SEARCH_JOURNEY_RECORD_BYTES,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
let text: string;
|
|
236
|
+
try {
|
|
237
|
+
text = await this.fs.readTextFile(recordPath, options);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (isNotFoundError(error)) return null;
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
const actualBytes = Buffer.byteLength(text, "utf8");
|
|
243
|
+
if (actualBytes > MAX_SEARCH_JOURNEY_RECORD_BYTES) {
|
|
244
|
+
throw new UntangleError("INDEX_INCOMPLETE", `search journey "${sessionId}" is corrupted`, {
|
|
245
|
+
operation: "search_journey_read",
|
|
246
|
+
context: {
|
|
247
|
+
sessionId,
|
|
248
|
+
actualBytes,
|
|
249
|
+
maximumBytes: MAX_SEARCH_JOURNEY_RECORD_BYTES,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return this.parse(sessionId, text, "search_journey_read");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private parse(sessionId: string, text: string, operation: string): SearchJourneyV1 {
|
|
257
|
+
try {
|
|
258
|
+
const parsed: unknown = JSON.parse(text);
|
|
259
|
+
const issue = searchJourneyValidationIssue(parsed);
|
|
260
|
+
if (issue !== null) throw new Error(issue);
|
|
261
|
+
const validated = parsed as SearchJourneyV1;
|
|
262
|
+
if (validated.sessionId !== sessionId) throw new Error("session id mismatch");
|
|
263
|
+
return validated;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw new UntangleError("INDEX_INCOMPLETE", `search journey "${sessionId}" is corrupted`, {
|
|
266
|
+
operation,
|
|
267
|
+
context: { sessionId },
|
|
268
|
+
cause: error,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Liefert den geheimen lokalen HMAC-Schluessel fuer Journey-Fingerprints.
|
|
275
|
+
* Er liegt absichtlich ausserhalb der exportierbaren Session-Datensaetze.
|
|
276
|
+
*/
|
|
277
|
+
public async getOrCreateFingerprintKey(options?: PortCallOptions): Promise<Uint8Array> {
|
|
278
|
+
assertNotAborted(options, "search_journey_fingerprint_key");
|
|
279
|
+
await this.fs.mkdir(path.dirname(this.fingerprintKeyPath), {
|
|
280
|
+
recursive: true,
|
|
281
|
+
...signalOptions(options),
|
|
282
|
+
});
|
|
283
|
+
const lock = await this.locks.acquire("search-journey:fingerprint-key", {
|
|
284
|
+
...signalOptions(options),
|
|
285
|
+
leaseMs: SEARCH_JOURNEY_LOCK_LEASE_MS,
|
|
286
|
+
waitMs: 5_000,
|
|
287
|
+
});
|
|
288
|
+
const lease = new RenewingLease(lock, options);
|
|
289
|
+
try {
|
|
290
|
+
try {
|
|
291
|
+
const persisted = (await this.fs.readTextFile(this.fingerprintKeyPath, options)).trim();
|
|
292
|
+
if (!FINGERPRINT_KEY.test(persisted)) throw new Error("invalid fingerprint key");
|
|
293
|
+
return decodeHex(persisted);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (!isNotFoundError(error)) {
|
|
296
|
+
throw new UntangleError(
|
|
297
|
+
"INDEX_INCOMPLETE",
|
|
298
|
+
"search journey fingerprint key is corrupted",
|
|
299
|
+
{
|
|
300
|
+
operation: "search_journey_fingerprint_key",
|
|
301
|
+
cause: error,
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let existingRecordCount = 0;
|
|
308
|
+
try {
|
|
309
|
+
existingRecordCount = (await this.fs.readDir(this.sessionsDir, options)).filter(
|
|
310
|
+
(entry) => entry.kind === "file" && entry.name.endsWith(".json"),
|
|
311
|
+
).length;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (!isNotFoundError(error)) throw error;
|
|
314
|
+
}
|
|
315
|
+
if (existingRecordCount > 0) {
|
|
316
|
+
throw new UntangleError(
|
|
317
|
+
"INDEX_INCOMPLETE",
|
|
318
|
+
"search journey fingerprint key is missing while session records exist",
|
|
319
|
+
{
|
|
320
|
+
operation: "search_journey_fingerprint_key",
|
|
321
|
+
context: { existingRecordCount },
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const key = this.random.bytes(32);
|
|
327
|
+
const tempPath = `${this.fingerprintKeyPath}.${this.random.uuid()}.tmp`;
|
|
328
|
+
try {
|
|
329
|
+
await this.fs.writeFile(tempPath, encode(hex(key)), {
|
|
330
|
+
...signalOptions(options),
|
|
331
|
+
mode: 0o600,
|
|
332
|
+
});
|
|
333
|
+
await verifyLeasesForCommit([lease], this.clock);
|
|
334
|
+
await this.fs.rename(tempPath, this.fingerprintKeyPath, options);
|
|
335
|
+
return key.slice();
|
|
336
|
+
} finally {
|
|
337
|
+
// Cleanup deliberately ignores an aborted caller signal: otherwise
|
|
338
|
+
// cancellation could strand unbounded temp bytes outside record limits.
|
|
339
|
+
await this.fs.remove(tempPath).catch(() => undefined);
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
await lease.release();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
public async get(sessionId: string, options?: PortCallOptions): Promise<SearchJourneyV1 | null> {
|
|
347
|
+
assertNotAborted(options, "search_journey_get");
|
|
348
|
+
return await this.read(sessionId, options);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
public async put(
|
|
352
|
+
session: SearchJourneyV1,
|
|
353
|
+
options: PortCallOptions & { expectedRevision: number | null },
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
assertNotAborted(options, "search_journey_put");
|
|
356
|
+
if (options?.expectedRevision === undefined) {
|
|
357
|
+
throw new UntangleError(
|
|
358
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
359
|
+
"search journey writes require an expected revision",
|
|
360
|
+
{ operation: "search_journey_put", context: { sessionId: session.sessionId } },
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
assertSessionId(session.sessionId);
|
|
364
|
+
const validationIssue = searchJourneyValidationIssue(session);
|
|
365
|
+
if (validationIssue !== null) {
|
|
366
|
+
throw new UntangleError(
|
|
367
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
368
|
+
`search journey cannot be persisted: ${validationIssue}`,
|
|
369
|
+
{
|
|
370
|
+
operation: "search_journey_put",
|
|
371
|
+
context: { sessionId: session.sessionId, validationIssue },
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const expected = options.expectedRevision;
|
|
376
|
+
const requiredRevision = expected === null ? 0 : expected + 1;
|
|
377
|
+
if (session.revision !== requiredRevision) {
|
|
378
|
+
throw new UntangleError(
|
|
379
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
380
|
+
`search journey revision ${String(session.revision)} does not follow expected revision ${String(expected)}`,
|
|
381
|
+
{
|
|
382
|
+
operation: "search_journey_put",
|
|
383
|
+
context: {
|
|
384
|
+
sessionId: session.sessionId,
|
|
385
|
+
expectedRevision: expected,
|
|
386
|
+
suppliedRevision: session.revision,
|
|
387
|
+
requiredRevision,
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const body = JSON.stringify(session);
|
|
393
|
+
const bytes = encode(body);
|
|
394
|
+
if (bytes.byteLength > MAX_SEARCH_JOURNEY_RECORD_BYTES) {
|
|
395
|
+
throw new UntangleError(
|
|
396
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
397
|
+
`search journey exceeds the ${String(MAX_SEARCH_JOURNEY_RECORD_BYTES)} byte record limit`,
|
|
398
|
+
{
|
|
399
|
+
operation: "search_journey_put",
|
|
400
|
+
context: { sessionId: session.sessionId, byteLength: bytes.byteLength },
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
await this.fs.mkdir(this.sessionsDir, { recursive: true, ...signalOptions(options) });
|
|
406
|
+
const creationLock =
|
|
407
|
+
expected === null
|
|
408
|
+
? await this.locks.acquire("search-journey:create", {
|
|
409
|
+
...signalOptions(options),
|
|
410
|
+
leaseMs: SEARCH_JOURNEY_LOCK_LEASE_MS,
|
|
411
|
+
waitMs: 5_000,
|
|
412
|
+
})
|
|
413
|
+
: null;
|
|
414
|
+
const creationLease = creationLock === null ? null : new RenewingLease(creationLock, options);
|
|
415
|
+
try {
|
|
416
|
+
const lock = await this.locks.acquire(`search-journey:${session.sessionId}`, {
|
|
417
|
+
...signalOptions(options),
|
|
418
|
+
leaseMs: SEARCH_JOURNEY_LOCK_LEASE_MS,
|
|
419
|
+
waitMs: 5_000,
|
|
420
|
+
});
|
|
421
|
+
const lease = new RenewingLease(lock, options);
|
|
422
|
+
try {
|
|
423
|
+
const current = await this.read(session.sessionId, options);
|
|
424
|
+
const conflict = expected === null ? current !== null : current?.revision !== expected;
|
|
425
|
+
if (conflict) {
|
|
426
|
+
throw new UntangleError(
|
|
427
|
+
"SEARCH_SESSION_CONFLICT",
|
|
428
|
+
`search journey "${session.sessionId}" changed concurrently`,
|
|
429
|
+
{
|
|
430
|
+
retryable: true,
|
|
431
|
+
operation: "search_journey_put",
|
|
432
|
+
context: {
|
|
433
|
+
sessionId: session.sessionId,
|
|
434
|
+
expectedRevision: expected,
|
|
435
|
+
actualRevision: current?.revision ?? null,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (expected === null) {
|
|
441
|
+
const recordCount = (await this.fs.readDir(this.sessionsDir, options)).filter(
|
|
442
|
+
(entry) => entry.kind === "file" && entry.name.endsWith(".json"),
|
|
443
|
+
).length;
|
|
444
|
+
if (recordCount >= MAX_SEARCH_JOURNEY_FILES) {
|
|
445
|
+
throw new UntangleError(
|
|
446
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
447
|
+
`search journey store reached its limit of ${String(MAX_SEARCH_JOURNEY_FILES)} records`,
|
|
448
|
+
{
|
|
449
|
+
operation: "search_journey_put",
|
|
450
|
+
context: { recordCount },
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const finalPath = this.pathFor(session.sessionId);
|
|
456
|
+
const tempPath = `${finalPath}.${this.random.uuid()}.tmp`;
|
|
457
|
+
try {
|
|
458
|
+
await this.fs.writeFile(tempPath, bytes, {
|
|
459
|
+
...signalOptions(options),
|
|
460
|
+
mode: 0o600,
|
|
461
|
+
});
|
|
462
|
+
await verifyLeasesForCommit(
|
|
463
|
+
creationLease === null ? [lease] : [creationLease, lease],
|
|
464
|
+
this.clock,
|
|
465
|
+
);
|
|
466
|
+
await this.fs.rename(tempPath, finalPath, options);
|
|
467
|
+
} finally {
|
|
468
|
+
await this.fs.remove(tempPath).catch(() => undefined);
|
|
469
|
+
}
|
|
470
|
+
} finally {
|
|
471
|
+
await lease.release();
|
|
472
|
+
}
|
|
473
|
+
} finally {
|
|
474
|
+
await creationLease?.release();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
public async list(workspaceId: string, options?: PortCallOptions): Promise<SearchJourneyV1[]> {
|
|
479
|
+
assertNotAborted(options, "search_journey_list");
|
|
480
|
+
try {
|
|
481
|
+
const stat = await this.fs.stat(this.sessionsDir, options);
|
|
482
|
+
if (stat === null) return [];
|
|
483
|
+
const entries = (await this.fs.readDir(this.sessionsDir, options))
|
|
484
|
+
.filter((entry) => entry.kind === "file" && entry.name.endsWith(".json"))
|
|
485
|
+
.sort((left, right) => compareCodepoints(left.name, right.name));
|
|
486
|
+
if (entries.length > MAX_SEARCH_JOURNEY_FILES) {
|
|
487
|
+
throw new UntangleError(
|
|
488
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
489
|
+
`search journey store contains ${String(entries.length)} records; limit is ${String(MAX_SEARCH_JOURNEY_FILES)}`,
|
|
490
|
+
{ operation: "search_journey_list", context: { recordCount: entries.length } },
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
const manifest: SearchJourneyReadManifestEntry[] = [];
|
|
494
|
+
let aggregateBytes = 0;
|
|
495
|
+
for (const entry of entries) {
|
|
496
|
+
assertNotAborted(options, "search_journey_list_budget");
|
|
497
|
+
const sessionId = entry.name.slice(0, -".json".length);
|
|
498
|
+
const recordPath = this.pathFor(sessionId);
|
|
499
|
+
const entryStat = await this.fs.stat(recordPath, options);
|
|
500
|
+
if (entryStat === null) {
|
|
501
|
+
throw new UntangleError(
|
|
502
|
+
"SEARCH_SESSION_CONFLICT",
|
|
503
|
+
`search journey record "${entry.name}" disappeared during snapshot preflight`,
|
|
504
|
+
{
|
|
505
|
+
operation: "search_journey_list",
|
|
506
|
+
retryable: true,
|
|
507
|
+
context: { fileName: entry.name, phase: "preflight" },
|
|
508
|
+
},
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (entryStat.kind !== "file" || entryStat.sizeBytes > MAX_SEARCH_JOURNEY_RECORD_BYTES) {
|
|
512
|
+
throw new UntangleError(
|
|
513
|
+
"INDEX_INCOMPLETE",
|
|
514
|
+
`search journey record "${entry.name}" is corrupted`,
|
|
515
|
+
{
|
|
516
|
+
operation: "search_journey_list",
|
|
517
|
+
context: {
|
|
518
|
+
fileName: entry.name,
|
|
519
|
+
kind: entryStat.kind,
|
|
520
|
+
sizeBytes: entryStat.sizeBytes,
|
|
521
|
+
maximumRecordBytes: MAX_SEARCH_JOURNEY_RECORD_BYTES,
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
manifest.push({
|
|
527
|
+
fileName: entry.name,
|
|
528
|
+
path: recordPath,
|
|
529
|
+
sessionId,
|
|
530
|
+
sizeBytes: entryStat.sizeBytes,
|
|
531
|
+
});
|
|
532
|
+
aggregateBytes += entryStat.sizeBytes;
|
|
533
|
+
if (aggregateBytes > MAX_SEARCH_JOURNEY_LIST_BYTES) {
|
|
534
|
+
throw new UntangleError(
|
|
535
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
536
|
+
`search journey snapshot exceeds the ${String(MAX_SEARCH_JOURNEY_LIST_BYTES)} byte aggregate read limit`,
|
|
537
|
+
{
|
|
538
|
+
operation: "search_journey_list",
|
|
539
|
+
context: {
|
|
540
|
+
recordCount: entries.length,
|
|
541
|
+
aggregateBytes,
|
|
542
|
+
maximumBytes: MAX_SEARCH_JOURNEY_LIST_BYTES,
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const sessions: SearchJourneyV1[] = [];
|
|
549
|
+
let actualAggregateBytes = 0;
|
|
550
|
+
for (const entry of manifest) {
|
|
551
|
+
assertNotAborted(options, "search_journey_list_read");
|
|
552
|
+
let text: string;
|
|
553
|
+
try {
|
|
554
|
+
text = await this.fs.readTextFile(entry.path, options);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (isNotFoundError(error)) {
|
|
557
|
+
throw new UntangleError(
|
|
558
|
+
"SEARCH_SESSION_CONFLICT",
|
|
559
|
+
`search journey record "${entry.fileName}" disappeared during snapshot read`,
|
|
560
|
+
{
|
|
561
|
+
operation: "search_journey_list",
|
|
562
|
+
retryable: true,
|
|
563
|
+
context: { fileName: entry.fileName, phase: "read" },
|
|
564
|
+
cause: error,
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
const actualBytes = Buffer.byteLength(text, "utf8");
|
|
571
|
+
if (actualBytes > MAX_SEARCH_JOURNEY_RECORD_BYTES) {
|
|
572
|
+
throw new UntangleError(
|
|
573
|
+
"INDEX_INCOMPLETE",
|
|
574
|
+
`search journey record "${entry.fileName}" exceeds its byte limit during snapshot read`,
|
|
575
|
+
{
|
|
576
|
+
operation: "search_journey_list",
|
|
577
|
+
context: {
|
|
578
|
+
fileName: entry.fileName,
|
|
579
|
+
actualBytes,
|
|
580
|
+
maximumRecordBytes: MAX_SEARCH_JOURNEY_RECORD_BYTES,
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
actualAggregateBytes += actualBytes;
|
|
586
|
+
if (actualAggregateBytes > MAX_SEARCH_JOURNEY_LIST_BYTES) {
|
|
587
|
+
throw new UntangleError(
|
|
588
|
+
"SEARCH_SESSION_INVALID_STATE",
|
|
589
|
+
`search journey snapshot exceeds the ${String(MAX_SEARCH_JOURNEY_LIST_BYTES)} byte aggregate read limit`,
|
|
590
|
+
{
|
|
591
|
+
operation: "search_journey_list",
|
|
592
|
+
context: {
|
|
593
|
+
recordCount: manifest.length,
|
|
594
|
+
aggregateBytes: actualAggregateBytes,
|
|
595
|
+
maximumBytes: MAX_SEARCH_JOURNEY_LIST_BYTES,
|
|
596
|
+
phase: "read",
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
const postReadStat = await this.fs.stat(entry.path, options);
|
|
602
|
+
if (
|
|
603
|
+
postReadStat === null ||
|
|
604
|
+
postReadStat.kind !== "file" ||
|
|
605
|
+
postReadStat.sizeBytes !== entry.sizeBytes ||
|
|
606
|
+
actualBytes !== entry.sizeBytes
|
|
607
|
+
) {
|
|
608
|
+
throw new UntangleError(
|
|
609
|
+
"SEARCH_SESSION_CONFLICT",
|
|
610
|
+
`search journey record "${entry.fileName}" changed during snapshot read`,
|
|
611
|
+
{
|
|
612
|
+
operation: "search_journey_list",
|
|
613
|
+
retryable: true,
|
|
614
|
+
context: {
|
|
615
|
+
fileName: entry.fileName,
|
|
616
|
+
phase: "post-read",
|
|
617
|
+
expectedBytes: entry.sizeBytes,
|
|
618
|
+
actualBytes,
|
|
619
|
+
observedBytes: postReadStat?.sizeBytes ?? null,
|
|
620
|
+
observedKind: postReadStat?.kind ?? "missing",
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
const session = this.parse(entry.sessionId, text, "search_journey_list");
|
|
626
|
+
if (session.workspaceId === workspaceId) sessions.push(session);
|
|
627
|
+
}
|
|
628
|
+
return sessions.sort(
|
|
629
|
+
(left, right) =>
|
|
630
|
+
compareCodepoints(left.createdAt, right.createdAt) ||
|
|
631
|
+
compareCodepoints(left.sessionId, right.sessionId),
|
|
632
|
+
);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (isNotFoundError(error)) return [];
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|