@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.1.3",
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.2",
43
- "@durable-streams/state": "0.1.2"
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": "^3.2.4",
50
- "@durable-streams/server-conformance-tests": "0.1.3"
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 { PendingLongPoll, Stream, StreamMessage } from "./types"
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
- seq?: string
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
- // Check sequence for writer coordination
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 (client knows data is durable)
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