socketduct 0.1.18 → 0.1.20

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.
Files changed (55) hide show
  1. package/.env.example +17 -5
  2. package/README.md +5 -5
  3. package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
  4. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  5. package/docs/architecture.md +11 -9
  6. package/docs/docker.md +5 -4
  7. package/docs/logging.md +131 -159
  8. package/docs/protocol.md +43 -7
  9. package/docs/reverse-gateway.md +75 -9
  10. package/docs/running.md +16 -13
  11. package/docs/security.md +16 -5
  12. package/docs/session-lifecycle.md +14 -3
  13. package/docs/socket-api.md +54 -20
  14. package/package.json +5 -1
  15. package/scripts/verify-zstd-interoperability.mjs +41 -0
  16. package/src/cli/socketduct.js +143 -0
  17. package/src/client/multiplexed-transport-set.js +12 -5
  18. package/src/client/virtual-socket.js +52 -0
  19. package/src/config/environment.js +56 -7
  20. package/src/index.js +64 -26
  21. package/src/logging/compressed-log-reader.js +256 -0
  22. package/src/logging/compressed-log-worker.js +430 -0
  23. package/src/logging/compressed-log-writer.js +538 -0
  24. package/src/logging/log-batch.js +72 -0
  25. package/src/logging/log-retention.js +241 -0
  26. package/src/logging/log-segment-index.js +176 -0
  27. package/src/logging/structured-logger.js +136 -30
  28. package/src/logging/zstd-frame-codec.js +73 -0
  29. package/src/reverse/client.js +372 -82
  30. package/src/reverse/disconnect-attribution.js +191 -0
  31. package/src/reverse/gateway.js +68 -18
  32. package/src/reverse/hub.js +215 -32
  33. package/src/reverse/runtime.js +5 -3
  34. package/src/spool/spool-file.js +136 -7
  35. package/tensorbuzz.yml +4 -2
  36. package/test/e2e/logger-shutdown-process.test.js +86 -0
  37. package/test/e2e/reverse-client-recovery-process.test.js +170 -0
  38. package/test/e2e/reverse-gateway-process.test.js +15 -14
  39. package/test/fixtures/reverse-client-recovery.mjs +123 -0
  40. package/test/integration/logging-cli.test.js +168 -0
  41. package/test/integration/logging-compressed-recovery.test.js +186 -0
  42. package/test/integration/reverse-gateway.test.js +597 -1
  43. package/test/integration/reverse-route-class.test.js +6 -1
  44. package/test/unit/client/multiplexed-transport-set.test.js +12 -1
  45. package/test/unit/config/docker-gateway-files.test.js +2 -0
  46. package/test/unit/config/environment.test.js +54 -14
  47. package/test/unit/index.test.js +3 -0
  48. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  49. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  50. package/test/unit/logging/log-retention.test.js +86 -0
  51. package/test/unit/logging/log-segment-index.test.js +104 -0
  52. package/test/unit/logging/structured-logger.test.js +14 -1
  53. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  54. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  55. package/test/unit/spool/spool-file.test.js +133 -0
