@durable-streams/server 0.3.2 → 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 +1297 -260
- package/dist/index.d.cts +236 -2
- package/dist/index.d.ts +236 -2
- package/dist/index.js +1344 -312
- package/package.json +3 -3
- package/src/crypto.ts +217 -0
- package/src/file-store.ts +187 -144
- package/src/glob.ts +70 -0
- package/src/index.ts +14 -0
- package/src/log.ts +56 -0
- package/src/server.ts +75 -26
- package/src/store.ts +59 -7
- 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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
|
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
|
|
165
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
|
|
@@ -247,10 +290,13 @@ export class FileBackedStreamStore {
|
|
|
247
290
|
this.db = openLMDB({
|
|
248
291
|
path: path.join(this.dataDir, `metadata.lmdb`),
|
|
249
292
|
compression: true,
|
|
293
|
+
noMemInit: true,
|
|
294
|
+
cache: true,
|
|
295
|
+
sharedStructuresKey: Symbol.for(`structures`),
|
|
250
296
|
})
|
|
251
297
|
|
|
252
|
-
//
|
|
253
|
-
|
|
298
|
+
// Pre-create the streams directory
|
|
299
|
+
fs.mkdirSync(path.join(this.dataDir, `streams`), { recursive: true })
|
|
254
300
|
|
|
255
301
|
// Initialize file handle pool with SIEVE cache
|
|
256
302
|
const maxFileHandles = options.maxFileHandles ?? 100
|
|
@@ -265,7 +311,7 @@ export class FileBackedStreamStore {
|
|
|
265
311
|
* Validates that LMDB metadata matches actual file contents and reconciles any mismatches.
|
|
266
312
|
*/
|
|
267
313
|
private recover(): void {
|
|
268
|
-
|
|
314
|
+
serverLog.info(`[FileBackedStreamStore] Starting recovery...`)
|
|
269
315
|
|
|
270
316
|
let recovered = 0
|
|
271
317
|
let reconciled = 0
|
|
@@ -288,17 +334,11 @@ export class FileBackedStreamStore {
|
|
|
288
334
|
const streamMeta = value as StreamMetadata
|
|
289
335
|
const streamPath = key.replace(`stream:`, ``)
|
|
290
336
|
|
|
291
|
-
|
|
292
|
-
const segmentPath = path.join(
|
|
293
|
-
this.dataDir,
|
|
294
|
-
`streams`,
|
|
295
|
-
streamMeta.directoryName,
|
|
296
|
-
`segment_00000.log`
|
|
297
|
-
)
|
|
337
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
298
338
|
|
|
299
339
|
// Check if file exists
|
|
300
340
|
if (!fs.existsSync(segmentPath)) {
|
|
301
|
-
|
|
341
|
+
serverLog.warn(
|
|
302
342
|
`[FileBackedStreamStore] Recovery: Stream file missing for ${streamPath}, removing from LMDB`
|
|
303
343
|
)
|
|
304
344
|
this.db.removeSync(key)
|
|
@@ -323,7 +363,7 @@ export class FileBackedStreamStore {
|
|
|
323
363
|
|
|
324
364
|
// Check if offset matches
|
|
325
365
|
if (trueOffset !== streamMeta.currentOffset) {
|
|
326
|
-
|
|
366
|
+
serverLog.warn(
|
|
327
367
|
`[FileBackedStreamStore] Recovery: Offset mismatch for ${streamPath}: ` +
|
|
328
368
|
`LMDB says ${streamMeta.currentOffset}, file says ${trueOffset}. Reconciling to file.`
|
|
329
369
|
)
|
|
@@ -339,12 +379,12 @@ export class FileBackedStreamStore {
|
|
|
339
379
|
|
|
340
380
|
recovered++
|
|
341
381
|
} catch (err) {
|
|
342
|
-
|
|
382
|
+
serverLog.error(`[FileBackedStreamStore] Error recovering stream:`, err)
|
|
343
383
|
errors++
|
|
344
384
|
}
|
|
345
385
|
}
|
|
346
386
|
|
|
347
|
-
|
|
387
|
+
serverLog.info(
|
|
348
388
|
`[FileBackedStreamStore] Recovery complete: ${recovered} streams, ` +
|
|
349
389
|
`${reconciled} reconciled, ${errors} errors`
|
|
350
390
|
)
|
|
@@ -358,39 +398,21 @@ export class FileBackedStreamStore {
|
|
|
358
398
|
try {
|
|
359
399
|
const fileContent = fs.readFileSync(segmentPath)
|
|
360
400
|
let filePos = 0
|
|
361
|
-
let currentDataOffset = 0
|
|
362
401
|
|
|
363
402
|
while (filePos < fileContent.length) {
|
|
364
|
-
|
|
365
|
-
if (filePos + 4 > fileContent.length) {
|
|
366
|
-
// Truncated length header - stop here
|
|
367
|
-
break
|
|
368
|
-
}
|
|
403
|
+
if (filePos + 4 > fileContent.length) break
|
|
369
404
|
|
|
370
405
|
const messageLength = fileContent.readUInt32BE(filePos)
|
|
371
|
-
filePos
|
|
372
|
-
|
|
373
|
-
// Check if we have the full message
|
|
374
|
-
if (filePos + messageLength > fileContent.length) {
|
|
375
|
-
// Truncated message data - stop here
|
|
376
|
-
break
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
filePos += messageLength
|
|
406
|
+
const frameEnd = filePos + 4 + messageLength + 1
|
|
380
407
|
|
|
381
|
-
|
|
382
|
-
if (filePos < fileContent.length) {
|
|
383
|
-
filePos += 1
|
|
384
|
-
}
|
|
408
|
+
if (frameEnd > fileContent.length) break
|
|
385
409
|
|
|
386
|
-
|
|
387
|
-
currentDataOffset += messageLength
|
|
410
|
+
filePos = frameEnd
|
|
388
411
|
}
|
|
389
412
|
|
|
390
|
-
|
|
391
|
-
return `0000000000000000_${String(currentDataOffset).padStart(16, `0`)}`
|
|
413
|
+
return `0000000000000000_${String(filePos).padStart(16, `0`)}`
|
|
392
414
|
} catch (err) {
|
|
393
|
-
|
|
415
|
+
serverLog.error(
|
|
394
416
|
`[FileBackedStreamStore] Error scanning file ${segmentPath}:`,
|
|
395
417
|
err
|
|
396
418
|
)
|
|
@@ -829,6 +851,8 @@ export class FileBackedStreamStore {
|
|
|
829
851
|
// Define key for LMDB operations
|
|
830
852
|
const key = `stream:${streamPath}`
|
|
831
853
|
|
|
854
|
+
const t0 = performance.now()
|
|
855
|
+
|
|
832
856
|
// Initialize metadata
|
|
833
857
|
// Note: We set closed to false initially, then set it true after appending initial data
|
|
834
858
|
// This prevents the closed check from rejecting the initial append
|
|
@@ -850,17 +874,11 @@ export class FileBackedStreamStore {
|
|
|
850
874
|
refCount: 0,
|
|
851
875
|
}
|
|
852
876
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
this.dataDir,
|
|
857
|
-
`streams`,
|
|
858
|
-
streamMeta.directoryName
|
|
859
|
-
)
|
|
877
|
+
const tAfterMeta = performance.now()
|
|
878
|
+
|
|
879
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
860
880
|
try {
|
|
861
|
-
|
|
862
|
-
const segmentPath = path.join(streamDir, `segment_00000.log`)
|
|
863
|
-
fs.writeFileSync(segmentPath, ``)
|
|
881
|
+
await this.db.put(key, streamMeta)
|
|
864
882
|
} catch (err) {
|
|
865
883
|
// Rollback source refcount on failure
|
|
866
884
|
if (isFork && sourceMeta) {
|
|
@@ -874,15 +892,24 @@ export class FileBackedStreamStore {
|
|
|
874
892
|
this.db.putSync(sourceKey, updatedSource)
|
|
875
893
|
}
|
|
876
894
|
}
|
|
877
|
-
|
|
878
|
-
`[FileBackedStreamStore] Error creating stream
|
|
895
|
+
serverLog.error(
|
|
896
|
+
`[FileBackedStreamStore] Error creating stream (LMDB put):`,
|
|
879
897
|
err
|
|
880
898
|
)
|
|
881
899
|
throw err
|
|
882
900
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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()
|
|
886
913
|
|
|
887
914
|
// Append initial data if provided
|
|
888
915
|
if (options.initialData && options.initialData.length > 0) {
|
|
@@ -909,16 +936,33 @@ export class FileBackedStreamStore {
|
|
|
909
936
|
throw err
|
|
910
937
|
}
|
|
911
938
|
}
|
|
939
|
+
const tAfterAppend = performance.now()
|
|
912
940
|
|
|
913
941
|
// Now set closed flag if requested (after initial append succeeded)
|
|
914
942
|
if (options.closed) {
|
|
915
943
|
const updatedMeta = this.db.get(key) as StreamMetadata
|
|
916
944
|
updatedMeta.closed = true
|
|
917
|
-
this.db.
|
|
945
|
+
await this.db.put(key, updatedMeta)
|
|
918
946
|
}
|
|
919
947
|
|
|
920
948
|
// Re-fetch updated metadata
|
|
921
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
|
+
}
|
|
922
966
|
return this.streamMetaToStream(updated)
|
|
923
967
|
}
|
|
924
968
|
|
|
@@ -975,26 +1019,18 @@ export class FileBackedStreamStore {
|
|
|
975
1019
|
// Cancel any pending long-polls for this stream
|
|
976
1020
|
this.cancelLongPollsForStream(streamPath)
|
|
977
1021
|
|
|
978
|
-
|
|
979
|
-
const segmentPath = path.join(
|
|
980
|
-
this.dataDir,
|
|
981
|
-
`streams`,
|
|
982
|
-
streamMeta.directoryName,
|
|
983
|
-
`segment_00000.log`
|
|
984
|
-
)
|
|
985
|
-
this.fileHandlePool.closeFileHandle(segmentPath).catch((err: Error) => {
|
|
986
|
-
console.error(`[FileBackedStreamStore] Error closing file handle:`, err)
|
|
987
|
-
})
|
|
1022
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
988
1023
|
|
|
989
1024
|
// Delete from LMDB
|
|
990
1025
|
this.db.removeSync(key)
|
|
991
1026
|
|
|
992
|
-
//
|
|
993
|
-
this.
|
|
994
|
-
.
|
|
1027
|
+
// Close handle then delete file (chained to avoid EBUSY on Windows)
|
|
1028
|
+
this.fileHandlePool
|
|
1029
|
+
.closeFileHandle(segmentPath)
|
|
1030
|
+
.then(() => fs.promises.unlink(segmentPath))
|
|
995
1031
|
.catch((err: Error) => {
|
|
996
|
-
|
|
997
|
-
`[FileBackedStreamStore] Error
|
|
1032
|
+
serverLog.error(
|
|
1033
|
+
`[FileBackedStreamStore] Error cleaning up stream file:`,
|
|
998
1034
|
err
|
|
999
1035
|
)
|
|
1000
1036
|
})
|
|
@@ -1144,17 +1180,13 @@ export class FileBackedStreamStore {
|
|
|
1144
1180
|
const readSeq = parts[0]!
|
|
1145
1181
|
const byteOffset = parts[1]!
|
|
1146
1182
|
|
|
1147
|
-
|
|
1148
|
-
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
|
|
1149
1185
|
const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
|
|
1150
1186
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
`streams`,
|
|
1155
|
-
streamMeta.directoryName
|
|
1156
|
-
)
|
|
1157
|
-
const segmentPath = path.join(streamDir, `segment_00000.log`)
|
|
1187
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
1188
|
+
|
|
1189
|
+
const tAppendStart = performance.now()
|
|
1158
1190
|
|
|
1159
1191
|
// Get write stream from pool
|
|
1160
1192
|
const stream = this.fileHandlePool.getWriteStream(segmentPath)
|
|
@@ -1176,6 +1208,8 @@ export class FileBackedStreamStore {
|
|
|
1176
1208
|
})
|
|
1177
1209
|
})
|
|
1178
1210
|
|
|
1211
|
+
const tAfterWrite = performance.now()
|
|
1212
|
+
|
|
1179
1213
|
// 2. Create message object for return value
|
|
1180
1214
|
const message: StreamMessage = {
|
|
1181
1215
|
data: processedData,
|
|
@@ -1186,6 +1220,8 @@ export class FileBackedStreamStore {
|
|
|
1186
1220
|
// 3. Flush to disk (blocks here until durable)
|
|
1187
1221
|
await this.fileHandlePool.fsyncFile(segmentPath)
|
|
1188
1222
|
|
|
1223
|
+
const tAfterFsync = performance.now()
|
|
1224
|
+
|
|
1189
1225
|
// 4. Update LMDB metadata atomically (only after flush, so metadata reflects durability)
|
|
1190
1226
|
// This includes both the offset update and producer state update
|
|
1191
1227
|
// Producer state is committed HERE (not in validateProducer) for atomicity
|
|
@@ -1214,7 +1250,25 @@ export class FileBackedStreamStore {
|
|
|
1214
1250
|
closedBy: closedBy ?? streamMeta.closedBy,
|
|
1215
1251
|
}
|
|
1216
1252
|
const key = `stream:${streamPath}`
|
|
1217
|
-
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
|
+
}
|
|
1218
1272
|
|
|
1219
1273
|
// 5. Notify long-polls (data is now readable from disk)
|
|
1220
1274
|
this.notifyLongPolls(streamPath)
|
|
@@ -1390,7 +1444,7 @@ export class FileBackedStreamStore {
|
|
|
1390
1444
|
},
|
|
1391
1445
|
producers: updatedProducers,
|
|
1392
1446
|
}
|
|
1393
|
-
this.db.
|
|
1447
|
+
await this.db.put(key, updatedMeta)
|
|
1394
1448
|
|
|
1395
1449
|
// Notify any pending long-polls
|
|
1396
1450
|
this.notifyLongPollsClosed(streamPath)
|
|
@@ -1449,8 +1503,10 @@ export class FileBackedStreamStore {
|
|
|
1449
1503
|
// Skip newline
|
|
1450
1504
|
filePos += 1
|
|
1451
1505
|
|
|
1452
|
-
// Calculate this message's logical offset (end position)
|
|
1453
|
-
|
|
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
|
|
1454
1510
|
const logicalOffset = baseByteOffset + physicalDataOffset
|
|
1455
1511
|
|
|
1456
1512
|
// Stop if we've exceeded the cap
|
|
@@ -1468,7 +1524,10 @@ export class FileBackedStreamStore {
|
|
|
1468
1524
|
}
|
|
1469
1525
|
}
|
|
1470
1526
|
} catch (err) {
|
|
1471
|
-
|
|
1527
|
+
serverLog.error(
|
|
1528
|
+
`[FileBackedStreamStore] Error reading segment file:`,
|
|
1529
|
+
err
|
|
1530
|
+
)
|
|
1472
1531
|
}
|
|
1473
1532
|
|
|
1474
1533
|
return messages
|
|
@@ -1512,12 +1571,7 @@ export class FileBackedStreamStore {
|
|
|
1512
1571
|
// Read source's own segment file
|
|
1513
1572
|
// For a fork source, its own data starts at physical byte 0 in its segment file,
|
|
1514
1573
|
// but the logical offsets need to account for its own forkOffset base
|
|
1515
|
-
const segmentPath =
|
|
1516
|
-
this.dataDir,
|
|
1517
|
-
`streams`,
|
|
1518
|
-
sourceMeta.directoryName,
|
|
1519
|
-
`segment_00000.log`
|
|
1520
|
-
)
|
|
1574
|
+
const segmentPath = segmentFile(this.dataDir, sourceMeta.directoryName)
|
|
1521
1575
|
|
|
1522
1576
|
// The base offset for this source's own data is its forkOffset (if it's a fork) or 0
|
|
1523
1577
|
const sourceBaseByte = sourceMeta.forkOffset
|
|
@@ -1578,12 +1632,7 @@ export class FileBackedStreamStore {
|
|
|
1578
1632
|
|
|
1579
1633
|
// Read fork's own segment file with offset translation
|
|
1580
1634
|
// Physical bytes in file start at 0, but logical offsets start at forkOffset
|
|
1581
|
-
const segmentPath =
|
|
1582
|
-
this.dataDir,
|
|
1583
|
-
`streams`,
|
|
1584
|
-
streamMeta.directoryName,
|
|
1585
|
-
`segment_00000.log`
|
|
1586
|
-
)
|
|
1635
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
1587
1636
|
const ownMessages = this.readMessagesFromSegmentFile(
|
|
1588
1637
|
segmentPath,
|
|
1589
1638
|
startByte,
|
|
@@ -1592,12 +1641,7 @@ export class FileBackedStreamStore {
|
|
|
1592
1641
|
messages.push(...ownMessages)
|
|
1593
1642
|
} else {
|
|
1594
1643
|
// Non-forked stream: read from segment file directly
|
|
1595
|
-
const segmentPath =
|
|
1596
|
-
this.dataDir,
|
|
1597
|
-
`streams`,
|
|
1598
|
-
streamMeta.directoryName,
|
|
1599
|
-
`segment_00000.log`
|
|
1600
|
-
)
|
|
1644
|
+
const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
|
|
1601
1645
|
const ownMessages = this.readMessagesFromSegmentFile(
|
|
1602
1646
|
segmentPath,
|
|
1603
1647
|
startByte,
|
|
@@ -1701,6 +1745,10 @@ export class FileBackedStreamStore {
|
|
|
1701
1745
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
1702
1746
|
}
|
|
1703
1747
|
|
|
1748
|
+
if (normalizeContentType(streamMeta.contentType) === `application/json`) {
|
|
1749
|
+
return formatJsonMessages(messages)
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1704
1752
|
// Concatenate all message data
|
|
1705
1753
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)
|
|
1706
1754
|
const concatenated = new Uint8Array(totalSize)
|
|
@@ -1710,11 +1758,6 @@ export class FileBackedStreamStore {
|
|
|
1710
1758
|
offset += msg.data.length
|
|
1711
1759
|
}
|
|
1712
1760
|
|
|
1713
|
-
// For JSON mode, wrap in array brackets
|
|
1714
|
-
if (normalizeContentType(streamMeta.contentType) === `application/json`) {
|
|
1715
|
-
return formatJsonResponse(concatenated)
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
1761
|
return concatenated
|
|
1719
1762
|
}
|
|
1720
1763
|
|
|
@@ -1747,7 +1790,7 @@ export class FileBackedStreamStore {
|
|
|
1747
1790
|
|
|
1748
1791
|
// Clear file handle pool
|
|
1749
1792
|
this.fileHandlePool.closeAll().catch((err: Error) => {
|
|
1750
|
-
|
|
1793
|
+
serverLog.error(`[FileBackedStreamStore] Error closing handles:`, err)
|
|
1751
1794
|
})
|
|
1752
1795
|
|
|
1753
1796
|
// Note: Files are not deleted in clear() with unique directory names
|
package/src/glob.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob pattern matching for webhook subscription patterns.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - `*` matches exactly one path segment
|
|
6
|
+
* - `**` matches zero or more path segments (recursive)
|
|
7
|
+
* - Literal segments match exactly
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Match a stream path against a glob pattern.
|
|
12
|
+
*/
|
|
13
|
+
export function globMatch(pattern: string, path: string): boolean {
|
|
14
|
+
const patternParts = splitPath(pattern)
|
|
15
|
+
const pathParts = splitPath(path)
|
|
16
|
+
return matchParts(patternParts, 0, pathParts, 0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function splitPath(p: string): Array<string> {
|
|
20
|
+
// Normalize: remove leading/trailing slashes, split on /
|
|
21
|
+
return p
|
|
22
|
+
.replace(/^\/+/, ``)
|
|
23
|
+
.replace(/\/+$/, ``)
|
|
24
|
+
.split(`/`)
|
|
25
|
+
.filter((s) => s.length > 0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matchParts(
|
|
29
|
+
pattern: Array<string>,
|
|
30
|
+
pi: number,
|
|
31
|
+
path: Array<string>,
|
|
32
|
+
si: number
|
|
33
|
+
): boolean {
|
|
34
|
+
while (pi < pattern.length && si < path.length) {
|
|
35
|
+
const seg = pattern[pi]!
|
|
36
|
+
|
|
37
|
+
if (seg === `**`) {
|
|
38
|
+
// ** matches zero or more segments
|
|
39
|
+
// Try matching rest of pattern against every possible suffix of path
|
|
40
|
+
for (let i = si; i <= path.length; i++) {
|
|
41
|
+
if (matchParts(pattern, pi + 1, path, i)) {
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (seg === `*`) {
|
|
49
|
+
// * matches exactly one segment
|
|
50
|
+
pi++
|
|
51
|
+
si++
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Literal match (also handle %2A as *)
|
|
56
|
+
const decodedSeg = seg.replace(/%2[Aa]/g, `*`)
|
|
57
|
+
if (decodedSeg !== path[si]) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
pi++
|
|
61
|
+
si++
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handle trailing ** which matches zero segments
|
|
65
|
+
while (pi < pattern.length && pattern[pi] === `**`) {
|
|
66
|
+
pi++
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return pi === pattern.length && si === path.length
|
|
70
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,3 +25,17 @@ export type {
|
|
|
25
25
|
StreamLifecycleEvent,
|
|
26
26
|
StreamLifecycleHook,
|
|
27
27
|
} from "./types"
|
|
28
|
+
export { SubscriptionManager, validateWebhookUrl } from "./subscription-manager"
|
|
29
|
+
export { SubscriptionRoutes } from "./subscription-routes"
|
|
30
|
+
export type {
|
|
31
|
+
SubscriptionCallbackRequest,
|
|
32
|
+
SubscriptionCreateInput,
|
|
33
|
+
SubscriptionError,
|
|
34
|
+
SubscriptionErrorCode,
|
|
35
|
+
SubscriptionRecord,
|
|
36
|
+
SubscriptionStatus,
|
|
37
|
+
SubscriptionStreamInfo,
|
|
38
|
+
SubscriptionStreamLink,
|
|
39
|
+
SubscriptionType,
|
|
40
|
+
} from "./subscription-types"
|
|
41
|
+
export { globMatch } from "./glob"
|