@lsdsoftware/ordered-record-store 0.2.0

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.
@@ -0,0 +1,797 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, open, readdir, readFile, rename, truncate, unlink, } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { decodeSegment, encodeRecordFrame, encodeSegmentHeader, } from './segment-codec.js';
5
+ import { generateRecordIdentity, invertUuidHex, parseRecordId, segmentBytesFromHex, } from './record-id.js';
6
+ import { OrderedRecordStoreError, } from './types.js';
7
+ import { assertData, assertIdentifier, assertLimit, invalidArgument } from './validation.js';
8
+ const DEFAULT_ROTATION_TARGET_BYTES = 256 * 1024;
9
+ const DEFAULT_CHECKPOINT_DELAY_MILLISECONDS = 5 * 60 * 1000;
10
+ const DEFAULT_IDLE_EVICTION_MILLISECONDS = 14 * 24 * 60 * 60 * 1000;
11
+ const DEFAULT_SWEEP_INTERVAL_MILLISECONDS = 60 * 60 * 1000;
12
+ const DEFAULT_RETRY_DELAY_MILLISECONDS = 30 * 1000;
13
+ const DEFAULT_MAX_CONCURRENT_OBJECT_OPERATIONS = 4;
14
+ const MAX_CACHED_REMOTE_SEGMENTS = 64;
15
+ export async function openSegmentedOrderedRecordStore(options) {
16
+ const store = new SegmentedOrderedRecordStore(options);
17
+ await store.initialize();
18
+ return store;
19
+ }
20
+ class SegmentedOrderedRecordStore {
21
+ #dataDirectory;
22
+ #segmentsDirectory;
23
+ #objectStore;
24
+ #rotationTargetBytes;
25
+ #checkpointDelayMilliseconds;
26
+ #idleEvictionMilliseconds;
27
+ #retryDelayMilliseconds;
28
+ #objectLimiter;
29
+ #streams = new Map();
30
+ #remoteSegments = new Map();
31
+ #sweepIntervalMilliseconds;
32
+ #sweepTimer = null;
33
+ #closed = false;
34
+ constructor(options) {
35
+ if (!path.isAbsolute(options.dataDirectory)) {
36
+ throw invalidArgument('dataDirectory must be an absolute path');
37
+ }
38
+ this.#dataDirectory = path.resolve(options.dataDirectory);
39
+ this.#segmentsDirectory = path.join(this.#dataDirectory, 'v1', 'segments');
40
+ this.#objectStore = options.objectStore;
41
+ this.#rotationTargetBytes = positiveInteger(options.rotationTargetBytes ?? DEFAULT_ROTATION_TARGET_BYTES, 'rotationTargetBytes');
42
+ this.#checkpointDelayMilliseconds = nonnegativeInteger(options.checkpointDelayMilliseconds ?? DEFAULT_CHECKPOINT_DELAY_MILLISECONDS, 'checkpointDelayMilliseconds');
43
+ this.#idleEvictionMilliseconds = positiveInteger(options.idleEvictionMilliseconds ?? DEFAULT_IDLE_EVICTION_MILLISECONDS, 'idleEvictionMilliseconds');
44
+ this.#sweepIntervalMilliseconds = positiveInteger(options.sweepIntervalMilliseconds ?? DEFAULT_SWEEP_INTERVAL_MILLISECONDS, 'sweepIntervalMilliseconds');
45
+ this.#retryDelayMilliseconds = positiveInteger(options.retryDelayMilliseconds ?? DEFAULT_RETRY_DELAY_MILLISECONDS, 'retryDelayMilliseconds');
46
+ this.#objectLimiter = new PriorityLimiter(positiveInteger(options.maxConcurrentObjectOperations ?? DEFAULT_MAX_CONCURRENT_OBJECT_OPERATIONS, 'maxConcurrentObjectOperations'));
47
+ }
48
+ async initialize() {
49
+ await mkdir(this.#segmentsDirectory, { recursive: true });
50
+ for (const filePath of await listSegmentFiles(this.#segmentsDirectory)) {
51
+ await this.#loadLocalFile(filePath);
52
+ }
53
+ const now = Date.now();
54
+ for (const stream of this.#streams.values()) {
55
+ stream.segments.sort(compareSegments);
56
+ const currentSegment = stream.segments.at(-1);
57
+ for (const segment of [...stream.segments]) {
58
+ const current = segment === currentSegment;
59
+ segment.final = !current;
60
+ if (!current && !segment.dirty) {
61
+ await this.#removeLocalSegment(stream, segment);
62
+ }
63
+ else if (segment.dirty) {
64
+ segment.dueAt = now;
65
+ }
66
+ else if (now - segment.lastAppendMilliseconds >= this.#idleEvictionMilliseconds) {
67
+ await this.#removeLocalSegment(stream, segment);
68
+ }
69
+ }
70
+ }
71
+ this.#sweepTimer = setInterval(() => {
72
+ void this.#sweepIdleSegments();
73
+ }, this.#sweepIntervalMilliseconds);
74
+ this.#sweepTimer.unref();
75
+ queueMicrotask(() => {
76
+ for (const stream of this.#streams.values())
77
+ this.#kickUploadWorker(stream);
78
+ });
79
+ }
80
+ async append({ streamId, data }) {
81
+ this.#assertOpen();
82
+ assertIdentifier(streamId, 'streamId');
83
+ assertData(data);
84
+ const stream = this.#getStream(streamId);
85
+ return this.#withStream(stream, async () => {
86
+ let segment = await this.#ensureCurrentLocalSegment(stream);
87
+ if (segment !== null && segment.buffer.length >= this.#rotationTargetBytes) {
88
+ await this.#retireCurrentSegment(stream, segment);
89
+ segment = null;
90
+ }
91
+ const record = segment === null
92
+ ? await this.#createSegmentWithRecord(stream, data)
93
+ : await this.#appendToSegment(stream, segment, data);
94
+ this.#kickUploadWorker(stream);
95
+ return record;
96
+ });
97
+ }
98
+ async read({ streamId, beforeId, afterId, limit }) {
99
+ this.#assertOpen();
100
+ assertIdentifier(streamId, 'streamId');
101
+ assertLimit(limit);
102
+ if (beforeId !== undefined && afterId !== undefined) {
103
+ throw invalidArgument('beforeId and afterId are mutually exclusive');
104
+ }
105
+ const boundary = beforeId ?? afterId;
106
+ if (boundary !== undefined)
107
+ parseRecordId(boundary);
108
+ const stream = this.#getStream(streamId);
109
+ return this.#withStream(stream, async () => {
110
+ if (afterId !== undefined) {
111
+ return this.#readAfter(stream, afterId, limit);
112
+ }
113
+ return this.#readBackward(stream, beforeId, limit);
114
+ });
115
+ }
116
+ async getLatest(streamIds) {
117
+ this.#assertOpen();
118
+ for (const streamId of streamIds)
119
+ assertIdentifier(streamId, 'streamId');
120
+ const entries = await Promise.all(streamIds.map(async (streamId) => {
121
+ const stream = this.#getStream(streamId);
122
+ const record = await this.#withStream(stream, () => this.#getLatestForStream(stream));
123
+ return [streamId, record];
124
+ }));
125
+ return new Map(entries);
126
+ }
127
+ async close() {
128
+ if (this.#closed)
129
+ return;
130
+ this.#closed = true;
131
+ if (this.#sweepTimer !== null)
132
+ clearInterval(this.#sweepTimer);
133
+ for (const stream of this.#streams.values()) {
134
+ if (stream.uploadTimer !== null)
135
+ clearTimeout(stream.uploadTimer);
136
+ stream.uploadTimer = null;
137
+ }
138
+ await Promise.all(Array.from(this.#streams.values(), stream => stream.tail));
139
+ await Promise.all(Array.from(this.#streams.values(), stream => stream.uploadWorker));
140
+ await Promise.all(Array.from(this.#streams.values(), stream => stream.tail));
141
+ this.#remoteSegments.clear();
142
+ }
143
+ async #loadLocalFile(filePath) {
144
+ const dirty = filePath.endsWith('.dirty.ors');
145
+ let location;
146
+ try {
147
+ location = parseLocalPath(this.#segmentsDirectory, filePath);
148
+ }
149
+ catch (error) {
150
+ if (dirty)
151
+ throw error;
152
+ await quarantine(filePath);
153
+ return;
154
+ }
155
+ let bytes;
156
+ let decoded;
157
+ try {
158
+ bytes = await readFile(filePath);
159
+ decoded = decodeSegment(bytes, { segmentHex: location.segmentHex });
160
+ assertHashLocation(decoded.streamId, location.shard, location.hashRemainder);
161
+ if (decoded.incompleteTail) {
162
+ if (!dirty)
163
+ throw corrupt('clean segment has an incomplete tail');
164
+ await truncate(filePath, decoded.validLength);
165
+ await syncFile(filePath);
166
+ bytes = bytes.subarray(0, decoded.validLength);
167
+ decoded = decodeSegment(bytes, { segmentHex: location.segmentHex });
168
+ }
169
+ }
170
+ catch (error) {
171
+ if (dirty)
172
+ throw error;
173
+ await quarantine(filePath);
174
+ return;
175
+ }
176
+ const stream = this.#getStream(decoded.streamId);
177
+ if (stream.segments.some(segment => segment.segmentHex === decoded.segmentHex)) {
178
+ if (dirty)
179
+ throw corrupt(`duplicate local segment ${decoded.segmentHex}`);
180
+ await quarantine(filePath);
181
+ return;
182
+ }
183
+ const lastAppendMilliseconds = Date.parse(decoded.records.at(-1).createdAt);
184
+ stream.segments.push({
185
+ streamId: decoded.streamId,
186
+ segmentHex: decoded.segmentHex,
187
+ segmentBytes: decoded.segmentBytes,
188
+ key: objectKey(decoded.streamId, decoded.segmentBytes),
189
+ path: filePath,
190
+ buffer: bytes,
191
+ records: [...decoded.records],
192
+ dirty,
193
+ final: false,
194
+ version: 0,
195
+ lastAppendMilliseconds,
196
+ dueAt: null,
197
+ retryAt: null,
198
+ uploading: false,
199
+ });
200
+ }
201
+ async #ensureCurrentLocalSegment(stream) {
202
+ const current = stream.segments.at(-1);
203
+ if (current !== undefined)
204
+ return current;
205
+ const objects = await this.#listObjects({ prefix: objectPrefix(stream.streamId), limit: 1 });
206
+ const newest = objects[0];
207
+ if (newest === undefined || newest.size >= this.#rotationTargetBytes)
208
+ return null;
209
+ const segmentHex = parseObjectKey(stream.streamId, newest.key);
210
+ const remote = await this.#getRemoteSegment(stream.streamId, segmentHex, newest.key);
211
+ if (remote === null)
212
+ throw corrupt(`listed segment disappeared: ${newest.key}`);
213
+ const segment = await this.#installCleanLocalSegment(stream, remote.decoded, remote.bytes);
214
+ this.#remoteSegments.delete(newest.key);
215
+ return segment;
216
+ }
217
+ async #installCleanLocalSegment(stream, decoded, bytes) {
218
+ const directory = localStreamDirectory(this.#segmentsDirectory, stream.streamId);
219
+ await mkdir(directory, { recursive: true });
220
+ const finalPath = path.join(directory, `${decoded.segmentHex}.clean.ors`);
221
+ const temporaryPath = path.join(directory, `${decoded.segmentHex}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`);
222
+ const handle = await open(temporaryPath, 'wx');
223
+ try {
224
+ await handle.writeFile(bytes);
225
+ await handle.sync();
226
+ }
227
+ finally {
228
+ await handle.close();
229
+ }
230
+ await rename(temporaryPath, finalPath);
231
+ await syncDirectory(directory);
232
+ const segment = {
233
+ streamId: stream.streamId,
234
+ segmentHex: decoded.segmentHex,
235
+ segmentBytes: decoded.segmentBytes,
236
+ key: objectKey(stream.streamId, decoded.segmentBytes),
237
+ path: finalPath,
238
+ buffer: bytes,
239
+ records: [...decoded.records],
240
+ dirty: false,
241
+ final: false,
242
+ version: 0,
243
+ lastAppendMilliseconds: Date.parse(decoded.records.at(-1).createdAt),
244
+ dueAt: null,
245
+ retryAt: null,
246
+ uploading: false,
247
+ };
248
+ stream.segments.push(segment);
249
+ stream.segments.sort(compareSegments);
250
+ return segment;
251
+ }
252
+ async #createSegmentWithRecord(stream, data) {
253
+ const identity = generateRecordIdentity();
254
+ const header = encodeSegmentHeader(stream.streamId, identity.segmentBytes, identity.createdAtMilliseconds);
255
+ const frame = encodeRecordFrame(identity, data);
256
+ const bytes = Buffer.concat([header, frame]);
257
+ const directory = localStreamDirectory(this.#segmentsDirectory, stream.streamId);
258
+ await mkdir(directory, { recursive: true });
259
+ const filePath = path.join(directory, `${identity.segmentHex}.dirty.ors`);
260
+ try {
261
+ const handle = await open(filePath, 'wx');
262
+ try {
263
+ await handle.writeFile(bytes);
264
+ await handle.sync();
265
+ }
266
+ finally {
267
+ await handle.close();
268
+ }
269
+ await syncDirectory(directory);
270
+ }
271
+ catch (error) {
272
+ await unlink(filePath).catch(() => undefined);
273
+ throw error;
274
+ }
275
+ const record = {
276
+ id: identity.id,
277
+ streamId: stream.streamId,
278
+ data,
279
+ createdAt: new Date(identity.createdAtMilliseconds).toISOString(),
280
+ };
281
+ const segment = {
282
+ streamId: stream.streamId,
283
+ segmentHex: identity.segmentHex,
284
+ segmentBytes: identity.segmentBytes,
285
+ key: objectKey(stream.streamId, identity.segmentBytes),
286
+ path: filePath,
287
+ buffer: bytes,
288
+ records: [record],
289
+ dirty: true,
290
+ final: false,
291
+ version: 1,
292
+ lastAppendMilliseconds: Date.now(),
293
+ dueAt: Date.now() + this.#checkpointDelayMilliseconds,
294
+ retryAt: null,
295
+ uploading: false,
296
+ };
297
+ stream.segments.push(segment);
298
+ stream.segments.sort(compareSegments);
299
+ return record;
300
+ }
301
+ async #appendToSegment(stream, segment, data) {
302
+ if (!segment.dirty)
303
+ await this.#markDirty(segment);
304
+ const identity = generateRecordIdentity(segment.segmentBytes);
305
+ const frame = encodeRecordFrame(identity, data);
306
+ const previousLength = segment.buffer.length;
307
+ const handle = await open(segment.path, 'a');
308
+ try {
309
+ await handle.write(frame);
310
+ await handle.sync();
311
+ }
312
+ catch (error) {
313
+ await handle.close().catch(() => undefined);
314
+ await truncate(segment.path, previousLength).catch(() => undefined);
315
+ await syncFile(segment.path).catch(() => undefined);
316
+ throw error;
317
+ }
318
+ await handle.close();
319
+ const record = {
320
+ id: identity.id,
321
+ streamId: stream.streamId,
322
+ data,
323
+ createdAt: new Date(identity.createdAtMilliseconds).toISOString(),
324
+ };
325
+ segment.buffer = Buffer.concat([segment.buffer, frame]);
326
+ segment.records.push(record);
327
+ segment.version++;
328
+ segment.lastAppendMilliseconds = Date.now();
329
+ if (segment.dueAt === null && segment.retryAt === null) {
330
+ segment.dueAt = Date.now() + this.#checkpointDelayMilliseconds;
331
+ }
332
+ return record;
333
+ }
334
+ async #markDirty(segment) {
335
+ const dirtyPath = segment.path.replace(/\.clean\.ors$/, '.dirty.ors');
336
+ await rename(segment.path, dirtyPath);
337
+ await syncDirectory(path.dirname(segment.path));
338
+ segment.path = dirtyPath;
339
+ segment.dirty = true;
340
+ segment.dueAt = Date.now() + this.#checkpointDelayMilliseconds;
341
+ segment.retryAt = null;
342
+ }
343
+ async #retireCurrentSegment(stream, segment) {
344
+ segment.final = true;
345
+ if (segment.dirty) {
346
+ segment.dueAt = Date.now();
347
+ segment.retryAt = null;
348
+ this.#kickUploadWorker(stream);
349
+ }
350
+ else {
351
+ await this.#removeLocalSegment(stream, segment);
352
+ }
353
+ }
354
+ async #readBackward(stream, beforeId, limit) {
355
+ let segmentHex;
356
+ let recordIndex;
357
+ if (beforeId === undefined) {
358
+ segmentHex = await this.#findHeadSegmentHex(stream);
359
+ if (segmentHex === null)
360
+ return [];
361
+ recordIndex = Number.POSITIVE_INFINITY;
362
+ }
363
+ else {
364
+ segmentHex = parseRecordId(beforeId).segmentHex;
365
+ recordIndex = -1;
366
+ }
367
+ const reverse = [];
368
+ let first = true;
369
+ while (segmentHex !== null && reverse.length < limit) {
370
+ const decoded = await this.#loadSegment(stream, segmentHex);
371
+ if (first && beforeId !== undefined) {
372
+ recordIndex = decoded.records.findIndex(record => record.id === beforeId);
373
+ if (recordIndex < 0)
374
+ throw invalidBoundary();
375
+ }
376
+ else if (recordIndex === Number.POSITIVE_INFINITY || !first) {
377
+ recordIndex = decoded.records.length;
378
+ }
379
+ for (let index = recordIndex - 1; index >= 0 && reverse.length < limit; index--) {
380
+ reverse.push(decoded.records[index]);
381
+ }
382
+ first = false;
383
+ if (reverse.length < limit) {
384
+ segmentHex = await this.#findOlderSegmentHex(stream, segmentHex);
385
+ }
386
+ }
387
+ return reverse.reverse();
388
+ }
389
+ async #readAfter(stream, afterId, limit) {
390
+ let segmentHex = parseRecordId(afterId).segmentHex;
391
+ const result = [];
392
+ let first = true;
393
+ while (segmentHex !== null && result.length < limit) {
394
+ const decoded = await this.#loadSegment(stream, segmentHex);
395
+ let start = 0;
396
+ if (first) {
397
+ const index = decoded.records.findIndex(record => record.id === afterId);
398
+ if (index < 0)
399
+ throw invalidBoundary();
400
+ start = index + 1;
401
+ }
402
+ for (let index = start; index < decoded.records.length && result.length < limit; index++) {
403
+ result.push(decoded.records[index]);
404
+ }
405
+ first = false;
406
+ if (result.length < limit) {
407
+ segmentHex = await this.#findNewerSegmentHex(stream, segmentHex);
408
+ }
409
+ }
410
+ return result;
411
+ }
412
+ async #getLatestForStream(stream) {
413
+ const current = stream.segments.at(-1);
414
+ if (current !== undefined)
415
+ return current.records.at(-1) ?? null;
416
+ const objects = await this.#listObjects({ prefix: objectPrefix(stream.streamId), limit: 1 });
417
+ const newest = objects[0];
418
+ if (newest === undefined)
419
+ return null;
420
+ const segmentHex = parseObjectKey(stream.streamId, newest.key);
421
+ const remote = await this.#getRemoteSegment(stream.streamId, segmentHex, newest.key);
422
+ if (remote === null)
423
+ throw corrupt(`listed segment disappeared: ${newest.key}`);
424
+ return remote.decoded.records.at(-1);
425
+ }
426
+ async #findHeadSegmentHex(stream) {
427
+ const local = stream.segments.at(-1);
428
+ if (local !== undefined)
429
+ return local.segmentHex;
430
+ const objects = await this.#listObjects({ prefix: objectPrefix(stream.streamId), limit: 1 });
431
+ return objects[0] === undefined ? null : parseObjectKey(stream.streamId, objects[0].key);
432
+ }
433
+ async #loadSegment(stream, segmentHex) {
434
+ const local = stream.segments.find(segment => segment.segmentHex === segmentHex);
435
+ if (local !== undefined) {
436
+ return decodeSegment(local.buffer, { streamId: stream.streamId, segmentHex });
437
+ }
438
+ const key = objectKey(stream.streamId, segmentBytesFromHex(segmentHex));
439
+ const remote = await this.#getRemoteSegment(stream.streamId, segmentHex, key);
440
+ if (remote === null)
441
+ throw invalidBoundary();
442
+ return remote.decoded;
443
+ }
444
+ async #findOlderSegmentHex(stream, segmentHex) {
445
+ const segmentBytes = segmentBytesFromHex(segmentHex);
446
+ const local = stream.segments
447
+ .filter(segment => Buffer.compare(segment.segmentBytes, segmentBytes) < 0)
448
+ .sort(compareSegments)
449
+ .at(-1);
450
+ const currentKey = objectKey(stream.streamId, segmentBytes);
451
+ const remote = (await this.#listObjects({
452
+ prefix: objectPrefix(stream.streamId),
453
+ startAfter: currentKey,
454
+ limit: 1,
455
+ }))[0];
456
+ const remoteHex = remote === undefined ? null : parseObjectKey(stream.streamId, remote.key);
457
+ if (local === undefined)
458
+ return remoteHex;
459
+ if (remoteHex === null)
460
+ return local.segmentHex;
461
+ return Buffer.compare(local.segmentBytes, segmentBytesFromHex(remoteHex)) > 0
462
+ ? local.segmentHex
463
+ : remoteHex;
464
+ }
465
+ async #findNewerSegmentHex(stream, segmentHex) {
466
+ const segmentBytes = segmentBytesFromHex(segmentHex);
467
+ const local = stream.segments
468
+ .filter(segment => Buffer.compare(segment.segmentBytes, segmentBytes) > 0)
469
+ .sort(compareSegments)[0];
470
+ const prefix = objectPrefix(stream.streamId);
471
+ const boundaryKey = objectKey(stream.streamId, segmentBytes);
472
+ let startAfter;
473
+ let remoteHex = null;
474
+ for (;;) {
475
+ const page = await this.#listObjects({ prefix, startAfter, limit: 1_000 });
476
+ if (page.length === 0)
477
+ break;
478
+ let reachedBoundary = false;
479
+ for (const object of page) {
480
+ if (object.key >= boundaryKey) {
481
+ reachedBoundary = true;
482
+ break;
483
+ }
484
+ remoteHex = parseObjectKey(stream.streamId, object.key);
485
+ }
486
+ if (reachedBoundary || page.length < 1_000)
487
+ break;
488
+ startAfter = page.at(-1).key;
489
+ }
490
+ if (local === undefined)
491
+ return remoteHex;
492
+ if (remoteHex === null)
493
+ return local.segmentHex;
494
+ return Buffer.compare(local.segmentBytes, segmentBytesFromHex(remoteHex)) < 0
495
+ ? local.segmentHex
496
+ : remoteHex;
497
+ }
498
+ #kickUploadWorker(stream) {
499
+ if (this.#closed || stream.uploadWorker !== null)
500
+ return;
501
+ if (stream.uploadTimer !== null) {
502
+ clearTimeout(stream.uploadTimer);
503
+ stream.uploadTimer = null;
504
+ }
505
+ const worker = this.#runUploadWorker(stream);
506
+ stream.uploadWorker = worker;
507
+ void worker.finally(() => {
508
+ if (stream.uploadWorker === worker)
509
+ stream.uploadWorker = null;
510
+ });
511
+ }
512
+ async #runUploadWorker(stream) {
513
+ while (!this.#closed) {
514
+ const capture = await this.#withStream(stream, () => this.#captureDueUpload(stream));
515
+ if (capture === null)
516
+ return;
517
+ try {
518
+ const checksumSha256 = createHash('sha256').update(capture.data).digest('base64');
519
+ await this.#objectLimiter.run(() => this.#objectStore.put({
520
+ key: capture.segment.key,
521
+ data: capture.data,
522
+ checksumSha256,
523
+ }), true);
524
+ this.#remoteSegments.delete(capture.segment.key);
525
+ await this.#withStream(stream, () => this.#completeUpload(stream, capture));
526
+ }
527
+ catch {
528
+ await this.#withStream(stream, () => {
529
+ capture.segment.uploading = false;
530
+ capture.segment.retryAt = Date.now() + this.#retryDelayMilliseconds;
531
+ capture.segment.dueAt = null;
532
+ this.#scheduleUpload(stream, capture.segment.retryAt);
533
+ });
534
+ return;
535
+ }
536
+ }
537
+ }
538
+ #captureDueUpload(stream) {
539
+ const segment = stream.segments
540
+ .filter(candidate => candidate.dirty)
541
+ .sort(compareSegments)[0];
542
+ if (segment === undefined || segment.uploading)
543
+ return null;
544
+ const readyAt = Math.max(segment.dueAt ?? 0, segment.retryAt ?? 0);
545
+ if (readyAt > Date.now()) {
546
+ this.#scheduleUpload(stream, readyAt);
547
+ return null;
548
+ }
549
+ segment.uploading = true;
550
+ segment.dueAt = null;
551
+ segment.retryAt = null;
552
+ return { segment, data: Buffer.from(segment.buffer), version: segment.version };
553
+ }
554
+ async #completeUpload(stream, capture) {
555
+ const segment = capture.segment;
556
+ segment.uploading = false;
557
+ if (segment.version !== capture.version || segment.buffer.length !== capture.data.length) {
558
+ if (segment.dueAt === null) {
559
+ segment.dueAt = Date.now() + this.#checkpointDelayMilliseconds;
560
+ }
561
+ this.#scheduleUpload(stream, segment.dueAt);
562
+ return;
563
+ }
564
+ const cleanPath = segment.path.replace(/\.dirty\.ors$/, '.clean.ors');
565
+ await rename(segment.path, cleanPath);
566
+ await syncDirectory(path.dirname(segment.path));
567
+ segment.path = cleanPath;
568
+ segment.dirty = false;
569
+ if (segment.final
570
+ || Date.now() - segment.lastAppendMilliseconds >= this.#idleEvictionMilliseconds) {
571
+ await this.#removeLocalSegment(stream, segment);
572
+ }
573
+ }
574
+ #scheduleUpload(stream, at) {
575
+ if (this.#closed)
576
+ return;
577
+ if (stream.uploadTimer !== null)
578
+ clearTimeout(stream.uploadTimer);
579
+ stream.uploadTimer = setTimeout(() => {
580
+ stream.uploadTimer = null;
581
+ this.#kickUploadWorker(stream);
582
+ }, Math.max(0, at - Date.now()));
583
+ stream.uploadTimer.unref();
584
+ }
585
+ async #sweepIdleSegments() {
586
+ if (this.#closed)
587
+ return;
588
+ await Promise.all(Array.from(this.#streams.values(), stream => this.#withStream(stream, async () => {
589
+ const current = stream.segments.at(-1);
590
+ if (current === undefined
591
+ || Date.now() - current.lastAppendMilliseconds < this.#idleEvictionMilliseconds)
592
+ return;
593
+ if (current.dirty) {
594
+ current.dueAt = Date.now();
595
+ current.retryAt = null;
596
+ this.#kickUploadWorker(stream);
597
+ }
598
+ else {
599
+ await this.#removeLocalSegment(stream, current);
600
+ }
601
+ })));
602
+ }
603
+ async #removeLocalSegment(stream, segment) {
604
+ await unlink(segment.path).catch(error => {
605
+ if (error.code !== 'ENOENT')
606
+ throw error;
607
+ });
608
+ await syncDirectory(path.dirname(segment.path));
609
+ const index = stream.segments.indexOf(segment);
610
+ if (index >= 0)
611
+ stream.segments.splice(index, 1);
612
+ }
613
+ #getStream(streamId) {
614
+ let stream = this.#streams.get(streamId);
615
+ if (stream === undefined) {
616
+ stream = {
617
+ streamId,
618
+ segments: [],
619
+ tail: Promise.resolve(),
620
+ uploadWorker: null,
621
+ uploadTimer: null,
622
+ };
623
+ this.#streams.set(streamId, stream);
624
+ }
625
+ return stream;
626
+ }
627
+ #withStream(stream, operation) {
628
+ const result = stream.tail.then(operation, operation);
629
+ stream.tail = result.then(() => undefined, () => undefined);
630
+ return result;
631
+ }
632
+ async #listObjects(request) {
633
+ return this.#objectLimiter.run(() => this.#objectStore.list(request), false);
634
+ }
635
+ async #getRemoteSegment(streamId, segmentHex, key) {
636
+ const cached = this.#remoteSegments.get(key);
637
+ if (cached !== undefined) {
638
+ this.#remoteSegments.delete(key);
639
+ this.#remoteSegments.set(key, cached);
640
+ return cached;
641
+ }
642
+ const bytes = await this.#objectLimiter.run(() => this.#objectStore.get(key), false);
643
+ if (bytes === null)
644
+ return null;
645
+ const buffer = Buffer.from(bytes);
646
+ const decoded = decodeSegment(buffer, { streamId, segmentHex });
647
+ if (decoded.incompleteTail)
648
+ throw corrupt(`S3 segment has an incomplete tail: ${key}`);
649
+ const remote = { bytes: buffer, decoded };
650
+ this.#remoteSegments.set(key, remote);
651
+ while (this.#remoteSegments.size > MAX_CACHED_REMOTE_SEGMENTS) {
652
+ this.#remoteSegments.delete(this.#remoteSegments.keys().next().value);
653
+ }
654
+ return remote;
655
+ }
656
+ #assertOpen() {
657
+ if (this.#closed) {
658
+ throw new OrderedRecordStoreError('CLOSED', 'ordered record store is closed');
659
+ }
660
+ }
661
+ }
662
+ class PriorityLimiter {
663
+ #limit;
664
+ #active = 0;
665
+ #high = [];
666
+ #normal = [];
667
+ constructor(limit) {
668
+ this.#limit = limit;
669
+ }
670
+ async run(operation, highPriority) {
671
+ if (this.#active >= this.#limit) {
672
+ await new Promise(resolve => {
673
+ (highPriority ? this.#high : this.#normal).push(resolve);
674
+ });
675
+ }
676
+ this.#active++;
677
+ try {
678
+ return await operation();
679
+ }
680
+ finally {
681
+ this.#active--;
682
+ const next = this.#high.shift() ?? this.#normal.shift();
683
+ next?.();
684
+ }
685
+ }
686
+ }
687
+ function streamHash(streamId) {
688
+ return createHash('sha256').update(streamId, 'utf8').digest('hex');
689
+ }
690
+ function objectPrefix(streamId) {
691
+ const hash = streamHash(streamId);
692
+ return `segments/v1/${hash.slice(0, 2)}/${hash.slice(2)}/`;
693
+ }
694
+ function objectKey(streamId, segmentBytes) {
695
+ const segmentHex = Buffer.from(segmentBytes).toString('hex');
696
+ return `${objectPrefix(streamId)}${invertUuidHex(segmentBytes)}-${segmentHex}.ors`;
697
+ }
698
+ function parseObjectKey(streamId, key) {
699
+ const prefix = objectPrefix(streamId);
700
+ if (!key.startsWith(prefix))
701
+ throw corrupt(`object key is outside stream prefix: ${key}`);
702
+ const name = key.slice(prefix.length);
703
+ const match = /^([0-9a-f]{32})-([0-9a-f]{32})\.ors$/.exec(name);
704
+ if (match === null)
705
+ throw corrupt(`malformed segment object key: ${key}`);
706
+ let segmentBytes;
707
+ try {
708
+ segmentBytes = segmentBytesFromHex(match[2]);
709
+ }
710
+ catch {
711
+ throw corrupt(`segment object key contains an invalid UUIDv7: ${key}`);
712
+ }
713
+ if (invertUuidHex(segmentBytes) !== match[1]) {
714
+ throw corrupt(`segment object key has an invalid inverted ID: ${key}`);
715
+ }
716
+ return match[2];
717
+ }
718
+ function localStreamDirectory(root, streamId) {
719
+ const hash = streamHash(streamId);
720
+ return path.join(root, hash.slice(0, 2), hash.slice(2));
721
+ }
722
+ function parseLocalPath(root, filePath) {
723
+ const parts = path.relative(root, filePath).split(path.sep);
724
+ if (parts.length !== 3 || !/^[0-9a-f]{2}$/.test(parts[0]) || !/^[0-9a-f]{62}$/.test(parts[1])) {
725
+ throw corrupt(`malformed local segment path: ${filePath}`);
726
+ }
727
+ const match = /^([0-9a-f]{32})\.(?:clean|dirty)\.ors$/.exec(parts[2]);
728
+ if (match === null)
729
+ throw corrupt(`malformed local segment filename: ${filePath}`);
730
+ try {
731
+ segmentBytesFromHex(match[1]);
732
+ }
733
+ catch {
734
+ throw corrupt(`local segment filename contains an invalid UUIDv7: ${filePath}`);
735
+ }
736
+ return { shard: parts[0], hashRemainder: parts[1], segmentHex: match[1] };
737
+ }
738
+ function assertHashLocation(streamId, shard, hashRemainder) {
739
+ const expected = streamHash(streamId);
740
+ if (expected !== `${shard}${hashRemainder}`) {
741
+ throw corrupt('segment stream ID does not match its local hash directory');
742
+ }
743
+ }
744
+ async function listSegmentFiles(directory) {
745
+ const result = [];
746
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
747
+ const child = path.join(directory, entry.name);
748
+ if (entry.isDirectory())
749
+ result.push(...await listSegmentFiles(child));
750
+ else if (entry.isFile() && /\.(?:clean|dirty)\.ors$/.test(entry.name))
751
+ result.push(child);
752
+ }
753
+ return result;
754
+ }
755
+ async function syncFile(filePath) {
756
+ const handle = await open(filePath, 'r+');
757
+ try {
758
+ await handle.sync();
759
+ }
760
+ finally {
761
+ await handle.close();
762
+ }
763
+ }
764
+ async function syncDirectory(directory) {
765
+ const handle = await open(directory, 'r');
766
+ try {
767
+ await handle.sync();
768
+ }
769
+ finally {
770
+ await handle.close();
771
+ }
772
+ }
773
+ async function quarantine(filePath) {
774
+ await rename(filePath, `${filePath}.quarantine-${Date.now()}-${randomBytes(4).toString('hex')}`);
775
+ await syncDirectory(path.dirname(filePath));
776
+ }
777
+ function compareSegments(left, right) {
778
+ return Buffer.compare(left.segmentBytes, right.segmentBytes);
779
+ }
780
+ function positiveInteger(value, name) {
781
+ if (!Number.isSafeInteger(value) || value <= 0) {
782
+ throw invalidArgument(`${name} must be a positive safe integer`);
783
+ }
784
+ return value;
785
+ }
786
+ function nonnegativeInteger(value, name) {
787
+ if (!Number.isSafeInteger(value) || value < 0) {
788
+ throw invalidArgument(`${name} must be a nonnegative safe integer`);
789
+ }
790
+ return value;
791
+ }
792
+ function invalidBoundary() {
793
+ return invalidArgument('pagination boundary must be a record from the requested stream');
794
+ }
795
+ function corrupt(message) {
796
+ return new OrderedRecordStoreError('CORRUPT_DATA', message);
797
+ }