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,538 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns, jsdoc/require-returns-description */
2
+ import {randomBytes} from "node:crypto"
3
+ import {Worker} from "node:worker_threads"
4
+
5
+ import {formatDiagnosticLogLine} from "./structured-logger.js"
6
+ import {createLogBatchId} from "./log-batch.js"
7
+ import {DEFAULT_LOG_COMPRESSION_LEVEL} from "./zstd-frame-codec.js"
8
+
9
+ export {DEFAULT_LOG_COMPRESSION_LEVEL} from "./zstd-frame-codec.js"
10
+
11
+ export const DEFAULT_LOG_FRAME_BYTES = 1024 * 1024
12
+ export const DEFAULT_LOG_FLUSH_INTERVAL_MS = 1000
13
+ export const DEFAULT_LOG_CRITICAL_FLUSH_INTERVAL_MS = 250
14
+ export const DEFAULT_LOG_SEGMENT_BYTES = 128 * 1024 * 1024
15
+ export const DEFAULT_LOG_QUEUE_BYTES = 64 * 1024 * 1024
16
+ export const DEFAULT_LOG_CRITICAL_RESERVE_BYTES = 8 * 1024 * 1024
17
+ export const DEFAULT_LOG_RETENTION_DAYS = 30
18
+ export const DEFAULT_LOG_RETENTION_BYTES = 4 * 1024 * 1024 * 1024
19
+
20
+ const ROLE = /^[a-z][a-z0-9-]{0,63}$/
21
+ const REPLACEABLE_EVENTS = new Set(["runtime.status", "transport_set.member_state"])
22
+ const NONCRITICAL_EVENTS = new Set(["runtime.status", "transport.connecting", "transport.connected", "transport.reconnect_scheduled", "transport.reconnect_succeeded", "transport_set.member_state"])
23
+
24
+ /** @typedef {{event: string, key: string, sequence: bigint, buffer: Buffer, charge: number, internal: boolean}} PendingRecord */
25
+ /** @typedef {{writerEpoch: string, acceptedHighWater: string, durableHighWater: string, queuedBytes: number, inFlightBytes: number, allocatedBytes: number, workerRestarts: number, workerState: string, health: string, state: string, criticalDrops: number, noncriticalDrops: number, coalesced: number}} LoggerStatus */
26
+
27
+ /**
28
+ * @typedef {object} CompressedJsonlFileLoggerOptions
29
+ * @property {string} directory - Private compressed-log directory.
30
+ * @property {string} role - Stable component role.
31
+ * @property {number} [processId] - Process identity for the JSON envelope.
32
+ * @property {() => Date} [now] - Clock provider.
33
+ * @property {number} [frameMaxBytes] - Maximum uncompressed JSONL frame batch.
34
+ * @property {number} [flushIntervalMs] - Ordinary maximum batch latency.
35
+ * @property {number} [criticalFlushIntervalMs] - Critical maximum batch latency.
36
+ * @property {number} [maxSegmentBytes] - Maximum compressed segment size.
37
+ * @property {number} [maxQueueBytes] - Shared producer/worker allocation bound.
38
+ * @property {number} [criticalReserveBytes] - Queue bytes reserved for critical events.
39
+ * @property {number} [compressionLevel] - Explicit zstd level, default 19.
40
+ * @property {number} [retentionDays] - Closed-segment maximum age, default 30 days.
41
+ * @property {number} [retentionBytes] - Directory quota, default 4 GiB.
42
+ * @property {{write(chunk: string): unknown}} [stderr] - Secret-free health reporting destination.
43
+ */
44
+
45
+ /** Bounded asynchronous producer for durable, independently indexed zstd frames. */
46
+ export class CompressedJsonlFileLogger {
47
+ /** @type {CompressedJsonlFileLoggerOptions} */ #options
48
+ /** @type {Buffer} */ #writerEpoch = randomBytes(16)
49
+ /** @type {string} */ #writerEpochText
50
+ /** @type {bigint} */ #acceptedHighWater = 0n
51
+ /** @type {bigint} */ #durableHighWater = 0n
52
+ /** @type {PendingRecord[]} */ #pending = []
53
+ /** @type {{batchId: string, firstSequence: bigint, lastSequence: bigint, jsonl: Buffer, charge: number, lifecycleSource: boolean} | undefined} */ #inFlight
54
+ /** @type {number} */ #pendingBytes = 0
55
+ /** @type {number} */ #pendingRawBytes = 0
56
+ /** @type {number} */ #inFlightBytes = 0
57
+ /** @type {number} */ #criticalDrops = 0
58
+ /** @type {number} */ #noncriticalDrops = 0
59
+ /** @type {number} */ #coalesced = 0
60
+ /** @type {Worker | undefined} */ #worker
61
+ /** @type {boolean} */ #workerReady = false
62
+ /** @type {number} */ #workerGeneration = 0
63
+ /** @type {number[]} */ #workerRestarts = []
64
+ /** @type {'starting' | 'healthy' | 'degraded' | 'failed' | 'closing' | 'closed'} */ #state = "starting"
65
+ /** @type {ReturnType<typeof setTimeout> | undefined} */ #flushTimer
66
+ /** @type {number | undefined} */ #flushTimerDueAt
67
+ /** @type {boolean} */ #accepting = true
68
+ /** @type {Array<{barrier: bigint, resolve: () => void, reject: (error: Error) => void}>} */ #flushWaiters = []
69
+ /** @type {Error | undefined} */ #failure
70
+ /** @type {Promise<LoggerStatus> | undefined} */ #closePromise
71
+ /** @type {(() => void) | undefined} */ #resolveWorkerClosed
72
+ /** @type {((error: Error) => void) | undefined} */ #rejectWorkerClosed
73
+ /** @type {boolean} */ #failureReported = false
74
+
75
+ /** @param {CompressedJsonlFileLoggerOptions} options - Storage and resource policy. */
76
+ constructor(options) {
77
+ validateOptions(options)
78
+ this.#options = options
79
+ this.#writerEpochText = this.#writerEpoch.toString("base64url")
80
+ this.#spawnWorker(false)
81
+ }
82
+
83
+ /**
84
+ * Sanitizes and queues one record without blocking the caller.
85
+ * @param event
86
+ * @param fields
87
+ * @returns {'accepted' | 'coalesced' | 'dropped'} Queue outcome.
88
+ */
89
+ log(/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) {
90
+ const critical = !NONCRITICAL_EVENTS.has(event)
91
+ if (!this.#accepting || this.#state === "closed" || (this.#state === "failed" && !critical)) {
92
+ this.#recordDrop(critical)
93
+ return "dropped"
94
+ }
95
+ const sequence = this.#acceptedHighWater + 1n
96
+ let line
97
+ try {
98
+ line = formatDiagnosticLogLine({
99
+ role: this.#options.role,
100
+ ...(this.#options.processId === undefined ? {} : {processId: this.#options.processId}),
101
+ ...(this.#options.now === undefined ? {} : {now: this.#options.now}),
102
+ event,
103
+ fields,
104
+ envelope: {writerEpoch: this.#writerEpochText, eventSequence: sequence.toString()}
105
+ })
106
+ } catch {
107
+ this.#recordDrop(critical)
108
+ return "dropped"
109
+ }
110
+ if (line === undefined) {
111
+ this.#recordDrop(critical)
112
+ return "dropped"
113
+ }
114
+ const buffer = Buffer.from(line)
115
+ const charge = conservativeCharge(buffer.length)
116
+ const maxQueueBytes = this.#options.maxQueueBytes ?? DEFAULT_LOG_QUEUE_BYTES
117
+ const normalLimit = maxQueueBytes - (this.#options.criticalReserveBytes ?? DEFAULT_LOG_CRITICAL_RESERVE_BYTES)
118
+ const limit = critical ? maxQueueBytes : normalLimit
119
+ const allocated = this.#pendingBytes + this.#inFlightBytes
120
+ const key = coalesceKey(event, fields)
121
+ if (allocated + charge > limit && REPLACEABLE_EVENTS.has(event)) {
122
+ const existing = this.#pending.findLast((record) => record.key === key)
123
+ if (existing !== undefined) {
124
+ const replacementLine = formatDiagnosticLogLine({
125
+ role: this.#options.role,
126
+ ...(this.#options.processId === undefined ? {} : {processId: this.#options.processId}),
127
+ ...(this.#options.now === undefined ? {} : {now: this.#options.now}),
128
+ event,
129
+ fields,
130
+ envelope: {writerEpoch: this.#writerEpochText, eventSequence: existing.sequence.toString()}
131
+ })
132
+ if (replacementLine !== undefined) {
133
+ const replacement = Buffer.from(replacementLine)
134
+ const replacementCharge = conservativeCharge(replacement.length)
135
+ if (allocated - existing.charge + replacementCharge <= limit) {
136
+ this.#pendingBytes += replacementCharge - existing.charge
137
+ this.#pendingRawBytes += replacement.length - existing.buffer.length
138
+ existing.buffer = replacement
139
+ existing.charge = replacementCharge
140
+ this.#coalesced += 1
141
+ return "coalesced"
142
+ }
143
+ }
144
+ }
145
+ }
146
+ if (allocated + charge > limit || buffer.length > (this.#options.frameMaxBytes ?? DEFAULT_LOG_FRAME_BYTES)) {
147
+ this.#recordDrop(critical)
148
+ return "dropped"
149
+ }
150
+ this.#acceptedHighWater = sequence
151
+ this.#pending.push({event, key, sequence, buffer, charge, internal: false})
152
+ this.#pendingBytes += charge
153
+ this.#pendingRawBytes += buffer.length
154
+ this.#worker?.ref()
155
+ if (this.#state !== "failed") {
156
+ if (this.#pendingRawBytes >= (this.#options.frameMaxBytes ?? DEFAULT_LOG_FRAME_BYTES)) {
157
+ this.#clearFlushTimer()
158
+ this.#sendNext()
159
+ } else this.#scheduleFlush(critical)
160
+ }
161
+ return "accepted"
162
+ }
163
+
164
+ /** Resolves only after every accepted record through the call is data+index synced. */
165
+ /** @returns {Promise<void>} */
166
+ flush() {
167
+ if (this.#failure !== undefined) return Promise.reject(this.#failure)
168
+ const barrier = this.#acceptedHighWater
169
+ this.#clearFlushTimer()
170
+ this.#sendNext()
171
+ return new Promise((resolve, reject) => {
172
+ this.#flushWaiters.push({barrier, resolve, reject})
173
+ this.#settleFlushWaiters()
174
+ })
175
+ }
176
+
177
+ /** Stops acceptance, flushes, seals, and terminates the worker exactly once. */
178
+ /** @returns {Promise<LoggerStatus>} */
179
+ close() {
180
+ if (this.#closePromise !== undefined) return this.#closePromise
181
+ this.#accepting = false
182
+ this.#state = this.#failure === undefined ? "closing" : "failed"
183
+ this.#closePromise = this.#closeInternal()
184
+ return this.#closePromise
185
+ }
186
+
187
+ /** @returns {LoggerStatus} */
188
+ status() {
189
+ return {
190
+ writerEpoch: this.#writerEpochText,
191
+ acceptedHighWater: this.#acceptedHighWater.toString(),
192
+ durableHighWater: this.#durableHighWater.toString(),
193
+ queuedBytes: this.#pendingBytes,
194
+ inFlightBytes: this.#inFlightBytes,
195
+ allocatedBytes: this.#pendingBytes + this.#inFlightBytes,
196
+ workerRestarts: this.#workerRestarts.length,
197
+ workerState: this.#workerReady ? "ready" : this.#state === "failed" ? "failed" : "starting",
198
+ health: this.#state === "failed" ? "failed" : this.#state === "degraded" ? "degraded" : "healthy",
199
+ state: this.#state,
200
+ criticalDrops: this.#criticalDrops,
201
+ noncriticalDrops: this.#noncriticalDrops,
202
+ coalesced: this.#coalesced
203
+ }
204
+ }
205
+
206
+ /** @returns {Promise<LoggerStatus>} */
207
+ async #closeInternal() {
208
+ try {
209
+ await this.flush()
210
+ while (this.#durableHighWater < this.#acceptedHighWater || this.#pending.length > 0 || this.#inFlight !== undefined) await this.flush()
211
+ this.#enqueueInternal("log_closed", {criticalDrops: this.#criticalDrops, noncriticalDrops: this.#noncriticalDrops, coalescedDrops: this.#coalesced})
212
+ await this.flush()
213
+ const worker = this.#worker
214
+ if (worker !== undefined) {
215
+ /** @type {Promise<void>} */
216
+ const closed = new Promise((resolve, reject) => { this.#resolveWorkerClosed = resolve; this.#rejectWorkerClosed = reject })
217
+ worker.ref()
218
+ worker.postMessage({type: "close"})
219
+ await closed
220
+ }
221
+ this.#state = "closed"
222
+ return this.status()
223
+ } catch (error) {
224
+ this.#state = "failed"
225
+ throw error
226
+ } finally {
227
+ this.#clearFlushTimer()
228
+ }
229
+ }
230
+
231
+ /** @param {boolean} critical - Whether the oldest pending event is critical. */
232
+ #scheduleFlush(critical) {
233
+ const delay = critical ? this.#options.criticalFlushIntervalMs ?? DEFAULT_LOG_CRITICAL_FLUSH_INTERVAL_MS : this.#options.flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS
234
+ const dueAt = Date.now() + delay
235
+ if (this.#flushTimer !== undefined) {
236
+ if (this.#flushTimerDueAt !== undefined && this.#flushTimerDueAt <= dueAt) return
237
+ clearTimeout(this.#flushTimer)
238
+ }
239
+ this.#flushTimerDueAt = dueAt
240
+ this.#flushTimer = setTimeout(() => {
241
+ this.#flushTimer = undefined
242
+ this.#flushTimerDueAt = undefined
243
+ this.#sendNext()
244
+ }, Math.max(0, dueAt - Date.now()))
245
+ }
246
+
247
+ #clearFlushTimer() {
248
+ if (this.#flushTimer !== undefined) clearTimeout(this.#flushTimer)
249
+ this.#flushTimer = undefined
250
+ this.#flushTimerDueAt = undefined
251
+ }
252
+
253
+ #sendNext() {
254
+ if (!this.#workerReady || this.#worker === undefined || this.#inFlight !== undefined || this.#pending.length === 0) {
255
+ if (this.#pending.length === 0 && this.#inFlight === undefined && this.#flushWaiters.length === 0) this.#worker?.unref()
256
+ return
257
+ }
258
+ const maxBytes = this.#options.frameMaxBytes ?? DEFAULT_LOG_FRAME_BYTES
259
+ /** @type {PendingRecord[]} */
260
+ const selected = []
261
+ let rawBytes = 0
262
+ while (this.#pending.length > 0) {
263
+ const next = /** @type {PendingRecord} */ (this.#pending[0])
264
+ if (selected.length > 0 && rawBytes + next.buffer.length > maxBytes) break
265
+ selected.push(/** @type {PendingRecord} */ (this.#pending.shift()))
266
+ rawBytes += next.buffer.length
267
+ }
268
+ const jsonl = Buffer.concat(selected.map((record) => record.buffer), rawBytes)
269
+ const firstSequence = /** @type {PendingRecord} */ (selected[0]).sequence
270
+ const lastSequence = /** @type {PendingRecord} */ (selected.at(-1)).sequence
271
+ const charge = selected.reduce((total, record) => total + record.charge, 0)
272
+ const lifecycleSource = selected.some((record) => !record.internal)
273
+ this.#pendingBytes -= charge
274
+ this.#pendingRawBytes -= rawBytes
275
+ this.#inFlightBytes = charge
276
+ this.#inFlight = {
277
+ batchId: createLogBatchId(this.#writerEpoch, firstSequence, lastSequence, jsonl).toString("base64url"),
278
+ firstSequence,
279
+ lastSequence,
280
+ jsonl,
281
+ charge,
282
+ lifecycleSource
283
+ }
284
+ if (lifecycleSource) this.#enqueueInternal("log_batch_accepted", {queuedBytes: this.#pendingBytes + this.#inFlightBytes})
285
+ this.#postInFlight()
286
+ }
287
+
288
+ #postInFlight() {
289
+ const batch = this.#inFlight
290
+ if (batch === undefined || this.#worker === undefined || !this.#workerReady) return
291
+ this.#worker.postMessage({
292
+ type: "batch",
293
+ batchId: batch.batchId,
294
+ firstSequence: batch.firstSequence.toString(),
295
+ lastSequence: batch.lastSequence.toString(),
296
+ jsonl: batch.jsonl
297
+ })
298
+ }
299
+
300
+ /** @param {boolean} restart - Whether this worker replaces a failed generation. */
301
+ #spawnWorker(restart) {
302
+ const generation = ++this.#workerGeneration
303
+ this.#workerReady = false
304
+ const worker = new Worker(new URL("./compressed-log-worker.js", import.meta.url), {
305
+ workerData: {
306
+ directory: this.#options.directory,
307
+ role: this.#options.role,
308
+ writerEpoch: this.#writerEpochText,
309
+ maxSegmentBytes: this.#options.maxSegmentBytes ?? DEFAULT_LOG_SEGMENT_BYTES,
310
+ compressionLevel: this.#options.compressionLevel ?? DEFAULT_LOG_COMPRESSION_LEVEL,
311
+ retentionAgeMs: (this.#options.retentionDays ?? DEFAULT_LOG_RETENTION_DAYS) * 24 * 60 * 60 * 1000,
312
+ retentionBytes: this.#options.retentionBytes ?? DEFAULT_LOG_RETENTION_BYTES
313
+ }
314
+ })
315
+ this.#worker = worker
316
+ if (this.#pending.length === 0 && this.#inFlight === undefined && this.#flushWaiters.length === 0) worker.unref()
317
+ let failureHandled = false
318
+ const fail = (/** @type {unknown} */ error) => {
319
+ if (failureHandled || generation !== this.#workerGeneration) return
320
+ failureHandled = true
321
+ const failure = error instanceof Error ? error : new Error("Compressed log worker failed")
322
+ void worker.terminate().then(
323
+ () => this.#handleWorkerFailure(failure, generation),
324
+ (terminationError) => this.#handleWorkerFailure(terminationError instanceof Error ? terminationError : failure, generation)
325
+ )
326
+ }
327
+ worker.on("message", (message) => {
328
+ if (generation !== this.#workerGeneration) return
329
+ if (message?.type === "ready") {
330
+ this.#workerReady = true
331
+ this.#state = "healthy"
332
+ const recoveredHighWater = parseSequence(message.durableHighWater)
333
+ if (recoveredHighWater > this.#acceptedHighWater) { fail(new Error("Compressed log worker recovered beyond producer high-water")); return }
334
+ if (this.#inFlight !== undefined && recoveredHighWater === this.#inFlight.lastSequence) {
335
+ if (!durableBatchMatches(message.lastDurableBatch, this.#inFlight)) { fail(new Error("Compressed log worker recovered a conflicting batch identity")); return }
336
+ } else if (recoveredHighWater !== this.#durableHighWater) { fail(new Error("Compressed log worker recovered an unexpected durable high-water")); return }
337
+ this.#durableHighWater = recoveredHighWater
338
+ if (this.#inFlight !== undefined && this.#inFlight.lastSequence <= recoveredHighWater) this.#releaseInFlight(this.#inFlight.batchId)
339
+ else this.#postInFlight()
340
+ if (Number.isSafeInteger(message.orphanRecoveredCount) && message.orphanRecoveredCount > 0) this.#enqueueInternal("log_orphan_recovered", {})
341
+ if (restart) {
342
+ this.#enqueueInternal("log_worker_restarted", {attempt: this.#workerRestarts.length})
343
+ this.#enqueueInternal("log_sink_recovered", {attempt: this.#workerRestarts.length})
344
+ }
345
+ this.#settleFlushWaiters()
346
+ if (this.#inFlight === undefined) this.#sendNext()
347
+ return
348
+ }
349
+ if (message?.type === "ack") {
350
+ if (!this.#workerReady || this.#inFlight === undefined || message.batchId !== this.#inFlight.batchId) { fail(new Error("Compressed log worker acknowledged an unknown batch")); return }
351
+ const highWater = parseSequence(message.durableHighWater)
352
+ if (highWater !== this.#inFlight.lastSequence) { fail(new Error("Compressed log worker durable high-water mismatch")); return }
353
+ if (!durableBatchMatches(message.lastDurableBatch, this.#inFlight)) { fail(new Error("Compressed log worker durable batch identity mismatch")); return }
354
+ const lifecycleSource = this.#inFlight.lifecycleSource
355
+ this.#durableHighWater = highWater
356
+ this.#releaseInFlight(message.batchId)
357
+ if (lifecycleSource) this.#enqueueInternal("log_frame_committed", {queuedBytes: this.#pendingBytes})
358
+ this.#settleFlushWaiters()
359
+ if (this.#pending.length > 0) this.#sendNext()
360
+ else if (this.#flushWaiters.length === 0) worker.unref()
361
+ return
362
+ }
363
+ if (message?.type === "closed") {
364
+ this.#workerReady = false
365
+ this.#resolveWorkerClosed?.()
366
+ this.#resolveWorkerClosed = undefined
367
+ this.#rejectWorkerClosed = undefined
368
+ return
369
+ }
370
+ if (message?.type === "failed") fail(new Error("Compressed log worker reported durable sink failure"))
371
+ })
372
+ worker.once("error", fail)
373
+ worker.once("exit", (code) => {
374
+ if (this.#state === "closing" && this.#resolveWorkerClosed === undefined && code === 0) return
375
+ if (this.#state === "closed") return
376
+ if (!failureHandled && code !== 0) fail(new Error(`Compressed log worker exited with code ${code}`))
377
+ })
378
+ }
379
+
380
+ /** @param {string} batchId - Acknowledged durable batch. */
381
+ #releaseInFlight(batchId) {
382
+ if (this.#inFlight?.batchId !== batchId) return
383
+ this.#inFlight = undefined
384
+ this.#inFlightBytes = 0
385
+ }
386
+
387
+ /**
388
+ * @param {Error} error - Worker failure. @param {number} generation - Worker generation.
389
+ * @param generation
390
+ */
391
+ #handleWorkerFailure(error, generation) {
392
+ if (generation !== this.#workerGeneration || this.#state === "failed" || this.#state === "closed") return
393
+ this.#workerReady = false
394
+ this.#worker = undefined
395
+ this.#rejectWorkerClosed?.(error)
396
+ this.#resolveWorkerClosed = undefined
397
+ this.#rejectWorkerClosed = undefined
398
+ const now = Date.now()
399
+ this.#workerRestarts = this.#workerRestarts.filter((time) => now - time <= 60_000)
400
+ if (!this.#accepting || this.#workerRestarts.length >= 3) {
401
+ this.#fail(error)
402
+ return
403
+ }
404
+ this.#workerRestarts.push(now)
405
+ this.#state = "degraded"
406
+ this.#spawnWorker(true)
407
+ }
408
+
409
+ #settleFlushWaiters() {
410
+ if (this.#failure !== undefined) {
411
+ for (const waiter of this.#flushWaiters.splice(0)) waiter.reject(this.#failure)
412
+ return
413
+ }
414
+ if (!this.#workerReady) return
415
+ const remaining = []
416
+ for (const waiter of this.#flushWaiters) {
417
+ if (this.#durableHighWater >= waiter.barrier) waiter.resolve()
418
+ else remaining.push(waiter)
419
+ }
420
+ this.#flushWaiters = remaining
421
+ }
422
+
423
+ /** @param {Error} error - Terminal sink failure. */
424
+ #fail(error) {
425
+ this.#failure = error
426
+ this.#state = "failed"
427
+ this.#clearFlushTimer()
428
+ this.#settleFlushWaiters()
429
+ if (!this.#failureReported) {
430
+ this.#failureReported = true
431
+ try { (this.#options.stderr ?? process.stderr).write("Socketduct logger failure (LOG_COMPRESSED_SINK_FAILED)\n") } catch { /* recursive logger failure is intentionally suppressed */ }
432
+ }
433
+ }
434
+
435
+ /** @param {boolean} critical - Event class. */
436
+ #recordDrop(critical) {
437
+ if (critical) this.#criticalDrops += 1
438
+ else this.#noncriticalDrops += 1
439
+ }
440
+
441
+ /**
442
+ * Queues a bounded sink-lifecycle record without recursively producing more sink events.
443
+ * @param {string} event - Internal event name.
444
+ * @param {Record<string, unknown>} fields - Safe counters.
445
+ */
446
+ #enqueueInternal(event, fields) {
447
+ if (this.#state === "failed" || this.#state === "closed") return false
448
+ const sequence = this.#acceptedHighWater + 1n
449
+ const line = formatDiagnosticLogLine({
450
+ role: this.#options.role,
451
+ ...(this.#options.processId === undefined ? {} : {processId: this.#options.processId}),
452
+ ...(this.#options.now === undefined ? {} : {now: this.#options.now}),
453
+ event,
454
+ fields,
455
+ envelope: {writerEpoch: this.#writerEpochText, eventSequence: sequence.toString()}
456
+ })
457
+ if (line === undefined) return false
458
+ const buffer = Buffer.from(line)
459
+ const charge = conservativeCharge(buffer.length)
460
+ const maxQueueBytes = this.#options.maxQueueBytes ?? DEFAULT_LOG_QUEUE_BYTES
461
+ if (this.#pendingBytes + this.#inFlightBytes + charge > maxQueueBytes) {
462
+ this.#criticalDrops += 1
463
+ return false
464
+ }
465
+ this.#acceptedHighWater = sequence
466
+ this.#pending.push({event, key: event, sequence, buffer, charge, internal: true})
467
+ this.#pendingBytes += charge
468
+ this.#pendingRawBytes += buffer.length
469
+ this.#worker?.ref()
470
+ return true
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Creates the managed compressed diagnostic sink.
476
+ * @param options
477
+ */
478
+ export function createCompressedJsonlFileLogger(/** @type {CompressedJsonlFileLoggerOptions} */ options) {
479
+ return new CompressedJsonlFileLogger(options)
480
+ }
481
+
482
+ /** @param {CompressedJsonlFileLoggerOptions} options - Candidate options. */
483
+ function validateOptions(options) {
484
+ if (typeof options !== "object" || options === null || typeof options.directory !== "string" || options.directory.trim().length === 0) throw new TypeError("directory must be a non-empty string")
485
+ if (typeof options.role !== "string" || !ROLE.test(options.role)) throw new TypeError("role must be 1-64 lowercase letters, digits, or hyphens")
486
+ boundedInteger(options.processId ?? process.pid, "processId", 0, Number.MAX_SAFE_INTEGER)
487
+ boundedInteger(options.frameMaxBytes ?? DEFAULT_LOG_FRAME_BYTES, "frameMaxBytes", 128, 16 * 1024 * 1024)
488
+ boundedInteger(options.flushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS, "flushIntervalMs", 1, 60_000)
489
+ boundedInteger(options.criticalFlushIntervalMs ?? DEFAULT_LOG_CRITICAL_FLUSH_INTERVAL_MS, "criticalFlushIntervalMs", 1, 60_000)
490
+ boundedInteger(options.maxSegmentBytes ?? DEFAULT_LOG_SEGMENT_BYTES, "maxSegmentBytes", 1024, 4 * 1024 * 1024 * 1024)
491
+ boundedInteger(options.retentionDays ?? DEFAULT_LOG_RETENTION_DAYS, "retentionDays", 1, 3650)
492
+ const retentionBytes = boundedInteger(options.retentionBytes ?? DEFAULT_LOG_RETENTION_BYTES, "retentionBytes", 1024, 1024 * 1024 * 1024 * 1024)
493
+ const maxQueueBytes = boundedInteger(options.maxQueueBytes ?? DEFAULT_LOG_QUEUE_BYTES, "maxQueueBytes", 1024, 1024 * 1024 * 1024)
494
+ const criticalReserveBytes = boundedInteger(options.criticalReserveBytes ?? DEFAULT_LOG_CRITICAL_RESERVE_BYTES, "criticalReserveBytes", 0, maxQueueBytes - 1)
495
+ if (maxQueueBytes - criticalReserveBytes < 512) throw new RangeError("noncritical log queue must retain at least 512 bytes")
496
+ if ((options.maxSegmentBytes ?? DEFAULT_LOG_SEGMENT_BYTES) > retentionBytes) throw new RangeError("maxSegmentBytes must not exceed retentionBytes")
497
+ }
498
+
499
+ /**
500
+ * @param {number} value - Candidate. @param {string} name - Field. @param {number} minimum - Inclusive minimum. @param {number} maximum - Inclusive maximum.
501
+ * @param name
502
+ * @param minimum
503
+ * @param maximum
504
+ */
505
+ function boundedInteger(value, name, minimum, maximum) {
506
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new RangeError(`${name} must be an integer from ${minimum} to ${maximum}`)
507
+ return value
508
+ }
509
+
510
+ /** @param {number} bytes - Raw JSONL bytes. */
511
+ function conservativeCharge(bytes) {
512
+ return bytes * 3 + 128
513
+ }
514
+
515
+ /**
516
+ * @param {string} event - Event name. @param {Record<string, unknown>} fields - Safe diagnostic fields.
517
+ * @param fields
518
+ */
519
+ function coalesceKey(event, fields) {
520
+ const member = typeof fields.gatewayId === "string" ? fields.gatewayId : typeof fields.routeClass === "string" ? fields.routeClass : ""
521
+ return `${event}\0${member}`
522
+ }
523
+
524
+ /** @param {unknown} value - Serialized sequence. */
525
+ function parseSequence(value) {
526
+ if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/.test(value)) throw new Error("Invalid compressed log durable high-water")
527
+ return BigInt(value)
528
+ }
529
+
530
+ /**
531
+ * @param {unknown} value - Worker-reported last durable batch.
532
+ * @param {{batchId: string, firstSequence: bigint, lastSequence: bigint}} batch - Producer batch.
533
+ */
534
+ function durableBatchMatches(value, batch) {
535
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false
536
+ const candidate = /** @type {Record<string, unknown>} */ (value)
537
+ return candidate.batchId === batch.batchId && candidate.firstSequence === batch.firstSequence.toString() && candidate.lastSequence === batch.lastSequence.toString()
538
+ }
@@ -0,0 +1,72 @@
1
+ /* eslint-disable jsdoc/require-param-type, jsdoc/require-returns */
2
+ import {createHash} from "node:crypto"
3
+
4
+ /**
5
+ * Computes the normative deterministic 128-bit batch identity.
6
+ * @param writerEpoch
7
+ * @param firstSequence
8
+ * @param lastSequence
9
+ * @param jsonl
10
+ */
11
+ export function createLogBatchId(/** @type {Buffer | Uint8Array} */ writerEpoch, /** @type {bigint} */ firstSequence, /** @type {bigint} */ lastSequence, /** @type {Buffer | Uint8Array} */ jsonl) {
12
+ if (!(writerEpoch instanceof Uint8Array) || writerEpoch.byteLength !== 16) throw new TypeError("writerEpoch must contain exactly 16 bytes")
13
+ if (typeof firstSequence !== "bigint" || firstSequence < 1n || typeof lastSequence !== "bigint" || lastSequence < firstSequence) throw new RangeError("batch sequence range is invalid")
14
+ if (!(jsonl instanceof Uint8Array) || jsonl.byteLength === 0 || jsonl[jsonl.byteLength - 1] !== 0x0a) throw new TypeError("batch JSONL must be non-empty and newline terminated")
15
+ const range = Buffer.alloc(16)
16
+ range.writeBigUInt64BE(firstSequence, 0)
17
+ range.writeBigUInt64BE(lastSequence, 8)
18
+ return createHash("sha256")
19
+ .update("socketduct-log-batch:v1")
20
+ .update(Buffer.from([0]))
21
+ .update(writerEpoch)
22
+ .update(range)
23
+ .update(jsonl)
24
+ .digest()
25
+ .subarray(0, 16)
26
+ }
27
+
28
+ /**
29
+ * Validates a complete batch and extracts structural timestamps/sequences.
30
+ * @param jsonl
31
+ * @param writerEpoch
32
+ */
33
+ export function inspectLogBatch(/** @type {Buffer | Uint8Array} */ jsonl, /** @type {Buffer | Uint8Array} */ writerEpoch) {
34
+ const raw = Buffer.from(jsonl)
35
+ const epoch = Buffer.from(writerEpoch)
36
+ if (raw.length === 0 || raw.at(-1) !== 0x0a) throw new Error("Log batch is not complete newline JSONL")
37
+ const epochText = epoch.toString("base64url")
38
+ const lines = raw.subarray(0, raw.length - 1).toString("utf8").split("\n")
39
+ /** @type {Array<Record<string, unknown>>} */
40
+ const records = []
41
+ let firstSequence
42
+ let previousSequence
43
+ let minTimestamp = Number.POSITIVE_INFINITY
44
+ let maxTimestamp = Number.NEGATIVE_INFINITY
45
+ for (const line of lines) {
46
+ if (line.length === 0) throw new Error("Log batch contains an empty record")
47
+ const parsed = JSON.parse(line)
48
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Log record must be an object")
49
+ const record = /** @type {Record<string, unknown>} */ (parsed)
50
+ if (record.writerEpoch !== epochText || typeof record.eventSequence !== "string" || !/^[1-9][0-9]*$/.test(record.eventSequence)) throw new Error("Log record writer identity is invalid")
51
+ const sequence = BigInt(record.eventSequence)
52
+ if (previousSequence !== undefined && sequence !== previousSequence + 1n) throw new Error("Log record sequence is not contiguous")
53
+ firstSequence ??= sequence
54
+ previousSequence = sequence
55
+ if (typeof record.timestamp !== "string") throw new Error("Log record timestamp is missing")
56
+ const timestamp = Date.parse(record.timestamp)
57
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw new Error("Log record timestamp is invalid")
58
+ minTimestamp = Math.min(minTimestamp, timestamp)
59
+ maxTimestamp = Math.max(maxTimestamp, timestamp)
60
+ records.push(record)
61
+ }
62
+ if (firstSequence === undefined || previousSequence === undefined) throw new Error("Log batch contains no records")
63
+ return {
64
+ records,
65
+ firstSequence,
66
+ lastSequence: previousSequence,
67
+ recordCount: records.length,
68
+ minTimestamp: BigInt(minTimestamp),
69
+ maxTimestamp: BigInt(maxTimestamp),
70
+ batchId: createLogBatchId(epoch, firstSequence, previousSequence, raw)
71
+ }
72
+ }