@durable-streams/server 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +518 -47
- package/dist/index.d.cts +191 -15
- package/dist/index.d.ts +191 -15
- package/dist/index.js +518 -47
- package/package.json +5 -5
- package/src/file-store.ts +238 -10
- package/src/server.ts +398 -61
- package/src/store.ts +272 -7
- package/src/types.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Node.js reference server implementation for Durable Streams",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,15 +39,15 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@neophi/sieve-cache": "^1.0.0",
|
|
41
41
|
"lmdb": "^3.3.0",
|
|
42
|
-
"@durable-streams/client": "0.1.
|
|
43
|
-
"@durable-streams/state": "0.1.
|
|
42
|
+
"@durable-streams/client": "0.1.4",
|
|
43
|
+
"@durable-streams/state": "0.1.4"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.0.0",
|
|
47
47
|
"tsdown": "^0.9.0",
|
|
48
48
|
"typescript": "^5.0.0",
|
|
49
|
-
"vitest": "^
|
|
50
|
-
"@durable-streams/server-conformance-tests": "0.1.
|
|
49
|
+
"vitest": "^4.0.0",
|
|
50
|
+
"@durable-streams/server-conformance-tests": "0.1.7"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"dist",
|
package/src/file-store.ts
CHANGED
|
@@ -15,8 +15,24 @@ import {
|
|
|
15
15
|
normalizeContentType,
|
|
16
16
|
processJsonAppend,
|
|
17
17
|
} from "./store"
|
|
18
|
+
import type { AppendOptions, AppendResult } from "./store"
|
|
18
19
|
import type { Database } from "lmdb"
|
|
19
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
PendingLongPoll,
|
|
22
|
+
ProducerState,
|
|
23
|
+
ProducerValidationResult,
|
|
24
|
+
Stream,
|
|
25
|
+
StreamMessage,
|
|
26
|
+
} from "./types"
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Serializable producer state for LMDB storage.
|
|
30
|
+
*/
|
|
31
|
+
interface SerializableProducerState {
|
|
32
|
+
epoch: number
|
|
33
|
+
lastSeq: number
|
|
34
|
+
lastUpdated: number
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
38
|
* Stream metadata stored in LMDB.
|
|
@@ -37,6 +53,11 @@ interface StreamMetadata {
|
|
|
37
53
|
* This allows safe async deletion and immediate reuse of stream paths.
|
|
38
54
|
*/
|
|
39
55
|
directoryName: string
|
|
56
|
+
/**
|
|
57
|
+
* Producer states for idempotent writes.
|
|
58
|
+
* Stored as a plain object for LMDB serialization.
|
|
59
|
+
*/
|
|
60
|
+
producers?: Record<string, SerializableProducerState>
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
/**
|
|
@@ -168,6 +189,11 @@ export class FileBackedStreamStore {
|
|
|
168
189
|
private fileHandlePool: FileHandlePool
|
|
169
190
|
private pendingLongPolls: Array<PendingLongPoll> = []
|
|
170
191
|
private dataDir: string
|
|
192
|
+
/**
|
|
193
|
+
* Per-producer locks for serializing validation+append operations.
|
|
194
|
+
* Key: "{streamPath}:{producerId}"
|
|
195
|
+
*/
|
|
196
|
+
private producerLocks = new Map<string, Promise<unknown>>()
|
|
171
197
|
|
|
172
198
|
constructor(options: FileBackedStreamStoreOptions) {
|
|
173
199
|
this.dataDir = options.dataDir
|
|
@@ -320,6 +346,15 @@ export class FileBackedStreamStore {
|
|
|
320
346
|
* Convert LMDB metadata to Stream object.
|
|
321
347
|
*/
|
|
322
348
|
private streamMetaToStream(meta: StreamMetadata): Stream {
|
|
349
|
+
// Convert producers from object to Map if present
|
|
350
|
+
let producers: Map<string, ProducerState> | undefined
|
|
351
|
+
if (meta.producers) {
|
|
352
|
+
producers = new Map()
|
|
353
|
+
for (const [id, state] of Object.entries(meta.producers)) {
|
|
354
|
+
producers.set(id, { ...state })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
323
358
|
return {
|
|
324
359
|
path: meta.path,
|
|
325
360
|
contentType: meta.contentType,
|
|
@@ -329,9 +364,132 @@ export class FileBackedStreamStore {
|
|
|
329
364
|
ttlSeconds: meta.ttlSeconds,
|
|
330
365
|
expiresAt: meta.expiresAt,
|
|
331
366
|
createdAt: meta.createdAt,
|
|
367
|
+
producers,
|
|
332
368
|
}
|
|
333
369
|
}
|
|
334
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Validate producer state WITHOUT mutating.
|
|
373
|
+
* Returns proposed state to commit after successful append.
|
|
374
|
+
*
|
|
375
|
+
* IMPORTANT: This function does NOT mutate producer state. The caller must
|
|
376
|
+
* commit the proposedState after successful append (file write + fsync + LMDB).
|
|
377
|
+
* This ensures atomicity: if any step fails, producer state is not advanced.
|
|
378
|
+
*/
|
|
379
|
+
private validateProducer(
|
|
380
|
+
meta: StreamMetadata,
|
|
381
|
+
producerId: string,
|
|
382
|
+
epoch: number,
|
|
383
|
+
seq: number
|
|
384
|
+
): ProducerValidationResult {
|
|
385
|
+
// Initialize producers map if needed (safe - just ensures map exists)
|
|
386
|
+
if (!meta.producers) {
|
|
387
|
+
meta.producers = {}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const state = meta.producers[producerId]
|
|
391
|
+
const now = Date.now()
|
|
392
|
+
|
|
393
|
+
// New producer - accept if seq is 0
|
|
394
|
+
if (!state) {
|
|
395
|
+
if (seq !== 0) {
|
|
396
|
+
return {
|
|
397
|
+
status: `sequence_gap`,
|
|
398
|
+
expectedSeq: 0,
|
|
399
|
+
receivedSeq: seq,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Return proposed state, don't mutate yet
|
|
403
|
+
return {
|
|
404
|
+
status: `accepted`,
|
|
405
|
+
isNew: true,
|
|
406
|
+
producerId,
|
|
407
|
+
proposedState: { epoch, lastSeq: 0, lastUpdated: now },
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Epoch validation (client-declared, server-validated)
|
|
412
|
+
if (epoch < state.epoch) {
|
|
413
|
+
return { status: `stale_epoch`, currentEpoch: state.epoch }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (epoch > state.epoch) {
|
|
417
|
+
// New epoch must start at seq=0
|
|
418
|
+
if (seq !== 0) {
|
|
419
|
+
return { status: `invalid_epoch_seq` }
|
|
420
|
+
}
|
|
421
|
+
// Return proposed state for new epoch, don't mutate yet
|
|
422
|
+
return {
|
|
423
|
+
status: `accepted`,
|
|
424
|
+
isNew: true,
|
|
425
|
+
producerId,
|
|
426
|
+
proposedState: { epoch, lastSeq: 0, lastUpdated: now },
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Same epoch: sequence validation
|
|
431
|
+
if (seq <= state.lastSeq) {
|
|
432
|
+
return { status: `duplicate`, lastSeq: state.lastSeq }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (seq === state.lastSeq + 1) {
|
|
436
|
+
// Return proposed state, don't mutate yet
|
|
437
|
+
return {
|
|
438
|
+
status: `accepted`,
|
|
439
|
+
isNew: false,
|
|
440
|
+
producerId,
|
|
441
|
+
proposedState: { epoch, lastSeq: seq, lastUpdated: now },
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Sequence gap
|
|
446
|
+
return {
|
|
447
|
+
status: `sequence_gap`,
|
|
448
|
+
expectedSeq: state.lastSeq + 1,
|
|
449
|
+
receivedSeq: seq,
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Acquire a lock for serialized producer operations.
|
|
455
|
+
* Returns a release function.
|
|
456
|
+
*/
|
|
457
|
+
private async acquireProducerLock(
|
|
458
|
+
streamPath: string,
|
|
459
|
+
producerId: string
|
|
460
|
+
): Promise<() => void> {
|
|
461
|
+
const lockKey = `${streamPath}:${producerId}`
|
|
462
|
+
|
|
463
|
+
// Wait for any existing lock
|
|
464
|
+
while (this.producerLocks.has(lockKey)) {
|
|
465
|
+
await this.producerLocks.get(lockKey)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Create our lock
|
|
469
|
+
let releaseLock: () => void
|
|
470
|
+
const lockPromise = new Promise<void>((resolve) => {
|
|
471
|
+
releaseLock = resolve
|
|
472
|
+
})
|
|
473
|
+
this.producerLocks.set(lockKey, lockPromise)
|
|
474
|
+
|
|
475
|
+
return () => {
|
|
476
|
+
this.producerLocks.delete(lockKey)
|
|
477
|
+
releaseLock!()
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Get the current epoch for a producer on a stream.
|
|
483
|
+
* Returns undefined if the producer doesn't exist or stream not found.
|
|
484
|
+
*/
|
|
485
|
+
getProducerEpoch(streamPath: string, producerId: string): number | undefined {
|
|
486
|
+
const meta = this.getMetaIfNotExpired(streamPath)
|
|
487
|
+
if (!meta?.producers) {
|
|
488
|
+
return undefined
|
|
489
|
+
}
|
|
490
|
+
return meta.producers[producerId]?.epoch
|
|
491
|
+
}
|
|
492
|
+
|
|
335
493
|
/**
|
|
336
494
|
* Check if a stream is expired based on TTL or Expires-At.
|
|
337
495
|
*/
|
|
@@ -528,12 +686,8 @@ export class FileBackedStreamStore {
|
|
|
528
686
|
async append(
|
|
529
687
|
streamPath: string,
|
|
530
688
|
data: Uint8Array,
|
|
531
|
-
options: {
|
|
532
|
-
|
|
533
|
-
contentType?: string
|
|
534
|
-
isInitialCreate?: boolean
|
|
535
|
-
} = {}
|
|
536
|
-
): Promise<StreamMessage | null> {
|
|
689
|
+
options: AppendOptions & { isInitialCreate?: boolean } = {}
|
|
690
|
+
): Promise<StreamMessage | AppendResult | null> {
|
|
537
691
|
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
538
692
|
|
|
539
693
|
if (!streamMeta) {
|
|
@@ -551,7 +705,32 @@ export class FileBackedStreamStore {
|
|
|
551
705
|
}
|
|
552
706
|
}
|
|
553
707
|
|
|
554
|
-
//
|
|
708
|
+
// Handle producer validation FIRST if producer headers are present
|
|
709
|
+
// This must happen before Stream-Seq check so that retries with both
|
|
710
|
+
// producer headers AND Stream-Seq can return 204 (duplicate) instead of
|
|
711
|
+
// failing the Stream-Seq conflict check.
|
|
712
|
+
let producerResult: ProducerValidationResult | undefined
|
|
713
|
+
if (
|
|
714
|
+
options.producerId !== undefined &&
|
|
715
|
+
options.producerEpoch !== undefined &&
|
|
716
|
+
options.producerSeq !== undefined
|
|
717
|
+
) {
|
|
718
|
+
producerResult = this.validateProducer(
|
|
719
|
+
streamMeta,
|
|
720
|
+
options.producerId,
|
|
721
|
+
options.producerEpoch,
|
|
722
|
+
options.producerSeq
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
// Return early for non-accepted results (duplicate, stale epoch, gap)
|
|
726
|
+
// IMPORTANT: Return 204 for duplicate BEFORE Stream-Seq check
|
|
727
|
+
if (producerResult.status !== `accepted`) {
|
|
728
|
+
return { message: null, producerResult }
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Check sequence for writer coordination (Stream-Seq, separate from Producer-Seq)
|
|
733
|
+
// This happens AFTER producer validation so retries can be deduplicated
|
|
555
734
|
if (options.seq !== undefined) {
|
|
556
735
|
if (
|
|
557
736
|
streamMeta.lastSeq !== undefined &&
|
|
@@ -620,12 +799,19 @@ export class FileBackedStreamStore {
|
|
|
620
799
|
// 3. Flush to disk (blocks here until durable)
|
|
621
800
|
await this.fileHandlePool.fsyncFile(segmentPath)
|
|
622
801
|
|
|
623
|
-
// 4. Update LMDB metadata (only after flush, so metadata reflects durability)
|
|
802
|
+
// 4. Update LMDB metadata atomically (only after flush, so metadata reflects durability)
|
|
803
|
+
// This includes both the offset update and producer state update
|
|
804
|
+
// Producer state is committed HERE (not in validateProducer) for atomicity
|
|
805
|
+
const updatedProducers = { ...streamMeta.producers }
|
|
806
|
+
if (producerResult && producerResult.status === `accepted`) {
|
|
807
|
+
updatedProducers[producerResult.producerId] = producerResult.proposedState
|
|
808
|
+
}
|
|
624
809
|
const updatedMeta: StreamMetadata = {
|
|
625
810
|
...streamMeta,
|
|
626
811
|
currentOffset: newOffset,
|
|
627
812
|
lastSeq: options.seq ?? streamMeta.lastSeq,
|
|
628
813
|
totalBytes: streamMeta.totalBytes + processedData.length + 5, // +4 for length, +1 for newline
|
|
814
|
+
producers: updatedProducers,
|
|
629
815
|
}
|
|
630
816
|
const key = `stream:${streamPath}`
|
|
631
817
|
this.db.putSync(key, updatedMeta)
|
|
@@ -633,10 +819,52 @@ export class FileBackedStreamStore {
|
|
|
633
819
|
// 5. Notify long-polls (data is now readable from disk)
|
|
634
820
|
this.notifyLongPolls(streamPath)
|
|
635
821
|
|
|
636
|
-
// 6. Return
|
|
822
|
+
// 6. Return AppendResult if producer headers were used
|
|
823
|
+
if (producerResult) {
|
|
824
|
+
return {
|
|
825
|
+
message,
|
|
826
|
+
producerResult,
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
637
830
|
return message
|
|
638
831
|
}
|
|
639
832
|
|
|
833
|
+
/**
|
|
834
|
+
* Append with producer serialization for concurrent request handling.
|
|
835
|
+
* This ensures that validation+append is atomic per producer.
|
|
836
|
+
*/
|
|
837
|
+
async appendWithProducer(
|
|
838
|
+
streamPath: string,
|
|
839
|
+
data: Uint8Array,
|
|
840
|
+
options: AppendOptions
|
|
841
|
+
): Promise<AppendResult> {
|
|
842
|
+
if (!options.producerId) {
|
|
843
|
+
// No producer - just do a normal append
|
|
844
|
+
const result = await this.append(streamPath, data, options)
|
|
845
|
+
if (result && `message` in result) {
|
|
846
|
+
return result
|
|
847
|
+
}
|
|
848
|
+
return { message: result }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Acquire lock for this producer
|
|
852
|
+
const releaseLock = await this.acquireProducerLock(
|
|
853
|
+
streamPath,
|
|
854
|
+
options.producerId
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
try {
|
|
858
|
+
const result = await this.append(streamPath, data, options)
|
|
859
|
+
if (result && `message` in result) {
|
|
860
|
+
return result
|
|
861
|
+
}
|
|
862
|
+
return { message: result }
|
|
863
|
+
} finally {
|
|
864
|
+
releaseLock()
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
640
868
|
read(
|
|
641
869
|
streamPath: string,
|
|
642
870
|
offset?: string
|