@durable-streams/server 0.3.1 → 0.3.3
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 +1344 -266
- package/dist/index.d.cts +258 -2
- package/dist/index.d.ts +258 -2
- package/dist/index.js +1391 -318
- package/package.json +4 -4
- package/src/crypto.ts +217 -0
- package/src/file-store.ts +239 -144
- package/src/glob.ts +70 -0
- package/src/index.ts +14 -0
- package/src/log.ts +56 -0
- package/src/server.ts +96 -40
- package/src/store.ts +66 -10
- package/src/subscription-manager.ts +882 -0
- package/src/subscription-routes.ts +504 -0
- package/src/subscription-types.ts +80 -0
- package/src/types.ts +8 -0
package/src/file-store.ts
CHANGED
|
@@ -8,10 +8,10 @@ import * as path from "node:path"
|
|
|
8
8
|
import { randomBytes } from "node:crypto"
|
|
9
9
|
import { open as openLMDB } from "lmdb"
|
|
10
10
|
import { SieveCache } from "@neophi/sieve-cache"
|
|
11
|
-
import {
|
|
11
|
+
import { serverLog } from "./log"
|
|
12
12
|
import { encodeStreamPath } from "./path-encoding"
|
|
13
13
|
import {
|
|
14
|
-
|
|
14
|
+
formatJsonMessages,
|
|
15
15
|
normalizeContentType,
|
|
16
16
|
processJsonAppend,
|
|
17
17
|
} from "./store"
|
|
@@ -54,7 +54,7 @@ interface StreamMetadata {
|
|
|
54
54
|
segmentCount: number
|
|
55
55
|
totalBytes: number
|
|
56
56
|
/**
|
|
57
|
-
* Unique
|
|
57
|
+
* Unique file stem for this stream instance.
|
|
58
58
|
* Format: {encoded_path}~{timestamp}~{random_hex}
|
|
59
59
|
* This allows safe async deletion and immediate reuse of stream paths.
|
|
60
60
|
*/
|
|
@@ -104,6 +104,15 @@ interface StreamMetadata {
|
|
|
104
104
|
*/
|
|
105
105
|
interface PooledHandle {
|
|
106
106
|
stream: fs.WriteStream
|
|
107
|
+
/**
|
|
108
|
+
* Coalesced fsync leader. When set and `scheduled` is true, new fsync callers
|
|
109
|
+
* piggyback on this batch; when `scheduled` is false the batch has already
|
|
110
|
+
* entered its syscall and new callers must start a new batch.
|
|
111
|
+
*/
|
|
112
|
+
syncLeader: {
|
|
113
|
+
promise: Promise<void>
|
|
114
|
+
scheduled: boolean
|
|
115
|
+
} | null
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
class FileHandlePool {
|
|
@@ -114,7 +123,7 @@ class FileHandlePool {
|
|
|
114
123
|
evictHook: (_key: string, handle: PooledHandle) => {
|
|
115
124
|
// Close the handle when evicted (sync version - fire and forget)
|
|
116
125
|
this.closeHandle(handle).catch((err: Error) => {
|
|
117
|
-
|
|
126
|
+
serverLog.error(`[FileHandlePool] Error closing evicted handle:`, err)
|
|
118
127
|
})
|
|
119
128
|
},
|
|
120
129
|
})
|
|
@@ -125,49 +134,80 @@ class FileHandlePool {
|
|
|
125
134
|
|
|
126
135
|
if (!handle) {
|
|
127
136
|
const stream = fs.createWriteStream(filePath, { flags: `a` })
|
|
128
|
-
handle = { stream }
|
|
137
|
+
handle = { stream, syncLeader: null }
|
|
129
138
|
this.cache.set(filePath, handle)
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
return handle.stream
|
|
133
142
|
}
|
|
134
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Open a write stream eagerly so the first write does not pay the lazy
|
|
146
|
+
* `open()` stall. Resolves once the underlying fd is ready.
|
|
147
|
+
*/
|
|
148
|
+
async openWriteStream(filePath: string): Promise<fs.WriteStream> {
|
|
149
|
+
const stream = this.getWriteStream(filePath)
|
|
150
|
+
const fd = (stream as unknown as { fd: number | null }).fd
|
|
151
|
+
if (typeof fd === `number`) return stream
|
|
152
|
+
await new Promise<void>((resolve, reject) => {
|
|
153
|
+
stream.once(`open`, () => resolve())
|
|
154
|
+
stream.once(`error`, (err) => reject(err))
|
|
155
|
+
})
|
|
156
|
+
return stream
|
|
157
|
+
}
|
|
158
|
+
|
|
135
159
|
/**
|
|
136
160
|
* Flush a specific file to disk immediately.
|
|
137
|
-
*
|
|
161
|
+
* Concurrent callers on the same fd share one in-flight fdatasync: the
|
|
162
|
+
* first caller issues the syscall, later arrivals during that window wait
|
|
163
|
+
* for it to finish and then issue a fresh syscall (because their writes
|
|
164
|
+
* may have landed after the in-flight syscall started). This preserves
|
|
165
|
+
* durability without adding scheduling latency.
|
|
138
166
|
*/
|
|
139
|
-
|
|
167
|
+
fsyncFile(filePath: string): Promise<void> {
|
|
140
168
|
const handle = this.cache.get(filePath)
|
|
141
|
-
if (!handle)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (err) reject(err)
|
|
154
|
-
else resolve()
|
|
155
|
-
})
|
|
156
|
-
}
|
|
157
|
-
const onError = (err: Error): void => {
|
|
158
|
-
handle.stream.off(`open`, onOpen)
|
|
159
|
-
reject(err)
|
|
160
|
-
}
|
|
161
|
-
handle.stream.once(`open`, onOpen)
|
|
162
|
-
handle.stream.once(`error`, onError)
|
|
163
|
-
return
|
|
164
|
-
}
|
|
169
|
+
if (!handle) {
|
|
170
|
+
return Promise.reject(
|
|
171
|
+
new Error(
|
|
172
|
+
`[FileHandlePool] Cannot fsync: handle not found for ${filePath}`
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const existing = handle.syncLeader
|
|
178
|
+
if (existing && existing.scheduled) {
|
|
179
|
+
return existing.promise
|
|
180
|
+
}
|
|
165
181
|
|
|
182
|
+
let resolveFn!: () => void
|
|
183
|
+
let rejectFn!: (err: Error) => void
|
|
184
|
+
const promise = new Promise<void>((res, rej) => {
|
|
185
|
+
resolveFn = res
|
|
186
|
+
rejectFn = rej
|
|
187
|
+
})
|
|
188
|
+
const leader = { promise, scheduled: true }
|
|
189
|
+
handle.syncLeader = leader
|
|
190
|
+
|
|
191
|
+
const runSyscall = (fd: number): void => {
|
|
192
|
+
leader.scheduled = false
|
|
166
193
|
fs.fdatasync(fd, (err) => {
|
|
167
|
-
if (
|
|
168
|
-
|
|
194
|
+
if (handle.syncLeader === leader) handle.syncLeader = null
|
|
195
|
+
if (err) rejectFn(err)
|
|
196
|
+
else resolveFn()
|
|
169
197
|
})
|
|
170
|
-
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const fd = (handle.stream as unknown as { fd: number | null }).fd
|
|
201
|
+
if (typeof fd === `number`) {
|
|
202
|
+
runSyscall(fd)
|
|
203
|
+
} else {
|
|
204
|
+
handle.stream.once(`open`, (openedFd: number) => runSyscall(openedFd))
|
|
205
|
+
handle.stream.once(`error`, (err: Error) => {
|
|
206
|
+
if (handle.syncLeader === leader) handle.syncLeader = null
|
|
207
|
+
rejectFn(err)
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
return promise
|
|
171
211
|
}
|
|
172
212
|
|
|
173
213
|
async closeAll(): Promise<void> {
|
|
@@ -212,18 +252,21 @@ export interface FileBackedStreamStoreOptions {
|
|
|
212
252
|
*/
|
|
213
253
|
function generateUniqueDirectoryName(streamPath: string): string {
|
|
214
254
|
const encoded = encodeStreamPath(streamPath)
|
|
215
|
-
const timestamp = Date.now().toString(36)
|
|
216
|
-
const random = randomBytes(4).toString(`hex`)
|
|
255
|
+
const timestamp = Date.now().toString(36)
|
|
256
|
+
const random = randomBytes(4).toString(`hex`)
|
|
217
257
|
return `${encoded}~${timestamp}~${random}`
|
|
218
258
|
}
|
|
219
259
|
|
|
260
|
+
function segmentFile(dataDir: string, dirName: string): string {
|
|
261
|
+
return path.join(dataDir, `streams`, `${dirName}.log`)
|
|
262
|
+
}
|
|
263
|
+
|
|
220
264
|
/**
|
|
221
265
|
* File-backed implementation of StreamStore.
|
|
222
266
|
* Maintains the same interface as the in-memory StreamStore for drop-in compatibility.
|
|
223
267
|
*/
|
|
224
268
|
export class FileBackedStreamStore {
|
|
225
269
|
private db: Database
|
|
226
|
-
private fileManager: StreamFileManager
|
|
227
270
|
private fileHandlePool: FileHandlePool
|
|
228
271
|
private pendingLongPolls: Array<PendingLongPoll> = []
|
|
229
272
|
private dataDir: string
|
|
@@ -232,6 +275,13 @@ export class FileBackedStreamStore {
|
|
|
232
275
|
* Key: "{streamPath}:{producerId}"
|
|
233
276
|
*/
|
|
234
277
|
private producerLocks = new Map<string, Promise<unknown>>()
|
|
278
|
+
/**
|
|
279
|
+
* Per-stream append locks. Serializes the read-modify-write of currentOffset
|
|
280
|
+
* across all concurrent appenders on the same stream so the LMDB-tracked
|
|
281
|
+
* offset cannot drift behind the file's actual byte position.
|
|
282
|
+
* Key: streamPath
|
|
283
|
+
*/
|
|
284
|
+
private streamAppendLocks = new Map<string, Promise<unknown>>()
|
|
235
285
|
|
|
236
286
|
constructor(options: FileBackedStreamStoreOptions) {
|
|
237
287
|
this.dataDir = options.dataDir
|
|
@@ -240,10 +290,13 @@ export class FileBackedStreamStore {
|
|
|
240
290
|
this.db = openLMDB({
|
|
241
291
|
path: path.join(this.dataDir, `metadata.lmdb`),
|
|
242
292
|
compression: true,
|
|
293
|
+
noMemInit: true,
|
|
294
|
+
cache: true,
|
|
295
|
+
sharedStructuresKey: Symbol.for(`structures`),
|
|
243
296
|
})
|
|
244
297
|
|
|
245
|
-
//
|
|
246
|
-
|
|
298
|
+
// Pre-create the streams directory
|
|
299
|
+
fs.mkdirSync(path.join(this.dataDir, `streams`), { recursive: true })
|
|
247
300
|
|
|
248
301
|
// Initialize file handle pool with SIEVE cache
|
|
249
302
|
const maxFileHandles = options.maxFileHandles ?? 100
|
|
@@ -258,7 +311,7 @@ export class FileBackedStreamStore {
|
|
|
258
311
|
* Validates that LMDB metadata matches actual file contents and reconciles any mismatches.
|
|
259
312
|
*/
|
|
260
313
|
private recover(): void {
|
|
261
|
-
|
|
314
|
+
serverLog.info(`[FileBackedStreamStore] Starting recovery...`)
|
|
262
315
|
|
|
263
316
|
let recovered = 0
|
|
264
317
|
let reconciled = 0
|
|
@@ -281,17 +334,11 @@ export class FileBackedStreamStore {
|
|
|
281
334
|
const streamMeta = value as StreamMetadata
|
|
282
335
|
const streamPath = key.replace(`stream:`, ``)
|
|
283
336
|
|
|
284
|
-
|
|
285
|
-
const segmentPath = path.join(
|
|
286
|
-
this.dataDir,
|
|
287
|
-
`streams`,
|
|
288
|
-
streamMeta.directoryName,
|
|
289
|
-
`segment_00000.log`
|
|
290
|
-
)
|
|
337
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
291
338
|
|
|
292
339
|
// Check if file exists
|
|
293
340
|
if (!fs.existsSync(segmentPath)) {
|
|
294
|
-
|
|
341
|
+
serverLog.warn(
|
|
295
342
|
`[FileBackedStreamStore] Recovery: Stream file missing for ${streamPath}, removing from LMDB`
|
|
296
343
|
)
|
|
297
344
|
this.db.removeSync(key)
|
|
@@ -316,7 +363,7 @@ export class FileBackedStreamStore {
|
|
|
316
363
|
|
|
317
364
|
// Check if offset matches
|
|
318
365
|
if (trueOffset !== streamMeta.currentOffset) {
|
|
319
|
-
|
|
366
|
+
serverLog.warn(
|
|
320
367
|
`[FileBackedStreamStore] Recovery: Offset mismatch for ${streamPath}: ` +
|
|
321
368
|
`LMDB says ${streamMeta.currentOffset}, file says ${trueOffset}. Reconciling to file.`
|
|
322
369
|
)
|
|
@@ -332,12 +379,12 @@ export class FileBackedStreamStore {
|
|
|
332
379
|
|
|
333
380
|
recovered++
|
|
334
381
|
} catch (err) {
|
|
335
|
-
|
|
382
|
+
serverLog.error(`[FileBackedStreamStore] Error recovering stream:`, err)
|
|
336
383
|
errors++
|
|
337
384
|
}
|
|
338
385
|
}
|
|
339
386
|
|
|
340
|
-
|
|
387
|
+
serverLog.info(
|
|
341
388
|
`[FileBackedStreamStore] Recovery complete: ${recovered} streams, ` +
|
|
342
389
|
`${reconciled} reconciled, ${errors} errors`
|
|
343
390
|
)
|
|
@@ -351,39 +398,21 @@ export class FileBackedStreamStore {
|
|
|
351
398
|
try {
|
|
352
399
|
const fileContent = fs.readFileSync(segmentPath)
|
|
353
400
|
let filePos = 0
|
|
354
|
-
let currentDataOffset = 0
|
|
355
401
|
|
|
356
402
|
while (filePos < fileContent.length) {
|
|
357
|
-
|
|
358
|
-
if (filePos + 4 > fileContent.length) {
|
|
359
|
-
// Truncated length header - stop here
|
|
360
|
-
break
|
|
361
|
-
}
|
|
403
|
+
if (filePos + 4 > fileContent.length) break
|
|
362
404
|
|
|
363
405
|
const messageLength = fileContent.readUInt32BE(filePos)
|
|
364
|
-
filePos
|
|
406
|
+
const frameEnd = filePos + 4 + messageLength + 1
|
|
365
407
|
|
|
366
|
-
|
|
367
|
-
if (filePos + messageLength > fileContent.length) {
|
|
368
|
-
// Truncated message data - stop here
|
|
369
|
-
break
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
filePos += messageLength
|
|
408
|
+
if (frameEnd > fileContent.length) break
|
|
373
409
|
|
|
374
|
-
|
|
375
|
-
if (filePos < fileContent.length) {
|
|
376
|
-
filePos += 1
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Update offset with this complete message
|
|
380
|
-
currentDataOffset += messageLength
|
|
410
|
+
filePos = frameEnd
|
|
381
411
|
}
|
|
382
412
|
|
|
383
|
-
|
|
384
|
-
return `0000000000000000_${String(currentDataOffset).padStart(16, `0`)}`
|
|
413
|
+
return `0000000000000000_${String(filePos).padStart(16, `0`)}`
|
|
385
414
|
} catch (err) {
|
|
386
|
-
|
|
415
|
+
serverLog.error(
|
|
387
416
|
`[FileBackedStreamStore] Error scanning file ${segmentPath}:`,
|
|
388
417
|
err
|
|
389
418
|
)
|
|
@@ -535,6 +564,33 @@ export class FileBackedStreamStore {
|
|
|
535
564
|
}
|
|
536
565
|
}
|
|
537
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Acquire a per-stream append lock that serializes the read-modify-write
|
|
569
|
+
* of currentOffset across all concurrent appenders on the same stream.
|
|
570
|
+
* Without this, two concurrent appends can read the same starting
|
|
571
|
+
* currentOffset, both compute their newOffset, both write a frame to the
|
|
572
|
+
* file, but only one of their LMDB updates wins — leaving currentOffset
|
|
573
|
+
* lagging the file's actual byte position. Returns a release function.
|
|
574
|
+
*/
|
|
575
|
+
private async acquireStreamAppendLock(
|
|
576
|
+
streamPath: string
|
|
577
|
+
): Promise<() => void> {
|
|
578
|
+
while (this.streamAppendLocks.has(streamPath)) {
|
|
579
|
+
await this.streamAppendLocks.get(streamPath)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let releaseLock: () => void
|
|
583
|
+
const lockPromise = new Promise<void>((resolve) => {
|
|
584
|
+
releaseLock = resolve
|
|
585
|
+
})
|
|
586
|
+
this.streamAppendLocks.set(streamPath, lockPromise)
|
|
587
|
+
|
|
588
|
+
return () => {
|
|
589
|
+
this.streamAppendLocks.delete(streamPath)
|
|
590
|
+
releaseLock!()
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
538
594
|
/**
|
|
539
595
|
* Get the current epoch for a producer on a stream.
|
|
540
596
|
* Returns undefined if the producer doesn't exist or stream not found.
|
|
@@ -795,6 +851,8 @@ export class FileBackedStreamStore {
|
|
|
795
851
|
// Define key for LMDB operations
|
|
796
852
|
const key = `stream:${streamPath}`
|
|
797
853
|
|
|
854
|
+
const t0 = performance.now()
|
|
855
|
+
|
|
798
856
|
// Initialize metadata
|
|
799
857
|
// Note: We set closed to false initially, then set it true after appending initial data
|
|
800
858
|
// This prevents the closed check from rejecting the initial append
|
|
@@ -816,17 +874,11 @@ export class FileBackedStreamStore {
|
|
|
816
874
|
refCount: 0,
|
|
817
875
|
}
|
|
818
876
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
this.dataDir,
|
|
823
|
-
`streams`,
|
|
824
|
-
streamMeta.directoryName
|
|
825
|
-
)
|
|
877
|
+
const tAfterMeta = performance.now()
|
|
878
|
+
|
|
879
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
826
880
|
try {
|
|
827
|
-
|
|
828
|
-
const segmentPath = path.join(streamDir, `segment_00000.log`)
|
|
829
|
-
fs.writeFileSync(segmentPath, ``)
|
|
881
|
+
await this.db.put(key, streamMeta)
|
|
830
882
|
} catch (err) {
|
|
831
883
|
// Rollback source refcount on failure
|
|
832
884
|
if (isFork && sourceMeta) {
|
|
@@ -840,15 +892,24 @@ export class FileBackedStreamStore {
|
|
|
840
892
|
this.db.putSync(sourceKey, updatedSource)
|
|
841
893
|
}
|
|
842
894
|
}
|
|
843
|
-
|
|
844
|
-
`[FileBackedStreamStore] Error creating stream
|
|
895
|
+
serverLog.error(
|
|
896
|
+
`[FileBackedStreamStore] Error creating stream (LMDB put):`,
|
|
845
897
|
err
|
|
846
898
|
)
|
|
847
899
|
throw err
|
|
848
900
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
901
|
+
const tAfterLmdb = performance.now()
|
|
902
|
+
try {
|
|
903
|
+
await this.fileHandlePool.openWriteStream(segmentPath)
|
|
904
|
+
} catch (err) {
|
|
905
|
+
this.db.removeSync(key)
|
|
906
|
+
serverLog.error(
|
|
907
|
+
`[FileBackedStreamStore] Error creating stream (file open):`,
|
|
908
|
+
err
|
|
909
|
+
)
|
|
910
|
+
throw err
|
|
911
|
+
}
|
|
912
|
+
const tAfterOpen = performance.now()
|
|
852
913
|
|
|
853
914
|
// Append initial data if provided
|
|
854
915
|
if (options.initialData && options.initialData.length > 0) {
|
|
@@ -875,16 +936,33 @@ export class FileBackedStreamStore {
|
|
|
875
936
|
throw err
|
|
876
937
|
}
|
|
877
938
|
}
|
|
939
|
+
const tAfterAppend = performance.now()
|
|
878
940
|
|
|
879
941
|
// Now set closed flag if requested (after initial append succeeded)
|
|
880
942
|
if (options.closed) {
|
|
881
943
|
const updatedMeta = this.db.get(key) as StreamMetadata
|
|
882
944
|
updatedMeta.closed = true
|
|
883
|
-
this.db.
|
|
945
|
+
await this.db.put(key, updatedMeta)
|
|
884
946
|
}
|
|
885
947
|
|
|
886
948
|
// Re-fetch updated metadata
|
|
887
949
|
const updated = this.db.get(key) as StreamMetadata
|
|
950
|
+
const totalMs = performance.now() - t0
|
|
951
|
+
if (totalMs > 50) {
|
|
952
|
+
serverLog.event(
|
|
953
|
+
{
|
|
954
|
+
event: `store.create`,
|
|
955
|
+
path: streamPath,
|
|
956
|
+
totalMs: +totalMs.toFixed(2),
|
|
957
|
+
metaMs: +(tAfterMeta - t0).toFixed(2),
|
|
958
|
+
lmdbMs: +(tAfterLmdb - tAfterMeta).toFixed(2),
|
|
959
|
+
openMs: +(tAfterOpen - tAfterLmdb).toFixed(2),
|
|
960
|
+
appendMs: +(tAfterAppend - tAfterOpen).toFixed(2),
|
|
961
|
+
initBytes: options.initialData?.length ?? 0,
|
|
962
|
+
},
|
|
963
|
+
`store.create slow`
|
|
964
|
+
)
|
|
965
|
+
}
|
|
888
966
|
return this.streamMetaToStream(updated)
|
|
889
967
|
}
|
|
890
968
|
|
|
@@ -941,26 +1019,18 @@ export class FileBackedStreamStore {
|
|
|
941
1019
|
// Cancel any pending long-polls for this stream
|
|
942
1020
|
this.cancelLongPollsForStream(streamPath)
|
|
943
1021
|
|
|
944
|
-
|
|
945
|
-
const segmentPath = path.join(
|
|
946
|
-
this.dataDir,
|
|
947
|
-
`streams`,
|
|
948
|
-
streamMeta.directoryName,
|
|
949
|
-
`segment_00000.log`
|
|
950
|
-
)
|
|
951
|
-
this.fileHandlePool.closeFileHandle(segmentPath).catch((err: Error) => {
|
|
952
|
-
console.error(`[FileBackedStreamStore] Error closing file handle:`, err)
|
|
953
|
-
})
|
|
1022
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
954
1023
|
|
|
955
1024
|
// Delete from LMDB
|
|
956
1025
|
this.db.removeSync(key)
|
|
957
1026
|
|
|
958
|
-
//
|
|
959
|
-
this.
|
|
960
|
-
.
|
|
1027
|
+
// Close handle then delete file (chained to avoid EBUSY on Windows)
|
|
1028
|
+
this.fileHandlePool
|
|
1029
|
+
.closeFileHandle(segmentPath)
|
|
1030
|
+
.then(() => fs.promises.unlink(segmentPath))
|
|
961
1031
|
.catch((err: Error) => {
|
|
962
|
-
|
|
963
|
-
`[FileBackedStreamStore] Error
|
|
1032
|
+
serverLog.error(
|
|
1033
|
+
`[FileBackedStreamStore] Error cleaning up stream file:`,
|
|
964
1034
|
err
|
|
965
1035
|
)
|
|
966
1036
|
})
|
|
@@ -985,10 +1055,28 @@ export class FileBackedStreamStore {
|
|
|
985
1055
|
}
|
|
986
1056
|
}
|
|
987
1057
|
|
|
1058
|
+
/**
|
|
1059
|
+
* Public append entry point. Serializes concurrent appends to the same
|
|
1060
|
+
* stream so the read-modify-write of currentOffset cannot interleave —
|
|
1061
|
+
* see acquireStreamAppendLock for the underlying race.
|
|
1062
|
+
*/
|
|
988
1063
|
async append(
|
|
989
1064
|
streamPath: string,
|
|
990
1065
|
data: Uint8Array,
|
|
991
1066
|
options: AppendOptions & { isInitialCreate?: boolean } = {}
|
|
1067
|
+
): Promise<StreamMessage | AppendResult | null> {
|
|
1068
|
+
const releaseLock = await this.acquireStreamAppendLock(streamPath)
|
|
1069
|
+
try {
|
|
1070
|
+
return await this.appendInner(streamPath, data, options)
|
|
1071
|
+
} finally {
|
|
1072
|
+
releaseLock()
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private async appendInner(
|
|
1077
|
+
streamPath: string,
|
|
1078
|
+
data: Uint8Array,
|
|
1079
|
+
options: AppendOptions & { isInitialCreate?: boolean } = {}
|
|
992
1080
|
): Promise<StreamMessage | AppendResult | null> {
|
|
993
1081
|
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
994
1082
|
|
|
@@ -1092,17 +1180,13 @@ export class FileBackedStreamStore {
|
|
|
1092
1180
|
const readSeq = parts[0]!
|
|
1093
1181
|
const byteOffset = parts[1]!
|
|
1094
1182
|
|
|
1095
|
-
|
|
1096
|
-
const newByteOffset = byteOffset + processedData.length
|
|
1183
|
+
const FRAME_OVERHEAD = 5 // 4-byte length prefix + 1-byte newline
|
|
1184
|
+
const newByteOffset = byteOffset + FRAME_OVERHEAD + processedData.length
|
|
1097
1185
|
const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
|
|
1098
1186
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
`streams`,
|
|
1103
|
-
streamMeta.directoryName
|
|
1104
|
-
)
|
|
1105
|
-
const segmentPath = path.join(streamDir, `segment_00000.log`)
|
|
1187
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
1188
|
+
|
|
1189
|
+
const tAppendStart = performance.now()
|
|
1106
1190
|
|
|
1107
1191
|
// Get write stream from pool
|
|
1108
1192
|
const stream = this.fileHandlePool.getWriteStream(segmentPath)
|
|
@@ -1124,6 +1208,8 @@ export class FileBackedStreamStore {
|
|
|
1124
1208
|
})
|
|
1125
1209
|
})
|
|
1126
1210
|
|
|
1211
|
+
const tAfterWrite = performance.now()
|
|
1212
|
+
|
|
1127
1213
|
// 2. Create message object for return value
|
|
1128
1214
|
const message: StreamMessage = {
|
|
1129
1215
|
data: processedData,
|
|
@@ -1134,6 +1220,8 @@ export class FileBackedStreamStore {
|
|
|
1134
1220
|
// 3. Flush to disk (blocks here until durable)
|
|
1135
1221
|
await this.fileHandlePool.fsyncFile(segmentPath)
|
|
1136
1222
|
|
|
1223
|
+
const tAfterFsync = performance.now()
|
|
1224
|
+
|
|
1137
1225
|
// 4. Update LMDB metadata atomically (only after flush, so metadata reflects durability)
|
|
1138
1226
|
// This includes both the offset update and producer state update
|
|
1139
1227
|
// Producer state is committed HERE (not in validateProducer) for atomicity
|
|
@@ -1162,7 +1250,25 @@ export class FileBackedStreamStore {
|
|
|
1162
1250
|
closedBy: closedBy ?? streamMeta.closedBy,
|
|
1163
1251
|
}
|
|
1164
1252
|
const key = `stream:${streamPath}`
|
|
1165
|
-
this.db.
|
|
1253
|
+
await this.db.put(key, updatedMeta)
|
|
1254
|
+
|
|
1255
|
+
const tAfterLmdb = performance.now()
|
|
1256
|
+
const appendTotal = tAfterLmdb - tAppendStart
|
|
1257
|
+
if (appendTotal > 50) {
|
|
1258
|
+
serverLog.event(
|
|
1259
|
+
{
|
|
1260
|
+
event: `store.append`,
|
|
1261
|
+
path: streamPath,
|
|
1262
|
+
totalMs: +appendTotal.toFixed(2),
|
|
1263
|
+
writeMs: +(tAfterWrite - tAppendStart).toFixed(2),
|
|
1264
|
+
fsyncMs: +(tAfterFsync - tAfterWrite).toFixed(2),
|
|
1265
|
+
lmdbMs: +(tAfterLmdb - tAfterFsync).toFixed(2),
|
|
1266
|
+
bytes: processedData.length,
|
|
1267
|
+
isInitial: options.isInitialCreate ?? false,
|
|
1268
|
+
},
|
|
1269
|
+
`store.append slow`
|
|
1270
|
+
)
|
|
1271
|
+
}
|
|
1166
1272
|
|
|
1167
1273
|
// 5. Notify long-polls (data is now readable from disk)
|
|
1168
1274
|
this.notifyLongPolls(streamPath)
|
|
@@ -1338,7 +1444,7 @@ export class FileBackedStreamStore {
|
|
|
1338
1444
|
},
|
|
1339
1445
|
producers: updatedProducers,
|
|
1340
1446
|
}
|
|
1341
|
-
this.db.
|
|
1447
|
+
await this.db.put(key, updatedMeta)
|
|
1342
1448
|
|
|
1343
1449
|
// Notify any pending long-polls
|
|
1344
1450
|
this.notifyLongPollsClosed(streamPath)
|
|
@@ -1397,8 +1503,10 @@ export class FileBackedStreamStore {
|
|
|
1397
1503
|
// Skip newline
|
|
1398
1504
|
filePos += 1
|
|
1399
1505
|
|
|
1400
|
-
// Calculate this message's logical offset (end position)
|
|
1401
|
-
|
|
1506
|
+
// Calculate this message's logical offset (end position).
|
|
1507
|
+
// Frames in our file layout are 4-byte length + data + 1-byte newline,
|
|
1508
|
+
// and stream offsets advance by the full frame size — see append().
|
|
1509
|
+
physicalDataOffset += messageLength + 5
|
|
1402
1510
|
const logicalOffset = baseByteOffset + physicalDataOffset
|
|
1403
1511
|
|
|
1404
1512
|
// Stop if we've exceeded the cap
|
|
@@ -1416,7 +1524,10 @@ export class FileBackedStreamStore {
|
|
|
1416
1524
|
}
|
|
1417
1525
|
}
|
|
1418
1526
|
} catch (err) {
|
|
1419
|
-
|
|
1527
|
+
serverLog.error(
|
|
1528
|
+
`[FileBackedStreamStore] Error reading segment file:`,
|
|
1529
|
+
err
|
|
1530
|
+
)
|
|
1420
1531
|
}
|
|
1421
1532
|
|
|
1422
1533
|
return messages
|
|
@@ -1460,12 +1571,7 @@ export class FileBackedStreamStore {
|
|
|
1460
1571
|
// Read source's own segment file
|
|
1461
1572
|
// For a fork source, its own data starts at physical byte 0 in its segment file,
|
|
1462
1573
|
// but the logical offsets need to account for its own forkOffset base
|
|
1463
|
-
const segmentPath =
|
|
1464
|
-
this.dataDir,
|
|
1465
|
-
`streams`,
|
|
1466
|
-
sourceMeta.directoryName,
|
|
1467
|
-
`segment_00000.log`
|
|
1468
|
-
)
|
|
1574
|
+
const segmentPath = segmentFile(this.dataDir, sourceMeta.directoryName)
|
|
1469
1575
|
|
|
1470
1576
|
// The base offset for this source's own data is its forkOffset (if it's a fork) or 0
|
|
1471
1577
|
const sourceBaseByte = sourceMeta.forkOffset
|
|
@@ -1526,12 +1632,7 @@ export class FileBackedStreamStore {
|
|
|
1526
1632
|
|
|
1527
1633
|
// Read fork's own segment file with offset translation
|
|
1528
1634
|
// Physical bytes in file start at 0, but logical offsets start at forkOffset
|
|
1529
|
-
const segmentPath =
|
|
1530
|
-
this.dataDir,
|
|
1531
|
-
`streams`,
|
|
1532
|
-
streamMeta.directoryName,
|
|
1533
|
-
`segment_00000.log`
|
|
1534
|
-
)
|
|
1635
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
1535
1636
|
const ownMessages = this.readMessagesFromSegmentFile(
|
|
1536
1637
|
segmentPath,
|
|
1537
1638
|
startByte,
|
|
@@ -1540,12 +1641,7 @@ export class FileBackedStreamStore {
|
|
|
1540
1641
|
messages.push(...ownMessages)
|
|
1541
1642
|
} else {
|
|
1542
1643
|
// Non-forked stream: read from segment file directly
|
|
1543
|
-
const segmentPath =
|
|
1544
|
-
this.dataDir,
|
|
1545
|
-
`streams`,
|
|
1546
|
-
streamMeta.directoryName,
|
|
1547
|
-
`segment_00000.log`
|
|
1548
|
-
)
|
|
1644
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
1549
1645
|
const ownMessages = this.readMessagesFromSegmentFile(
|
|
1550
1646
|
segmentPath,
|
|
1551
1647
|
startByte,
|
|
@@ -1649,6 +1745,10 @@ export class FileBackedStreamStore {
|
|
|
1649
1745
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
1650
1746
|
}
|
|
1651
1747
|
|
|
1748
|
+
if (normalizeContentType(streamMeta.contentType) === `application/json`) {
|
|
1749
|
+
return formatJsonMessages(messages)
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1652
1752
|
// Concatenate all message data
|
|
1653
1753
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)
|
|
1654
1754
|
const concatenated = new Uint8Array(totalSize)
|
|
@@ -1658,11 +1758,6 @@ export class FileBackedStreamStore {
|
|
|
1658
1758
|
offset += msg.data.length
|
|
1659
1759
|
}
|
|
1660
1760
|
|
|
1661
|
-
// For JSON mode, wrap in array brackets
|
|
1662
|
-
if (normalizeContentType(streamMeta.contentType) === `application/json`) {
|
|
1663
|
-
return formatJsonResponse(concatenated)
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
1761
|
return concatenated
|
|
1667
1762
|
}
|
|
1668
1763
|
|
|
@@ -1695,7 +1790,7 @@ export class FileBackedStreamStore {
|
|
|
1695
1790
|
|
|
1696
1791
|
// Clear file handle pool
|
|
1697
1792
|
this.fileHandlePool.closeAll().catch((err: Error) => {
|
|
1698
|
-
|
|
1793
|
+
serverLog.error(`[FileBackedStreamStore] Error closing handles:`, err)
|
|
1699
1794
|
})
|
|
1700
1795
|
|
|
1701
1796
|
// Note: Files are not deleted in clear() with unique directory names
|