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,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
|
+
}
|