返回 CodeWhale
pet-recorder.mjs
根目录 / pet / scripts / lib / pet-recorder.mjs
1 import { open, link, rename, unlink, lstat, realpath, opendir } from 'node:fs/promises';
2 import { constants } from 'node:fs';
3 import { DatabaseSync } from 'node:sqlite';
4 import { dirname, basename, resolve } from 'node:path';
5 import { randomUUID } from 'node:crypto';
6 import { setTimeout as delay } from 'node:timers/promises';
7 import { PET_BIN_MS, validatePetBucket, encodePetJSONL, decodePetJSONL } from '../../dist/core/pet-telemetry.js';
8
9 async function syncDirectory(path) {
10 let directory;
11 try { directory = await open(path, 'r'); await directory.sync(); }
12 catch (error) {
13 // Node cannot open/sync directory handles on Windows. File data is still
14 // synced before its atomic replacement on that platform.
15 if (process.platform !== 'win32' || !['EPERM', 'EISDIR', 'EINVAL', 'ENOTSUP'].includes(error.code)) throw error;
16 } finally { await directory?.close(); }
17 }
18
19 // SQLite's OS lock is released even after process death. This empty sidecar
20 // contains no events or recorder state; keep its pathname so later processes
21 // coordinate on the same inode. No PID files or stale-lock deletion are needed.
22 async function lockRecorder(path) {
23 const name = `${path}.writer-lock`;
24 try { const created = await open(name, 'wx', 0o600); await created.close(); }
25 catch (error) { if (error.code !== 'EEXIST') throw error; }
26 const identity = await lstat(name, { bigint: true });
27 if (!identity.isFile() || identity.nlink !== 1n || identity.size !== 0n)
28 throw new Error('Invalid pet recorder lock; existing files were preserved.');
29 let database;
30 const check = async () => {
31 const current = await lstat(name, { bigint: true });
32 if (!current.isFile() || current.nlink !== 1n || current.size !== 0n
33 || current.dev !== identity.dev || current.ino !== identity.ino)
34 throw new Error('The pet recorder lock was replaced; existing files were preserved.');
35 };
36 try {
37 database = new DatabaseSync(name);
38 database.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE');
39 await check();
40 return { check, close: () => { database.close(); } };
41 } catch (error) {
42 database?.close();
43 if (error.errcode === 5 || error.errcode === 6) throw new Error('Another pet recorder is using this output.');
44 throw error;
45 }
46 }
47
48 /** Replaces the CLI's unbounded append-only output. Each complete segment is
49 * replayable on its own; the same live pathname always holds the newest one. */
50 export async function createPetRecorder(path, { maxBuckets = 216_000, maxBytes = 64 * 1024 * 1024, report = () => {}, resume = false } = {}) {
51 if (!Number.isSafeInteger(maxBuckets) || maxBuckets < 1 || maxBuckets > 216_000
52 || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024)
53 throw new Error('Invalid pet recording segment limit.');
54 path = resolve(await realpath(dirname(resolve(path))), basename(path));
55 let lock = await lockRecorder(path), output, sequence = 0, bytes = 0, segment = 0, busy = false, restart = false, expectedMtime;
56 try {
57 try { output = await open(path, 'wx', 0o600); }
58 catch (error) {
59 if (!resume || error.code !== 'EEXIST') throw error;
60 const original = await lstat(path, { bigint: true });
61 if (!original.isFile() || original.size > 64n * 1024n * 1024n)
62 throw new Error('The previous pet recording is not a bounded regular file; it was preserved.');
63 output = await open(path, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
64 const held = await output.stat({ bigint: true });
65 if (held.dev !== original.dev || held.ino !== original.ino || held.size !== original.size)
66 throw new Error('The previous pet recording changed while opening; it was preserved.');
67 // Read at most the size already checked, including a single growth byte.
68 const contents = Buffer.alloc(Number(held.size) + 1);
69 let length = 0;
70 while (length < contents.length) {
71 const { bytesRead } = await output.read(contents, length, contents.length - length, length);
72 if (!bytesRead) break;
73 length += bytesRead;
74 }
75 if (length !== Number(held.size)) throw new Error('The previous pet recording changed while reading; it was preserved.');
76 const text = new TextDecoder('utf-8', { fatal: true }).decode(contents.subarray(0, length));
77 if (text && !text.endsWith('\n')) throw new Error('The previous pet recording has an incomplete final row; it was preserved.');
78 decodePetJSONL(text);
79 const unchanged = await output.stat({ bigint: true });
80 if (unchanged.size !== original.size || unchanged.mtimeNs !== original.mtimeNs)
81 throw new Error('The previous pet recording changed while reading; it was preserved.');
82 bytes = length; restart = true; expectedMtime = original.mtimeNs;
83 // Continue archive numbering without collecting a growing directory list.
84 const prefix = `${basename(path)}.segment-`;
85 for await (const entry of await opendir(dirname(path))) {
86 if (!entry.name.startsWith(prefix)) continue;
87 const suffix = entry.name.slice(prefix.length);
88 if (!/^[0-9]{6,}\.jsonl$/.test(suffix)) continue;
89 const number = Number(suffix.slice(0, -6));
90 if (!Number.isSafeInteger(number) || number >= Number.MAX_SAFE_INTEGER)
91 throw new Error('Pet archive numbering is exhausted; existing files were preserved.');
92 segment = Math.max(segment, number);
93 }
94 }
95 expectedMtime ??= (await output.stat({ bigint: true })).mtimeNs;
96 } catch (error) { try { await output?.close(); } finally { lock.close(); } throw error; }
97 return {
98 async append(bucket) {
99 if (!output || busy) throw new Error('Pet recorder is closed or already writing.');
100 validatePetBucket(bucket);
101 busy = true;
102 try {
103 await lock.check();
104 const held = await output.stat({ bigint: true }), current = await lstat(path, { bigint: true });
105 if (!current.isFile() || held.dev !== current.dev || held.ino !== current.ino || held.size !== BigInt(bytes) || held.mtimeNs !== expectedMtime)
106 throw new Error('The live pet recording was changed or replaced externally; existing files were preserved.');
107 const encode = seq => encodePetJSONL([{ ...bucket, sequence: seq, simTimeMs: seq * PET_BIN_MS }]);
108 let row = encode(sequence), size = Buffer.byteLength(row);
109 if (restart || sequence >= maxBuckets || bytes + size > maxBytes) {
110 row = encode(0); size = Buffer.byteLength(row);
111 if (size > maxBytes) throw new Error('Pet bucket exceeds the recording segment byte limit.');
112 const temporary = resolve(dirname(path), `.${basename(path)}.next-${randomUUID()}`);
113 const archive = `${path}.segment-${String(segment + 1).padStart(6, '0')}.jsonl`;
114 let next, installed = false, created = false;
115 try {
116 next = await open(temporary, 'wx', 0o600); created = true;
117 await next.writeFile(row); await next.sync();
118 const nextIdentity = await next.stat({ bigint: true });
119 await next.close(); next = undefined;
120 await output.sync();
121 // link is exclusive: a collision never replaces someone else's
122 // archive. Persist this name before replacing the live pathname.
123 await link(path, archive); await syncDirectory(dirname(path));
124 // Windows can reject replacement while either writer handle is
125 // open. Both files are synced and the old file is archived first.
126 await output.close(); output = undefined;
127 for (let attempt = 0; ; attempt++) {
128 await lock.check();
129 const destination = await lstat(path, { bigint: true });
130 if (!destination.isFile() || destination.dev !== held.dev || destination.ino !== held.ino
131 || destination.size !== held.size || destination.mtimeNs !== held.mtimeNs)
132 throw new Error('The live pet recording changed while rotating; existing files were preserved.');
133 try { await rename(temporary, path); break; }
134 catch (error) {
135 // A reader or file scanner can briefly deny replacement on
136 // Windows. Retry for under two seconds, checking identity each
137 // time; persistent denial still stops without deleting history.
138 if (process.platform !== 'win32' || !['EPERM', 'EBUSY'].includes(error.code) || attempt >= 20) throw error;
139 await delay(Math.min(100, (attempt + 1) * 25));
140 }
141 }
142 installed = true;
143 // The first row is already published. Account for it before any
144 // fallible cleanup/report so a later append sees the actual file.
145 sequence = 1; bytes = size; segment++; restart = false;
146 output = await open(path, constants.O_WRONLY | constants.O_APPEND);
147 const reopened = await output.stat({ bigint: true });
148 if (reopened.dev !== nextIdentity.dev || reopened.ino !== nextIdentity.ino)
149 throw new Error('The live pet recording was replaced externally after rotation.');
150 expectedMtime = reopened.mtimeNs;
151 await syncDirectory(dirname(path));
152 report(`Archived pet recording: ${archive}`);
153 } finally {
154 await next?.close();
155 if (created && !installed) await unlink(temporary);
156 }
157 return;
158 } else {
159 await output.writeFile(row);
160 expectedMtime = (await output.stat({ bigint: true })).mtimeNs;
161 }
162 bytes += size; sequence++;
163 } finally { busy = false; }
164 },
165 async close() {
166 if (busy) throw new Error('Wait for the pet recorder write before closing.');
167 const current = output, heldLock = lock; output = undefined; lock = undefined;
168 try { if (current) { try { await current.sync(); } finally { await current.close(); } } }
169 finally { heldLock?.close(); }
170 },
171 };
172 }
173
173 lines Plain Text