@@ -0,0 +1,430 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ import {createHash, randomUUID} from "node:crypto"
3
+ import {constants} from "node:fs"
4
+ import {lstat, mkdir, open, readdir, stat, unlink} from "node:fs/promises"
5
+ import {join} from "node:path"
6
+ import {isMainThread, parentPort, workerData} from "node:worker_threads"
7
+
8
+ import {inspectLogBatch} from "./log-batch.js"
9
+ import {readCompressedLogSegment} from "./compressed-log-reader.js"
10
+ import {enforceLogRetention} from "./log-retention.js"
11
+ import {createIndexEntry, createIndexHeader, INDEX_ENTRY_BYTES, INDEX_HEADER_BYTES, parseIndexEntry, parseIndexHeader, uuidBytes} from "./log-segment-index.js"
12
+ import {compressLogFrame, decompressLogFrame} from "./zstd-frame-codec.js"
13
+
14
+ const ROLE = /^[a-z][a-z0-9-]{0,63}$/
15
+
16
+ /** Dedicated storage worker. Compression and sync never execute on a protocol event loop. */
17
+ export class CompressedLogWorkerSink {
18
+ /** @type {string} */ #directory
19
+ /** @type {string} */ #role
20
+ /** @type {Buffer} */ #writerEpoch
21
+ /** @type {string} */ #writerEpochText
22
+ /** @type {number} */ #maxSegmentBytes
23
+ /** @type {number} */ #compressionLevel
24
+ /** @type {number} */ #retentionAgeMs
25
+ /** @type {number} */ #retentionBytes
26
+ /** @type {import('node:fs/promises').FileHandle | undefined} */ #dataHandle
27
+ /** @type {import('node:fs/promises').FileHandle | undefined} */ #indexHandle
28
+ /** @type {string | undefined} */ #dataPath
29
+ /** @type {string | undefined} */ #indexPath
30
+ /** @type {Buffer | undefined} */ #segmentUuid
31
+ /** @type {bigint} */ #dataBytes = 0n
32
+ /** @type {bigint} */ #durableHighWater = 0n
33
+ /** @type {{batchId: string, firstSequence: bigint, lastSequence: bigint} | undefined} */ #lastDurableBatch
34
+
35
+ /** @param {{directory: string, role: string, writerEpoch: string, maxSegmentBytes: number, compressionLevel: number, retentionAgeMs?: number, retentionBytes?: number}} options - Validated producer settings. */
36
+ constructor(options) {
37
+ if (typeof options.directory !== "string" || options.directory.length === 0) throw new TypeError("worker log directory is required")
38
+ if (typeof options.role !== "string" || !ROLE.test(options.role)) throw new TypeError("worker log role is invalid")
39
+ const writerEpoch = Buffer.from(options.writerEpoch, "base64url")
40
+ if (writerEpoch.length !== 16 || writerEpoch.toString("base64url") !== options.writerEpoch) throw new TypeError("worker epoch is invalid")
41
+ if (!Number.isSafeInteger(options.maxSegmentBytes) || options.maxSegmentBytes < 1024) throw new RangeError("maxSegmentBytes must be at least 1024")
42
+ this.#directory = options.directory
43
+ this.#role = options.role
44
+ this.#writerEpoch = writerEpoch
45
+ this.#writerEpochText = options.writerEpoch
46
+ this.#maxSegmentBytes = options.maxSegmentBytes
47
+ this.#compressionLevel = options.compressionLevel
48
+ this.#retentionAgeMs = options.retentionAgeMs ?? 30 * 24 * 60 * 60 * 1000
49
+ this.#retentionBytes = options.retentionBytes ?? 4 * 1024 * 1024 * 1024
50
+ }
51
+
52
+ /** Recovers the producer epoch and opens exactly one active segment. */
53
+ async start() {
54
+ await mkdir(this.#directory, {recursive: true, mode: 0o700})
55
+ const directoryStats = await lstat(this.#directory)
56
+ if (!directoryStats.isDirectory() || (directoryStats.mode & 0o777) !== 0o700) throw new Error("Unsafe compressed log directory")
57
+ const prefix = `${this.#role}-${this.#writerEpochText}-`
58
+ const candidates = (await readdir(this.#directory)).filter((name) => name.startsWith(prefix) && name.endsWith(".jsonl.zst"))
59
+ /** @type {Array<{path: string, sealed: boolean, firstSequence: bigint | undefined, lastSequence: bigint, lastBatch: {batchId: string, firstSequence: bigint, lastSequence: bigint} | undefined, dataBytes: bigint, segmentUuid: Buffer, orphanRecovered: boolean}>} */
60
+ const recovered = []
61
+ for (const name of candidates) {
62
+ const path = join(this.#directory, name)
63
+ try {
64
+ recovered.push(await this.#recoverSegment(path))
65
+ } catch (error) {
66
+ await markQuarantined(path, this.#directory)
67
+ throw error
68
+ }
69
+ }
70
+ recovered.sort((left, right) => left.firstSequence === undefined ? 1 : right.firstSequence === undefined ? -1 : left.firstSequence < right.firstSequence ? -1 : left.firstSequence > right.firstSequence ? 1 : 0)
71
+ /** @type {bigint | undefined} */
72
+ let expected
73
+ for (const segment of recovered) {
74
+ if (segment.firstSequence !== undefined) {
75
+ expected ??= segment.firstSequence
76
+ if (segment.firstSequence !== expected) throw new Error("Compressed log writer epoch contains an interior sequence gap")
77
+ expected = segment.lastSequence + 1n
78
+ this.#lastDurableBatch = segment.lastBatch
79
+ }
80
+ }
81
+ this.#durableHighWater = expected === undefined ? 0n : expected - 1n
82
+ const active = recovered.filter((segment) => !segment.sealed)
83
+ if (active.length > 1) throw new Error("Compressed log writer epoch has multiple active segments")
84
+ await this.#enforceRetention()
85
+ if (active.length === 1) await this.#openRecoveredActive(/** @type {typeof recovered[number]} */ (active[0]))
86
+ else await this.#createSegment()
87
+ return {...this.status(), orphanRecoveredCount: recovered.filter((segment) => segment.orphanRecovered).length}
88
+ }
89
+
90
+ /**
91
+ * Writes and durably indexes one immutable producer-retained batch.
92
+ * @param message
93
+ */
94
+ async appendBatch(/** @type {{batchId: string, firstSequence: string, lastSequence: string, jsonl: Uint8Array}} */ message) {
95
+ const batchId = Buffer.from(message.batchId, "base64url")
96
+ const raw = Buffer.from(message.jsonl)
97
+ if (batchId.length !== 16 || batchId.toString("base64url") !== message.batchId) throw new Error("Invalid compressed log batch ID")
98
+ const inspected = inspectLogBatch(raw, this.#writerEpoch)
99
+ if (inspected.firstSequence.toString() !== message.firstSequence || inspected.lastSequence.toString() !== message.lastSequence || !inspected.batchId.equals(batchId)) throw new Error("Compressed log batch metadata mismatch")
100
+ const existing = this.#lastDurableBatch
101
+ if (inspected.lastSequence <= this.#durableHighWater) {
102
+ if (existing?.batchId !== message.batchId || existing.firstSequence !== inspected.firstSequence || existing.lastSequence !== inspected.lastSequence) throw new Error("Compressed log batch is stale or conflicts with durable high-water")
103
+ return {duplicate: true, ...this.status()}
104
+ }
105
+ if (existing?.batchId === message.batchId) throw new Error("Compressed log batch identity conflict")
106
+ if (inspected.firstSequence !== this.#durableHighWater + 1n) throw new Error("Compressed log batch sequence is not contiguous")
107
+ const frame = await compressLogFrame(raw, this.#compressionLevel)
108
+ if (this.#dataBytes > 0n && this.#dataBytes + BigInt(frame.length) > BigInt(this.#maxSegmentBytes)) {
109
+ await this.#sealActive()
110
+ await this.#enforceRetention()
111
+ await this.#createSegment()
112
+ }
113
+ if (frame.length > this.#maxSegmentBytes) throw new Error("Compressed log frame exceeds segment byte limit")
114
+ const dataHandle = required(this.#dataHandle, "data handle")
115
+ const indexHandle = required(this.#indexHandle, "index handle")
116
+ const segmentUuid = required(this.#segmentUuid, "segment UUID")
117
+ const dataOffset = this.#dataBytes
118
+ await writeAll(dataHandle, frame, Number(dataOffset))
119
+ await dataHandle.datasync()
120
+ const entry = createIndexEntry({
121
+ segmentUuid,
122
+ writerEpoch: this.#writerEpoch,
123
+ batchId,
124
+ firstSequence: inspected.firstSequence,
125
+ lastSequence: inspected.lastSequence,
126
+ dataOffset,
127
+ compressedLength: BigInt(frame.length),
128
+ uncompressedLength: BigInt(raw.length),
129
+ recordCount: inspected.recordCount,
130
+ minTimestamp: inspected.minTimestamp,
131
+ maxTimestamp: inspected.maxTimestamp,
132
+ frameSha256: createHash("sha256").update(frame).digest()
133
+ })
134
+ const indexStats = await indexHandle.stat()
135
+ await writeAll(indexHandle, entry, indexStats.size)
136
+ await indexHandle.datasync()
137
+ this.#dataBytes += BigInt(frame.length)
138
+ this.#durableHighWater = inspected.lastSequence
139
+ this.#lastDurableBatch = {batchId: message.batchId, firstSequence: inspected.firstSequence, lastSequence: inspected.lastSequence}
140
+ return {duplicate: false, ...this.status()}
141
+ }
142
+
143
+ status() {
144
+ return {
145
+ durableHighWater: this.#durableHighWater.toString(),
146
+ lastDurableBatch: this.#lastDurableBatch === undefined ? undefined : {
147
+ batchId: this.#lastDurableBatch.batchId,
148
+ firstSequence: this.#lastDurableBatch.firstSequence.toString(),
149
+ lastSequence: this.#lastDurableBatch.lastSequence.toString()
150
+ },
151
+ segmentBytes: Number(this.#dataBytes),
152
+ segmentPath: this.#dataPath
153
+ }
154
+ }
155
+
156
+ /** Syncs, marks the active segment closed, and releases descriptors. */
157
+ async close() {
158
+ if (this.#dataHandle === undefined) return this.status()
159
+ await this.#sealActive()
160
+ await this.#enforceRetention()
161
+ return this.status()
162
+ }
163
+
164
+ /** @param {string} path - Candidate data segment. */
165
+ async #recoverSegment(path) {
166
+ const name = path.slice(path.lastIndexOf("/") + 1)
167
+ const uuidText = name.slice(`${this.#role}-${this.#writerEpochText}-`.length, -".jsonl.zst".length)
168
+ const segmentUuid = uuidBytes(uuidText)
169
+ const indexPath = `${path}.idx`
170
+ await Promise.all([assertPrivateFile(path), assertPrivateFile(indexPath)])
171
+ const sealed = await exists(`${path}.sealed`)
172
+ const [dataHandle, indexHandle] = await Promise.all([
173
+ open(path, constants.O_RDWR | constants.O_NOFOLLOW),
174
+ open(indexPath, constants.O_RDWR | constants.O_NOFOLLOW)
175
+ ])
176
+ let orphanRecovered = false
177
+ try {
178
+ const indexStats = await indexHandle.stat()
179
+ if (indexStats.size < INDEX_HEADER_BYTES) throw new Error("Compressed log index header is torn")
180
+ const completeLength = INDEX_HEADER_BYTES + Math.floor((indexStats.size - INDEX_HEADER_BYTES) / INDEX_ENTRY_BYTES) * INDEX_ENTRY_BYTES
181
+ if (completeLength !== indexStats.size) {
182
+ if (sealed) throw new Error("Sealed compressed log index tail is torn")
183
+ await indexHandle.truncate(completeLength)
184
+ await indexHandle.datasync()
185
+ }
186
+ const headerBytes = Buffer.alloc(INDEX_HEADER_BYTES)
187
+ await readAll(indexHandle, headerBytes, 0)
188
+ const header = parseIndexHeader(headerBytes)
189
+ if (!header.segmentUuid.equals(segmentUuid) || !header.writerEpoch.equals(this.#writerEpoch)) throw new Error("Compressed log segment identity mismatch")
190
+ let firstSequence
191
+ let lastSequence = 0n
192
+ let dataEnd = 0n
193
+ const entryCount = (completeLength - INDEX_HEADER_BYTES) / INDEX_ENTRY_BYTES
194
+ for (let index = 0; index < entryCount; index += 1) {
195
+ const bytes = Buffer.alloc(INDEX_ENTRY_BYTES)
196
+ await readAll(indexHandle, bytes, INDEX_HEADER_BYTES + index * INDEX_ENTRY_BYTES)
197
+ const entry = parseIndexEntry(bytes, {segmentUuid, writerEpoch: this.#writerEpoch})
198
+ firstSequence ??= entry.firstSequence
199
+ lastSequence = entry.lastSequence
200
+ dataEnd = entry.dataOffset + entry.compressedLength
201
+ }
202
+ const dataStats = await dataHandle.stat()
203
+ if (BigInt(dataStats.size) < dataEnd) throw new Error("Compressed log data is shorter than its durable index")
204
+ if (BigInt(dataStats.size) > dataEnd) {
205
+ if (sealed) throw new Error("Sealed compressed log contains unindexed data")
206
+ const tailLength = dataStats.size - Number(dataEnd)
207
+ const tail = Buffer.alloc(tailLength)
208
+ await readAll(dataHandle, tail, Number(dataEnd))
209
+ try {
210
+ const raw = await decompressLogFrame(tail)
211
+ const inspected = inspectLogBatch(raw, this.#writerEpoch)
212
+ if (inspected.firstSequence !== lastSequence + 1n) throw new Error("orphan sequence gap")
213
+ const entry = createIndexEntry({
214
+ segmentUuid,
215
+ writerEpoch: this.#writerEpoch,
216
+ batchId: inspected.batchId,
217
+ firstSequence: inspected.firstSequence,
218
+ lastSequence: inspected.lastSequence,
219
+ dataOffset: dataEnd,
220
+ compressedLength: BigInt(tail.length),
221
+ uncompressedLength: BigInt(raw.length),
222
+ recordCount: inspected.recordCount,
223
+ minTimestamp: inspected.minTimestamp,
224
+ maxTimestamp: inspected.maxTimestamp,
225
+ frameSha256: createHash("sha256").update(tail).digest()
226
+ })
227
+ await dataHandle.datasync()
228
+ await writeAll(indexHandle, entry, completeLength)
229
+ await indexHandle.datasync()
230
+ firstSequence ??= inspected.firstSequence
231
+ lastSequence = inspected.lastSequence
232
+ dataEnd += BigInt(tail.length)
233
+ orphanRecovered = true
234
+ } catch {
235
+ await dataHandle.truncate(Number(dataEnd))
236
+ await dataHandle.datasync()
237
+ }
238
+ }
239
+ } finally {
240
+ await Promise.allSettled([dataHandle.close(), indexHandle.close()])
241
+ }
242
+ await readCompressedLogSegment(path)
243
+ const metadata = await readSegmentMetadata(indexPath, segmentUuid, this.#writerEpoch)
244
+ return {path, sealed, segmentUuid, orphanRecovered, ...metadata}
245
+ }
246
+
247
+ /** @param {{path: string, segmentUuid: Buffer, dataBytes: bigint}} segment - Recovered active segment. */
248
+ async #openRecoveredActive(segment) {
249
+ this.#dataPath = segment.path
250
+ this.#indexPath = `${segment.path}.idx`
251
+ this.#segmentUuid = segment.segmentUuid
252
+ this.#dataBytes = segment.dataBytes
253
+ this.#dataHandle = await open(segment.path, constants.O_RDWR | constants.O_NOFOLLOW)
254
+ this.#indexHandle = await open(this.#indexPath, constants.O_RDWR | constants.O_NOFOLLOW)
255
+ }
256
+
257
+ async #createSegment() {
258
+ const segmentUuidText = randomUUID()
259
+ const segmentUuid = uuidBytes(segmentUuidText)
260
+ const name = `${this.#role}-${this.#writerEpochText}-${segmentUuidText}.jsonl.zst`
261
+ const dataPath = join(this.#directory, name)
262
+ const indexPath = `${dataPath}.idx`
263
+ const dataHandle = await open(dataPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
264
+ let indexHandle
265
+ try {
266
+ indexHandle = await open(indexPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
267
+ await writeAll(indexHandle, createIndexHeader({segmentUuid, writerEpoch: this.#writerEpoch}), 0)
268
+ await indexHandle.datasync()
269
+ await syncDirectory(this.#directory)
270
+ } catch (error) {
271
+ await dataHandle.close().catch(() => {})
272
+ await indexHandle?.close().catch(() => {})
273
+ await Promise.allSettled([unlink(dataPath), unlink(indexPath)])
274
+ throw error
275
+ }
276
+ this.#dataPath = dataPath
277
+ this.#indexPath = indexPath
278
+ this.#segmentUuid = segmentUuid
279
+ this.#dataBytes = 0n
280
+ this.#dataHandle = dataHandle
281
+ this.#indexHandle = indexHandle
282
+ }
283
+
284
+ async #sealActive() {
285
+ const dataHandle = required(this.#dataHandle, "data handle")
286
+ const indexHandle = required(this.#indexHandle, "index handle")
287
+ const dataPath = required(this.#dataPath, "data path")
288
+ await dataHandle.datasync()
289
+ await indexHandle.datasync()
290
+ await Promise.all([dataHandle.close(), indexHandle.close()])
291
+ const marker = await open(`${dataPath}.sealed`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
292
+ await marker.sync()
293
+ await marker.close()
294
+ await syncDirectory(this.#directory)
295
+ this.#dataHandle = undefined
296
+ this.#indexHandle = undefined
297
+ this.#dataPath = undefined
298
+ this.#indexPath = undefined
299
+ this.#segmentUuid = undefined
300
+ this.#dataBytes = 0n
301
+ }
302
+
303
+ async #enforceRetention() {
304
+ await enforceLogRetention({directory: this.#directory, maxAgeMs: this.#retentionAgeMs, maxBytes: this.#retentionBytes})
305
+ }
306
+ }
307
+
308
+ /**
309
+ * @param {string} indexPath - Index file. @param {Buffer} segmentUuid - Identity. @param {Buffer} writerEpoch - Identity.
310
+ * @param segmentUuid
311
+ * @param writerEpoch
312
+ */
313
+ async function readSegmentMetadata(indexPath, segmentUuid, writerEpoch) {
314
+ const handle = await open(indexPath, constants.O_RDONLY | constants.O_NOFOLLOW)
315
+ try {
316
+ const stats = await handle.stat()
317
+ const count = (stats.size - INDEX_HEADER_BYTES) / INDEX_ENTRY_BYTES
318
+ let firstSequence
319
+ let lastSequence = 0n
320
+ /** @type {{batchId: string, firstSequence: bigint, lastSequence: bigint} | undefined} */
321
+ let lastBatch
322
+ let dataBytes = 0n
323
+ for (let index = 0; index < count; index += 1) {
324
+ const bytes = Buffer.alloc(INDEX_ENTRY_BYTES)
325
+ await readAll(handle, bytes, INDEX_HEADER_BYTES + index * INDEX_ENTRY_BYTES)
326
+ const entry = parseIndexEntry(bytes, {segmentUuid, writerEpoch})
327
+ firstSequence ??= entry.firstSequence
328
+ lastSequence = entry.lastSequence
329
+ dataBytes = entry.dataOffset + entry.compressedLength
330
+ lastBatch = {batchId: entry.batchId.toString("base64url"), firstSequence: entry.firstSequence, lastSequence: entry.lastSequence}
331
+ }
332
+ return {firstSequence, lastSequence, lastBatch, dataBytes}
333
+ } finally { await handle.close() }
334
+ }
335
+
336
+ /**
337
+ * Short-write-safe positional writer.
338
+ * @param handle
339
+ * @param bytes
340
+ * @param position
341
+ */
342
+ export async function writeAll(/** @type {import('node:fs/promises').FileHandle} */ handle, /** @type {Buffer} */ bytes, /** @type {number} */ position) {
343
+ let offset = 0
344
+ while (offset < bytes.length) {
345
+ const {bytesWritten} = await handle.write(bytes, offset, bytes.length - offset, position + offset)
346
+ if (bytesWritten === 0) throw new Error("Compressed log write made no progress")
347
+ offset += bytesWritten
348
+ }
349
+ }
350
+
351
+ /**
352
+ *
353
+ * @param handle
354
+ * @param bytes
355
+ * @param position
356
+ */
357
+ async function readAll(/** @type {import('node:fs/promises').FileHandle} */ handle, /** @type {Buffer} */ bytes, /** @type {number} */ position) {
358
+ let offset = 0
359
+ while (offset < bytes.length) {
360
+ const {bytesRead} = await handle.read(bytes, offset, bytes.length - offset, position + offset)
361
+ if (bytesRead === 0) throw new Error("Compressed log read made no progress")
362
+ offset += bytesRead
363
+ }
364
+ }
365
+
366
+ /**
367
+ *
368
+ * @param directory
369
+ */
370
+ async function syncDirectory(/** @type {string} */ directory) {
371
+ const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
372
+ try { await handle.sync() } finally { await handle.close() }
373
+ }
374
+
375
+ /**
376
+ *
377
+ * @param path
378
+ */
379
+ async function assertPrivateFile(/** @type {string} */ path) {
380
+ const stats = await lstat(path)
381
+ if (!stats.isFile() || (stats.mode & 0o777) !== 0o600) throw new Error("Unsafe compressed log file")
382
+ }
383
+
384
+ /**
385
+ * @param {string} dataPath - Invalid segment path. @param {string} directory - Private log directory.
386
+ * @param directory
387
+ */
388
+ async function markQuarantined(dataPath, directory) {
389
+ const path = `${dataPath}.quarantine`
390
+ if (await exists(path)) return
391
+ const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
392
+ try {
393
+ await writeAll(handle, Buffer.from("LOG_EVIDENCE_INVALID\n"), 0)
394
+ await handle.sync()
395
+ } finally { await handle.close() }
396
+ await syncDirectory(directory)
397
+ }
398
+
399
+ /**
400
+ *
401
+ * @param path
402
+ */
403
+ async function exists(/** @type {string} */ path) {
404
+ try { await stat(path); return true } catch (error) { if (error !== null && typeof error === "object" && /** @type {{code?: unknown}} */ (error).code === "ENOENT") return false; throw error }
405
+ }
406
+
407
+ /**
408
+ * @param value
409
+ * @param name
410
+ * @template T @param {T | undefined} value - Required state. @param {string} name - State label. @returns {T}
411
+ */
412
+ function required(value, name) {
413
+ if (value === undefined) throw new Error(`Compressed log ${name} is unavailable`)
414
+ return value
415
+ }
416
+
417
+ if (!isMainThread) {
418
+ const port = /** @type {NonNullable<typeof parentPort>} */ (parentPort)
419
+ const sink = new CompressedLogWorkerSink(/** @type {ConstructorParameters<typeof CompressedLogWorkerSink>[0]} */ (workerData))
420
+ let queue = sink.start().then((status) => port.postMessage({type: "ready", ...status}))
421
+ port.on("message", (message) => {
422
+ queue = queue.then(async () => {
423
+ if (message?.type === "batch") port.postMessage({type: "ack", batchId: message.batchId, ...await sink.appendBatch(message)})
424
+ else if (message?.type === "close") { port.postMessage({type: "closed", ...await sink.close()}); port.close() } else throw new Error("Unsupported compressed log worker message")
425
+ }).catch((error) => {
426
+ port.postMessage({type: "failed", code: "LOG_WORKER_FAILED"})
427
+ setImmediate(() => { throw error })
428
+ })
429
+ })
430
+ }