@durable-streams/server 0.3.0 → 0.3.2
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 +48 -7
- package/dist/index.d.cts +22 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +48 -7
- package/package.json +4 -4
- package/src/file-store.ts +52 -0
- package/src/server.ts +21 -14
- package/src/store.ts +7 -3
package/dist/index.cjs
CHANGED
|
@@ -430,9 +430,9 @@ var StreamStore = class {
|
|
|
430
430
|
epoch: options.producerEpoch,
|
|
431
431
|
seq: options.producerSeq
|
|
432
432
|
};
|
|
433
|
-
this.notifyLongPollsClosed(path);
|
|
434
433
|
}
|
|
435
434
|
this.notifyLongPolls(path);
|
|
435
|
+
if (options.close) this.notifyLongPollsClosed(path);
|
|
436
436
|
if (producerResult || options.close) return {
|
|
437
437
|
message,
|
|
438
438
|
producerResult,
|
|
@@ -984,6 +984,13 @@ var FileBackedStreamStore = class {
|
|
|
984
984
|
* Key: "{streamPath}:{producerId}"
|
|
985
985
|
*/
|
|
986
986
|
producerLocks = new Map();
|
|
987
|
+
/**
|
|
988
|
+
* Per-stream append locks. Serializes the read-modify-write of currentOffset
|
|
989
|
+
* across all concurrent appenders on the same stream so the LMDB-tracked
|
|
990
|
+
* offset cannot drift behind the file's actual byte position.
|
|
991
|
+
* Key: streamPath
|
|
992
|
+
*/
|
|
993
|
+
streamAppendLocks = new Map();
|
|
987
994
|
constructor(options) {
|
|
988
995
|
this.dataDir = options.dataDir;
|
|
989
996
|
this.db = (0, lmdb.open)({
|
|
@@ -1180,6 +1187,26 @@ var FileBackedStreamStore = class {
|
|
|
1180
1187
|
};
|
|
1181
1188
|
}
|
|
1182
1189
|
/**
|
|
1190
|
+
* Acquire a per-stream append lock that serializes the read-modify-write
|
|
1191
|
+
* of currentOffset across all concurrent appenders on the same stream.
|
|
1192
|
+
* Without this, two concurrent appends can read the same starting
|
|
1193
|
+
* currentOffset, both compute their newOffset, both write a frame to the
|
|
1194
|
+
* file, but only one of their LMDB updates wins — leaving currentOffset
|
|
1195
|
+
* lagging the file's actual byte position. Returns a release function.
|
|
1196
|
+
*/
|
|
1197
|
+
async acquireStreamAppendLock(streamPath) {
|
|
1198
|
+
while (this.streamAppendLocks.has(streamPath)) await this.streamAppendLocks.get(streamPath);
|
|
1199
|
+
let releaseLock;
|
|
1200
|
+
const lockPromise = new Promise((resolve) => {
|
|
1201
|
+
releaseLock = resolve;
|
|
1202
|
+
});
|
|
1203
|
+
this.streamAppendLocks.set(streamPath, lockPromise);
|
|
1204
|
+
return () => {
|
|
1205
|
+
this.streamAppendLocks.delete(streamPath);
|
|
1206
|
+
releaseLock();
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1183
1210
|
* Get the current epoch for a producer on a stream.
|
|
1184
1211
|
* Returns undefined if the producer doesn't exist or stream not found.
|
|
1185
1212
|
*/
|
|
@@ -1437,7 +1464,20 @@ var FileBackedStreamStore = class {
|
|
|
1437
1464
|
}
|
|
1438
1465
|
}
|
|
1439
1466
|
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Public append entry point. Serializes concurrent appends to the same
|
|
1469
|
+
* stream so the read-modify-write of currentOffset cannot interleave —
|
|
1470
|
+
* see acquireStreamAppendLock for the underlying race.
|
|
1471
|
+
*/
|
|
1440
1472
|
async append(streamPath, data, options = {}) {
|
|
1473
|
+
const releaseLock = await this.acquireStreamAppendLock(streamPath);
|
|
1474
|
+
try {
|
|
1475
|
+
return await this.appendInner(streamPath, data, options);
|
|
1476
|
+
} finally {
|
|
1477
|
+
releaseLock();
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
async appendInner(streamPath, data, options = {}) {
|
|
1441
1481
|
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
1442
1482
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
1443
1483
|
if (streamMeta.softDeleted) throw new Error(`Stream is soft-deleted: ${streamPath}`);
|
|
@@ -2282,13 +2322,13 @@ var DurableStreamTestServer = class {
|
|
|
2282
2322
|
*/
|
|
2283
2323
|
async handleCreate(path, req, res) {
|
|
2284
2324
|
let contentType = req.headers[`content-type`];
|
|
2285
|
-
|
|
2325
|
+
const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
|
|
2326
|
+
const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
|
|
2327
|
+
if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = forkedFromHeader ? void 0 : `application/octet-stream`;
|
|
2286
2328
|
const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()];
|
|
2287
2329
|
const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER.toLowerCase()];
|
|
2288
2330
|
const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
|
|
2289
2331
|
const createClosed = closedHeader === `true`;
|
|
2290
|
-
const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
|
|
2291
|
-
const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
|
|
2292
2332
|
if (ttlHeader && expiresAtHeader) {
|
|
2293
2333
|
res.writeHead(400, { "content-type": `text/plain` });
|
|
2294
2334
|
res.end(`Cannot specify both Stream-TTL and Stream-Expires-At`);
|
|
@@ -2363,14 +2403,15 @@ var DurableStreamTestServer = class {
|
|
|
2363
2403
|
throw err;
|
|
2364
2404
|
}
|
|
2365
2405
|
const stream = this.store.get(path);
|
|
2406
|
+
const resolvedContentType = stream.contentType ?? contentType ?? `application/octet-stream`;
|
|
2366
2407
|
if (isNew && this.options.onStreamCreated) await Promise.resolve(this.options.onStreamCreated({
|
|
2367
2408
|
type: `created`,
|
|
2368
2409
|
path,
|
|
2369
|
-
contentType:
|
|
2410
|
+
contentType: resolvedContentType,
|
|
2370
2411
|
timestamp: Date.now()
|
|
2371
2412
|
}));
|
|
2372
2413
|
const headers = {
|
|
2373
|
-
"content-type":
|
|
2414
|
+
"content-type": resolvedContentType,
|
|
2374
2415
|
[STREAM_OFFSET_HEADER]: stream.currentOffset
|
|
2375
2416
|
};
|
|
2376
2417
|
if (isNew) headers[`location`] = `${this._url}${path}`;
|
|
@@ -2621,7 +2662,7 @@ var DurableStreamTestServer = class {
|
|
|
2621
2662
|
const result = await this.store.waitForMessages(path, currentOffset, this.options.longPollTimeout);
|
|
2622
2663
|
this.store.touchAccess(path);
|
|
2623
2664
|
if (this.isShuttingDown || !isConnected) break;
|
|
2624
|
-
if (result.streamClosed) {
|
|
2665
|
+
if (result.streamClosed && result.messages.length === 0) {
|
|
2625
2666
|
const finalControlData = {
|
|
2626
2667
|
[SSE_OFFSET_FIELD]: currentOffset,
|
|
2627
2668
|
[SSE_CLOSED_FIELD]: true
|
package/dist/index.d.cts
CHANGED
|
@@ -475,6 +475,13 @@ declare class FileBackedStreamStore {
|
|
|
475
475
|
* Key: "{streamPath}:{producerId}"
|
|
476
476
|
*/
|
|
477
477
|
private producerLocks;
|
|
478
|
+
/**
|
|
479
|
+
* Per-stream append locks. Serializes the read-modify-write of currentOffset
|
|
480
|
+
* across all concurrent appenders on the same stream so the LMDB-tracked
|
|
481
|
+
* offset cannot drift behind the file's actual byte position.
|
|
482
|
+
* Key: streamPath
|
|
483
|
+
*/
|
|
484
|
+
private streamAppendLocks;
|
|
478
485
|
constructor(options: FileBackedStreamStoreOptions);
|
|
479
486
|
/**
|
|
480
487
|
* Recover streams from disk on startup.
|
|
@@ -505,6 +512,15 @@ declare class FileBackedStreamStore {
|
|
|
505
512
|
*/
|
|
506
513
|
private acquireProducerLock;
|
|
507
514
|
/**
|
|
515
|
+
* Acquire a per-stream append lock that serializes the read-modify-write
|
|
516
|
+
* of currentOffset across all concurrent appenders on the same stream.
|
|
517
|
+
* Without this, two concurrent appends can read the same starting
|
|
518
|
+
* currentOffset, both compute their newOffset, both write a frame to the
|
|
519
|
+
* file, but only one of their LMDB updates wins — leaving currentOffset
|
|
520
|
+
* lagging the file's actual byte position. Returns a release function.
|
|
521
|
+
*/
|
|
522
|
+
private acquireStreamAppendLock;
|
|
523
|
+
/**
|
|
508
524
|
* Get the current epoch for a producer on a stream.
|
|
509
525
|
* Returns undefined if the producer doesn't exist or stream not found.
|
|
510
526
|
*/
|
|
@@ -550,9 +566,15 @@ declare class FileBackedStreamStore {
|
|
|
550
566
|
* whose refcount drops to zero.
|
|
551
567
|
*/
|
|
552
568
|
private deleteWithCascade;
|
|
569
|
+
/**
|
|
570
|
+
* Public append entry point. Serializes concurrent appends to the same
|
|
571
|
+
* stream so the read-modify-write of currentOffset cannot interleave —
|
|
572
|
+
* see acquireStreamAppendLock for the underlying race.
|
|
573
|
+
*/
|
|
553
574
|
append(streamPath: string, data: Uint8Array, options?: AppendOptions & {
|
|
554
575
|
isInitialCreate?: boolean;
|
|
555
576
|
}): Promise<StreamMessage | AppendResult | null>;
|
|
577
|
+
private appendInner;
|
|
556
578
|
/**
|
|
557
579
|
* Append with producer serialization for concurrent request handling.
|
|
558
580
|
* This ensures that validation+append is atomic per producer.
|
package/dist/index.d.ts
CHANGED
|
@@ -475,6 +475,13 @@ declare class FileBackedStreamStore {
|
|
|
475
475
|
* Key: "{streamPath}:{producerId}"
|
|
476
476
|
*/
|
|
477
477
|
private producerLocks;
|
|
478
|
+
/**
|
|
479
|
+
* Per-stream append locks. Serializes the read-modify-write of currentOffset
|
|
480
|
+
* across all concurrent appenders on the same stream so the LMDB-tracked
|
|
481
|
+
* offset cannot drift behind the file's actual byte position.
|
|
482
|
+
* Key: streamPath
|
|
483
|
+
*/
|
|
484
|
+
private streamAppendLocks;
|
|
478
485
|
constructor(options: FileBackedStreamStoreOptions);
|
|
479
486
|
/**
|
|
480
487
|
* Recover streams from disk on startup.
|
|
@@ -505,6 +512,15 @@ declare class FileBackedStreamStore {
|
|
|
505
512
|
*/
|
|
506
513
|
private acquireProducerLock;
|
|
507
514
|
/**
|
|
515
|
+
* Acquire a per-stream append lock that serializes the read-modify-write
|
|
516
|
+
* of currentOffset across all concurrent appenders on the same stream.
|
|
517
|
+
* Without this, two concurrent appends can read the same starting
|
|
518
|
+
* currentOffset, both compute their newOffset, both write a frame to the
|
|
519
|
+
* file, but only one of their LMDB updates wins — leaving currentOffset
|
|
520
|
+
* lagging the file's actual byte position. Returns a release function.
|
|
521
|
+
*/
|
|
522
|
+
private acquireStreamAppendLock;
|
|
523
|
+
/**
|
|
508
524
|
* Get the current epoch for a producer on a stream.
|
|
509
525
|
* Returns undefined if the producer doesn't exist or stream not found.
|
|
510
526
|
*/
|
|
@@ -550,9 +566,15 @@ declare class FileBackedStreamStore {
|
|
|
550
566
|
* whose refcount drops to zero.
|
|
551
567
|
*/
|
|
552
568
|
private deleteWithCascade;
|
|
569
|
+
/**
|
|
570
|
+
* Public append entry point. Serializes concurrent appends to the same
|
|
571
|
+
* stream so the read-modify-write of currentOffset cannot interleave —
|
|
572
|
+
* see acquireStreamAppendLock for the underlying race.
|
|
573
|
+
*/
|
|
553
574
|
append(streamPath: string, data: Uint8Array, options?: AppendOptions & {
|
|
554
575
|
isInitialCreate?: boolean;
|
|
555
576
|
}): Promise<StreamMessage | AppendResult | null>;
|
|
577
|
+
private appendInner;
|
|
556
578
|
/**
|
|
557
579
|
* Append with producer serialization for concurrent request handling.
|
|
558
580
|
* This ensures that validation+append is atomic per producer.
|
package/dist/index.js
CHANGED
|
@@ -407,9 +407,9 @@ var StreamStore = class {
|
|
|
407
407
|
epoch: options.producerEpoch,
|
|
408
408
|
seq: options.producerSeq
|
|
409
409
|
};
|
|
410
|
-
this.notifyLongPollsClosed(path$2);
|
|
411
410
|
}
|
|
412
411
|
this.notifyLongPolls(path$2);
|
|
412
|
+
if (options.close) this.notifyLongPollsClosed(path$2);
|
|
413
413
|
if (producerResult || options.close) return {
|
|
414
414
|
message,
|
|
415
415
|
producerResult,
|
|
@@ -961,6 +961,13 @@ var FileBackedStreamStore = class {
|
|
|
961
961
|
* Key: "{streamPath}:{producerId}"
|
|
962
962
|
*/
|
|
963
963
|
producerLocks = new Map();
|
|
964
|
+
/**
|
|
965
|
+
* Per-stream append locks. Serializes the read-modify-write of currentOffset
|
|
966
|
+
* across all concurrent appenders on the same stream so the LMDB-tracked
|
|
967
|
+
* offset cannot drift behind the file's actual byte position.
|
|
968
|
+
* Key: streamPath
|
|
969
|
+
*/
|
|
970
|
+
streamAppendLocks = new Map();
|
|
964
971
|
constructor(options) {
|
|
965
972
|
this.dataDir = options.dataDir;
|
|
966
973
|
this.db = open({
|
|
@@ -1157,6 +1164,26 @@ var FileBackedStreamStore = class {
|
|
|
1157
1164
|
};
|
|
1158
1165
|
}
|
|
1159
1166
|
/**
|
|
1167
|
+
* Acquire a per-stream append lock that serializes the read-modify-write
|
|
1168
|
+
* of currentOffset across all concurrent appenders on the same stream.
|
|
1169
|
+
* Without this, two concurrent appends can read the same starting
|
|
1170
|
+
* currentOffset, both compute their newOffset, both write a frame to the
|
|
1171
|
+
* file, but only one of their LMDB updates wins — leaving currentOffset
|
|
1172
|
+
* lagging the file's actual byte position. Returns a release function.
|
|
1173
|
+
*/
|
|
1174
|
+
async acquireStreamAppendLock(streamPath) {
|
|
1175
|
+
while (this.streamAppendLocks.has(streamPath)) await this.streamAppendLocks.get(streamPath);
|
|
1176
|
+
let releaseLock;
|
|
1177
|
+
const lockPromise = new Promise((resolve) => {
|
|
1178
|
+
releaseLock = resolve;
|
|
1179
|
+
});
|
|
1180
|
+
this.streamAppendLocks.set(streamPath, lockPromise);
|
|
1181
|
+
return () => {
|
|
1182
|
+
this.streamAppendLocks.delete(streamPath);
|
|
1183
|
+
releaseLock();
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1160
1187
|
* Get the current epoch for a producer on a stream.
|
|
1161
1188
|
* Returns undefined if the producer doesn't exist or stream not found.
|
|
1162
1189
|
*/
|
|
@@ -1414,7 +1441,20 @@ var FileBackedStreamStore = class {
|
|
|
1414
1441
|
}
|
|
1415
1442
|
}
|
|
1416
1443
|
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Public append entry point. Serializes concurrent appends to the same
|
|
1446
|
+
* stream so the read-modify-write of currentOffset cannot interleave —
|
|
1447
|
+
* see acquireStreamAppendLock for the underlying race.
|
|
1448
|
+
*/
|
|
1417
1449
|
async append(streamPath, data, options = {}) {
|
|
1450
|
+
const releaseLock = await this.acquireStreamAppendLock(streamPath);
|
|
1451
|
+
try {
|
|
1452
|
+
return await this.appendInner(streamPath, data, options);
|
|
1453
|
+
} finally {
|
|
1454
|
+
releaseLock();
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
async appendInner(streamPath, data, options = {}) {
|
|
1418
1458
|
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
1419
1459
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
1420
1460
|
if (streamMeta.softDeleted) throw new Error(`Stream is soft-deleted: ${streamPath}`);
|
|
@@ -2259,13 +2299,13 @@ var DurableStreamTestServer = class {
|
|
|
2259
2299
|
*/
|
|
2260
2300
|
async handleCreate(path$2, req, res) {
|
|
2261
2301
|
let contentType = req.headers[`content-type`];
|
|
2262
|
-
|
|
2302
|
+
const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
|
|
2303
|
+
const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
|
|
2304
|
+
if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = forkedFromHeader ? void 0 : `application/octet-stream`;
|
|
2263
2305
|
const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()];
|
|
2264
2306
|
const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER.toLowerCase()];
|
|
2265
2307
|
const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
|
|
2266
2308
|
const createClosed = closedHeader === `true`;
|
|
2267
|
-
const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
|
|
2268
|
-
const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
|
|
2269
2309
|
if (ttlHeader && expiresAtHeader) {
|
|
2270
2310
|
res.writeHead(400, { "content-type": `text/plain` });
|
|
2271
2311
|
res.end(`Cannot specify both Stream-TTL and Stream-Expires-At`);
|
|
@@ -2340,14 +2380,15 @@ var DurableStreamTestServer = class {
|
|
|
2340
2380
|
throw err;
|
|
2341
2381
|
}
|
|
2342
2382
|
const stream = this.store.get(path$2);
|
|
2383
|
+
const resolvedContentType = stream.contentType ?? contentType ?? `application/octet-stream`;
|
|
2343
2384
|
if (isNew && this.options.onStreamCreated) await Promise.resolve(this.options.onStreamCreated({
|
|
2344
2385
|
type: `created`,
|
|
2345
2386
|
path: path$2,
|
|
2346
|
-
contentType:
|
|
2387
|
+
contentType: resolvedContentType,
|
|
2347
2388
|
timestamp: Date.now()
|
|
2348
2389
|
}));
|
|
2349
2390
|
const headers = {
|
|
2350
|
-
"content-type":
|
|
2391
|
+
"content-type": resolvedContentType,
|
|
2351
2392
|
[STREAM_OFFSET_HEADER]: stream.currentOffset
|
|
2352
2393
|
};
|
|
2353
2394
|
if (isNew) headers[`location`] = `${this._url}${path$2}`;
|
|
@@ -2598,7 +2639,7 @@ var DurableStreamTestServer = class {
|
|
|
2598
2639
|
const result = await this.store.waitForMessages(path$2, currentOffset, this.options.longPollTimeout);
|
|
2599
2640
|
this.store.touchAccess(path$2);
|
|
2600
2641
|
if (this.isShuttingDown || !isConnected) break;
|
|
2601
|
-
if (result.streamClosed) {
|
|
2642
|
+
if (result.streamClosed && result.messages.length === 0) {
|
|
2602
2643
|
const finalControlData = {
|
|
2603
2644
|
[SSE_OFFSET_FIELD]: currentOffset,
|
|
2604
2645
|
[SSE_CLOSED_FIELD]: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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.2.
|
|
43
|
-
"@durable-streams/state": "0.2.
|
|
42
|
+
"@durable-streams/client": "0.2.4",
|
|
43
|
+
"@durable-streams/state": "0.2.6"
|
|
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
49
|
"vitest": "^4.0.0",
|
|
50
|
-
"@durable-streams/server-conformance-tests": "0.3.
|
|
50
|
+
"@durable-streams/server-conformance-tests": "0.3.1"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"dist",
|
package/src/file-store.ts
CHANGED
|
@@ -232,6 +232,13 @@ export class FileBackedStreamStore {
|
|
|
232
232
|
* Key: "{streamPath}:{producerId}"
|
|
233
233
|
*/
|
|
234
234
|
private producerLocks = new Map<string, Promise<unknown>>()
|
|
235
|
+
/**
|
|
236
|
+
* Per-stream append locks. Serializes the read-modify-write of currentOffset
|
|
237
|
+
* across all concurrent appenders on the same stream so the LMDB-tracked
|
|
238
|
+
* offset cannot drift behind the file's actual byte position.
|
|
239
|
+
* Key: streamPath
|
|
240
|
+
*/
|
|
241
|
+
private streamAppendLocks = new Map<string, Promise<unknown>>()
|
|
235
242
|
|
|
236
243
|
constructor(options: FileBackedStreamStoreOptions) {
|
|
237
244
|
this.dataDir = options.dataDir
|
|
@@ -535,6 +542,33 @@ export class FileBackedStreamStore {
|
|
|
535
542
|
}
|
|
536
543
|
}
|
|
537
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Acquire a per-stream append lock that serializes the read-modify-write
|
|
547
|
+
* of currentOffset across all concurrent appenders on the same stream.
|
|
548
|
+
* Without this, two concurrent appends can read the same starting
|
|
549
|
+
* currentOffset, both compute their newOffset, both write a frame to the
|
|
550
|
+
* file, but only one of their LMDB updates wins — leaving currentOffset
|
|
551
|
+
* lagging the file's actual byte position. Returns a release function.
|
|
552
|
+
*/
|
|
553
|
+
private async acquireStreamAppendLock(
|
|
554
|
+
streamPath: string
|
|
555
|
+
): Promise<() => void> {
|
|
556
|
+
while (this.streamAppendLocks.has(streamPath)) {
|
|
557
|
+
await this.streamAppendLocks.get(streamPath)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let releaseLock: () => void
|
|
561
|
+
const lockPromise = new Promise<void>((resolve) => {
|
|
562
|
+
releaseLock = resolve
|
|
563
|
+
})
|
|
564
|
+
this.streamAppendLocks.set(streamPath, lockPromise)
|
|
565
|
+
|
|
566
|
+
return () => {
|
|
567
|
+
this.streamAppendLocks.delete(streamPath)
|
|
568
|
+
releaseLock!()
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
538
572
|
/**
|
|
539
573
|
* Get the current epoch for a producer on a stream.
|
|
540
574
|
* Returns undefined if the producer doesn't exist or stream not found.
|
|
@@ -985,10 +1019,28 @@ export class FileBackedStreamStore {
|
|
|
985
1019
|
}
|
|
986
1020
|
}
|
|
987
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* Public append entry point. Serializes concurrent appends to the same
|
|
1024
|
+
* stream so the read-modify-write of currentOffset cannot interleave —
|
|
1025
|
+
* see acquireStreamAppendLock for the underlying race.
|
|
1026
|
+
*/
|
|
988
1027
|
async append(
|
|
989
1028
|
streamPath: string,
|
|
990
1029
|
data: Uint8Array,
|
|
991
1030
|
options: AppendOptions & { isInitialCreate?: boolean } = {}
|
|
1031
|
+
): Promise<StreamMessage | AppendResult | null> {
|
|
1032
|
+
const releaseLock = await this.acquireStreamAppendLock(streamPath)
|
|
1033
|
+
try {
|
|
1034
|
+
return await this.appendInner(streamPath, data, options)
|
|
1035
|
+
} finally {
|
|
1036
|
+
releaseLock()
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private async appendInner(
|
|
1041
|
+
streamPath: string,
|
|
1042
|
+
data: Uint8Array,
|
|
1043
|
+
options: AppendOptions & { isInitialCreate?: boolean } = {}
|
|
992
1044
|
): Promise<StreamMessage | AppendResult | null> {
|
|
993
1045
|
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
994
1046
|
|
package/src/server.ts
CHANGED
|
@@ -562,13 +562,24 @@ export class DurableStreamTestServer {
|
|
|
562
562
|
): Promise<void> {
|
|
563
563
|
let contentType = req.headers[`content-type`]
|
|
564
564
|
|
|
565
|
-
//
|
|
565
|
+
// Parse fork headers (must come before content-type sanitization so
|
|
566
|
+
// forks can fall through to the store's content-type inheritance)
|
|
567
|
+
const forkedFromHeader = req.headers[
|
|
568
|
+
STREAM_FORKED_FROM_HEADER.toLowerCase()
|
|
569
|
+
] as string | undefined
|
|
570
|
+
const forkOffsetHeader = req.headers[
|
|
571
|
+
STREAM_FORK_OFFSET_HEADER.toLowerCase()
|
|
572
|
+
] as string | undefined
|
|
573
|
+
|
|
574
|
+
// Sanitize content-type: if empty or invalid, use default — but only
|
|
575
|
+
// for non-fork creates. For forks, an omitted Content-Type means "inherit
|
|
576
|
+
// from source", which is resolved by the store.
|
|
566
577
|
if (
|
|
567
578
|
!contentType ||
|
|
568
579
|
contentType.trim() === `` ||
|
|
569
580
|
!/^[\w-]+\/[\w-]+/.test(contentType)
|
|
570
581
|
) {
|
|
571
|
-
contentType = `application/octet-stream`
|
|
582
|
+
contentType = forkedFromHeader ? undefined : `application/octet-stream`
|
|
572
583
|
}
|
|
573
584
|
|
|
574
585
|
const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()] as
|
|
@@ -582,14 +593,6 @@ export class DurableStreamTestServer {
|
|
|
582
593
|
const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()]
|
|
583
594
|
const createClosed = closedHeader === `true`
|
|
584
595
|
|
|
585
|
-
// Parse fork headers
|
|
586
|
-
const forkedFromHeader = req.headers[
|
|
587
|
-
STREAM_FORKED_FROM_HEADER.toLowerCase()
|
|
588
|
-
] as string | undefined
|
|
589
|
-
const forkOffsetHeader = req.headers[
|
|
590
|
-
STREAM_FORK_OFFSET_HEADER.toLowerCase()
|
|
591
|
-
] as string | undefined
|
|
592
|
-
|
|
593
596
|
// Validate TTL and Expires-At headers
|
|
594
597
|
if (ttlHeader && expiresAtHeader) {
|
|
595
598
|
res.writeHead(400, { "content-type": `text/plain` })
|
|
@@ -681,6 +684,8 @@ export class DurableStreamTestServer {
|
|
|
681
684
|
}
|
|
682
685
|
|
|
683
686
|
const stream = this.store.get(path)!
|
|
687
|
+
const resolvedContentType =
|
|
688
|
+
stream.contentType ?? contentType ?? `application/octet-stream`
|
|
684
689
|
|
|
685
690
|
// Call lifecycle hook for new streams
|
|
686
691
|
if (isNew && this.options.onStreamCreated) {
|
|
@@ -688,7 +693,7 @@ export class DurableStreamTestServer {
|
|
|
688
693
|
this.options.onStreamCreated({
|
|
689
694
|
type: `created`,
|
|
690
695
|
path,
|
|
691
|
-
contentType:
|
|
696
|
+
contentType: resolvedContentType,
|
|
692
697
|
timestamp: Date.now(),
|
|
693
698
|
})
|
|
694
699
|
)
|
|
@@ -696,7 +701,7 @@ export class DurableStreamTestServer {
|
|
|
696
701
|
|
|
697
702
|
// Return 201 for new streams, 200 for idempotent creates
|
|
698
703
|
const headers: Record<string, string> = {
|
|
699
|
-
"content-type":
|
|
704
|
+
"content-type": resolvedContentType,
|
|
700
705
|
[STREAM_OFFSET_HEADER]: stream.currentOffset,
|
|
701
706
|
}
|
|
702
707
|
|
|
@@ -1182,8 +1187,10 @@ export class DurableStreamTestServer {
|
|
|
1182
1187
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
1183
1188
|
if (this.isShuttingDown || !isConnected) break
|
|
1184
1189
|
|
|
1185
|
-
// Check if stream was closed during wait
|
|
1186
|
-
|
|
1190
|
+
// Check if stream was closed during wait. If the close also appended
|
|
1191
|
+
// final data, let the next loop iteration deliver those messages
|
|
1192
|
+
// before emitting the streamClosed control event.
|
|
1193
|
+
if (result.streamClosed && result.messages.length === 0) {
|
|
1187
1194
|
const finalControlData: Record<string, string | boolean> = {
|
|
1188
1195
|
[SSE_OFFSET_FIELD]: currentOffset,
|
|
1189
1196
|
[SSE_CLOSED_FIELD]: true,
|
package/src/store.ts
CHANGED
|
@@ -723,13 +723,17 @@ export class StreamStore {
|
|
|
723
723
|
seq: options.producerSeq!,
|
|
724
724
|
}
|
|
725
725
|
}
|
|
726
|
-
// Notify pending long-polls that stream is closed
|
|
727
|
-
this.notifyLongPollsClosed(path)
|
|
728
726
|
}
|
|
729
727
|
|
|
730
|
-
// Notify
|
|
728
|
+
// Notify pending long-polls of new messages before empty close signals.
|
|
729
|
+
// Append-and-close must deliver the final message with streamClosed
|
|
730
|
+
// metadata instead of waking readers with an empty close event first.
|
|
731
731
|
this.notifyLongPolls(path)
|
|
732
732
|
|
|
733
|
+
if (options.close) {
|
|
734
|
+
this.notifyLongPollsClosed(path)
|
|
735
|
+
}
|
|
736
|
+
|
|
733
737
|
// Return AppendResult if producer headers were used or stream was closed
|
|
734
738
|
if (producerResult || options.close) {
|
|
735
739
|
return {
|