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.
- package/.env.example +17 -5
- package/README.md +5 -5
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/docs/architecture.md +11 -9
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +43 -7
- package/docs/reverse-gateway.md +75 -9
- package/docs/running.md +16 -13
- package/docs/security.md +16 -5
- package/docs/session-lifecycle.md +14 -3
- package/docs/socket-api.md +54 -20
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/multiplexed-transport-set.js +12 -5
- package/src/client/virtual-socket.js +52 -0
- package/src/config/environment.js +56 -7
- package/src/index.js +64 -26
- package/src/logging/compressed-log-reader.js +256 -0
- package/src/logging/compressed-log-worker.js +430 -0
- package/src/logging/compressed-log-writer.js +538 -0
- package/src/logging/log-batch.js +72 -0
- package/src/logging/log-retention.js +241 -0
- package/src/logging/log-segment-index.js +176 -0
- package/src/logging/structured-logger.js +136 -30
- package/src/logging/zstd-frame-codec.js +73 -0
- package/src/reverse/client.js +372 -82
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -18
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +136 -7
- package/tensorbuzz.yml +4 -2
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +170 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +123 -0
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/reverse-gateway.test.js +597 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +12 -1
- package/test/unit/config/docker-gateway-files.test.js +2 -0
- package/test/unit/config/environment.test.js +54 -14
- package/test/unit/index.test.js +3 -0
- package/test/unit/logging/compressed-log-worker.test.js +29 -0
- package/test/unit/logging/compressed-log-writer.test.js +183 -0
- package/test/unit/logging/log-retention.test.js +86 -0
- package/test/unit/logging/log-segment-index.test.js +104 -0
- package/test/unit/logging/structured-logger.test.js +14 -1
- package/test/unit/logging/zstd-frame-codec.test.js +24 -0
- package/test/unit/reverse/disconnect-attribution.test.js +82 -0
- 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
|
+
}
|