logroller 2.0.1 → 2.1.0
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/CHANGELOG.md +52 -2
- package/README.md +232 -46
- package/dist/fsio.d.ts +3 -3
- package/dist/index.cjs +477 -132
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +476 -133
- package/dist/parsers.d.ts +2 -0
- package/dist/rotate-file-stream.d.ts +54 -16
- package/dist/shutdown.d.ts +32 -0
- package/dist/types.d.ts +96 -66
- package/package.json +2 -2
package/dist/index.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! logroller v2.
|
|
1
|
+
/*! logroller v2.1.0 | MIT License */
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -24,13 +24,13 @@ const existsSafe = (p) => {
|
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
26
|
/**
|
|
27
|
-
* Compress `src` → `dst`, deleting `src` ONLY on verified
|
|
28
|
-
* On failure removes the partial `dst` and reports via callback —
|
|
27
|
+
* Compress `src` → `dst` at gzip `level`, deleting `src` ONLY on verified
|
|
28
|
+
* success. On failure removes the partial `dst` and reports via callback —
|
|
29
29
|
* logs degrade to uncompressed rather than losing data.
|
|
30
30
|
*/
|
|
31
|
-
async function gzipMove(src, dst, onWarn) {
|
|
31
|
+
async function gzipMove(src, dst, onWarn, level = 6) {
|
|
32
32
|
try {
|
|
33
|
-
await pipeline(fs.createReadStream(src), zlib.createGzip({ level
|
|
33
|
+
await pipeline(fs.createReadStream(src), zlib.createGzip({ level }), fs.createWriteStream(dst));
|
|
34
34
|
await fs.promises.unlink(src);
|
|
35
35
|
return true;
|
|
36
36
|
}
|
|
@@ -43,19 +43,54 @@ async function gzipMove(src, dst, onWarn) {
|
|
|
43
43
|
const endStream = (ws) => ws ? new Promise((resolve) => ws.end(() => resolve())) : Promise.resolve();
|
|
44
44
|
|
|
45
45
|
const UNITS = {
|
|
46
|
-
b: 1,
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
b: 1,
|
|
47
|
+
k: 1024,
|
|
48
|
+
kb: 1024,
|
|
49
|
+
m: 1048576,
|
|
50
|
+
mb: 1048576,
|
|
51
|
+
g: 1073741824,
|
|
52
|
+
gb: 1073741824,
|
|
49
53
|
};
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Every unit token the duration regex can capture MUST have an entry here —
|
|
56
|
+
* a captured-but-unmapped unit would otherwise surface as a NaN that
|
|
57
|
+
* detonates later (e.g. `RangeError: Invalid time value` from Intl),
|
|
58
|
+
* far away from the actual misconfiguration.
|
|
59
|
+
*/
|
|
60
|
+
const DURATION_ALIASES = {
|
|
61
|
+
ms: 1,
|
|
62
|
+
s: 1_000,
|
|
63
|
+
m: 60_000,
|
|
64
|
+
h: 3_600_000,
|
|
65
|
+
hour: 3_600_000,
|
|
66
|
+
hours: 3_600_000,
|
|
52
67
|
};
|
|
68
|
+
/**
|
|
69
|
+
* Parse `"<n><unit?>"` or a bare number into milliseconds.
|
|
70
|
+
* Bare numbers are interpreted in `defaultUnit`.
|
|
71
|
+
*
|
|
72
|
+
* Accepted unit spellings: ms, s, m, h, hour, hours (case-insensitive).
|
|
73
|
+
*/
|
|
74
|
+
function parseDuration(value, defaultUnit, fieldName) {
|
|
75
|
+
if (typeof value === "number") {
|
|
76
|
+
return Math.round(value * DURATION_ALIASES[defaultUnit]);
|
|
77
|
+
}
|
|
78
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(h(?:our)?s?|ms|s|m)?\s*$/i.exec(value);
|
|
79
|
+
if (!m)
|
|
80
|
+
throw new Error(`Invalid ${fieldName} "${value}"`);
|
|
81
|
+
const unit = (m[2] ?? defaultUnit).toLowerCase();
|
|
82
|
+
const mult = DURATION_ALIASES[unit];
|
|
83
|
+
if (mult === undefined) {
|
|
84
|
+
throw new Error(`Invalid ${fieldName} unit "${m[2]}" in "${value}"`);
|
|
85
|
+
}
|
|
86
|
+
return Math.round(Number.parseFloat(m[1]) * mult);
|
|
87
|
+
}
|
|
53
88
|
function parseSize(v) {
|
|
54
89
|
if (v === undefined || v === null || v === 0)
|
|
55
90
|
return 0;
|
|
56
91
|
if (typeof v === "number")
|
|
57
92
|
return v;
|
|
58
|
-
const m = /^\s*(\d+(?:\.\d+)?)\s*(b|k|
|
|
93
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(v);
|
|
59
94
|
if (!m)
|
|
60
95
|
throw new Error(`Invalid maxSize "${v}"`);
|
|
61
96
|
return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
|
|
@@ -63,8 +98,11 @@ function parseSize(v) {
|
|
|
63
98
|
function parseMaxFiles(v) {
|
|
64
99
|
if (v === undefined || v === null || v === "" || v === 0)
|
|
65
100
|
return null;
|
|
66
|
-
if (typeof v === "number")
|
|
101
|
+
if (typeof v === "number") {
|
|
102
|
+
if (v < 0)
|
|
103
|
+
throw new Error(`Invalid maxFiles "${v}"`);
|
|
67
104
|
return { count: v };
|
|
105
|
+
}
|
|
68
106
|
const m = /^\s*(\d+)\s*d\s*$/i.exec(v);
|
|
69
107
|
if (m)
|
|
70
108
|
return { days: Number.parseInt(m[1], 10) };
|
|
@@ -79,7 +117,7 @@ function parseFrequency(v) {
|
|
|
79
117
|
if (v === "daily")
|
|
80
118
|
return { type: "daily" };
|
|
81
119
|
if (typeof v === "number") {
|
|
82
|
-
if (
|
|
120
|
+
if (v <= 0)
|
|
83
121
|
throw new Error(`Invalid frequency "${v}"`);
|
|
84
122
|
return { type: "interval", ms: v };
|
|
85
123
|
}
|
|
@@ -88,25 +126,23 @@ function parseFrequency(v) {
|
|
|
88
126
|
throw new Error(`Invalid frequency "${v}"`);
|
|
89
127
|
return { type: "interval", ms: Number.parseInt(m[1], 10) * FREQ_MULT[m[2].toLowerCase()] };
|
|
90
128
|
}
|
|
129
|
+
const FREQ_MULT = {
|
|
130
|
+
ms: 1,
|
|
131
|
+
s: 1_000,
|
|
132
|
+
m: 60_000,
|
|
133
|
+
h: 3_600_000,
|
|
134
|
+
};
|
|
135
|
+
/** Static virtual-clock shift. Accepts hours by default; h/hours/m/s/ms. */
|
|
91
136
|
function parseClockOffset(v) {
|
|
92
137
|
if (v === undefined || v === null || v === "")
|
|
93
138
|
return 0;
|
|
94
|
-
|
|
95
|
-
return Math.round(v * 3_600_000);
|
|
96
|
-
const m = /^\s*(-?\d+(?:\.\d+)?)\s*h(?:our)?s?\s*$/i.exec(v);
|
|
97
|
-
if (!m)
|
|
98
|
-
throw new Error(`Invalid addHours "${v}"`);
|
|
99
|
-
return Math.round(Number.parseFloat(m[1]) * 3_600_000);
|
|
139
|
+
return parseDuration(v, "h", "addHours");
|
|
100
140
|
}
|
|
141
|
+
/** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
|
|
101
142
|
function parseClockStep(v) {
|
|
102
|
-
if (v === undefined || v === null || v === ""
|
|
143
|
+
if (v === undefined || v === null || v === "")
|
|
103
144
|
return 0;
|
|
104
|
-
|
|
105
|
-
return Math.round(v * 3_600_000);
|
|
106
|
-
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(v);
|
|
107
|
-
if (!m)
|
|
108
|
-
throw new Error(`Invalid addHoursEveryMin "${v}"`);
|
|
109
|
-
return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? "h").toLowerCase()]);
|
|
145
|
+
return parseDuration(v, "h", "addHoursEveryMin");
|
|
110
146
|
}
|
|
111
147
|
|
|
112
148
|
/* Timezone-aware helpers built exclusively on Intl (zero deps). */
|
|
@@ -143,11 +179,30 @@ function partsIn(tz, date) {
|
|
|
143
179
|
const pad2 = (n) => n.padStart(2, "0");
|
|
144
180
|
/** Escape a literal string for embedding inside a RegExp. */
|
|
145
181
|
function escRe(s) {
|
|
146
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g,
|
|
182
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
147
183
|
}
|
|
148
184
|
/** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
|
|
149
185
|
function formatStamp(pattern, parts) {
|
|
150
|
-
return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (
|
|
186
|
+
return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
|
|
187
|
+
switch (token) {
|
|
188
|
+
case "YYYY":
|
|
189
|
+
return parts.year;
|
|
190
|
+
case "YY":
|
|
191
|
+
return parts.year.slice(-2);
|
|
192
|
+
case "MM":
|
|
193
|
+
return pad2(parts.month);
|
|
194
|
+
case "DD":
|
|
195
|
+
return pad2(parts.day);
|
|
196
|
+
case "HH":
|
|
197
|
+
return pad2(parts.hour);
|
|
198
|
+
case "mm":
|
|
199
|
+
return pad2(parts.minute);
|
|
200
|
+
case "ss":
|
|
201
|
+
return pad2(parts.second);
|
|
202
|
+
default:
|
|
203
|
+
return token;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
151
206
|
}
|
|
152
207
|
/** Offset of `tz` vs UTC at instant `date`, milliseconds. */
|
|
153
208
|
function tzOffsetMs(tz, date) {
|
|
@@ -177,6 +232,7 @@ const BUMP_DEFAULT_MS = 60_000;
|
|
|
177
232
|
const AUDIT_MAX_FILES = 600;
|
|
178
233
|
const AUDIT_MAX_EVENTS = 200;
|
|
179
234
|
const HEARTBEAT_STALE_MS = 30_000;
|
|
235
|
+
const TIMER_POLL_MS = 1_000;
|
|
180
236
|
const emptyAudit = (prefix, ext, datePattern, tz) => ({
|
|
181
237
|
version: 1,
|
|
182
238
|
prefix,
|
|
@@ -193,17 +249,22 @@ const emptyAudit = (prefix, ext, datePattern, tz) => ({
|
|
|
193
249
|
});
|
|
194
250
|
/**
|
|
195
251
|
* A `Writable` that appends to dated log files, rolling segments on time
|
|
196
|
-
* and/or size, optionally gzipping sealed segments, enforcing retention
|
|
197
|
-
*
|
|
198
|
-
*
|
|
252
|
+
* and/or size, optionally gzipping sealed segments, enforcing retention
|
|
253
|
+
* (count, age, and total-byte budget), maintaining a stable "current"
|
|
254
|
+
* symlink for log collectors, running an awaited seal hook (upload
|
|
255
|
+
* pipeline), and resuming an unbroken series across restarts via disk
|
|
256
|
+
* scan + audit manifest.
|
|
199
257
|
*
|
|
200
258
|
* @example
|
|
201
259
|
* ```ts
|
|
202
260
|
* const log = createStream({
|
|
203
261
|
* filename: "logs/app-%DATE%.log",
|
|
204
262
|
* maxSize: "20m",
|
|
263
|
+
* maxTotalSize: "5g",
|
|
205
264
|
* maxFiles: "90d",
|
|
206
265
|
* zippedArchive: true,
|
|
266
|
+
* symlink: true,
|
|
267
|
+
* onSeal: async ({ gz }) => uploadToS3(gz),
|
|
207
268
|
* });
|
|
208
269
|
* log.write("hello\n"); // or: readable.pipe(log)
|
|
209
270
|
* ```
|
|
@@ -218,6 +279,7 @@ class RotateFileStream extends Writable {
|
|
|
218
279
|
maxFiles;
|
|
219
280
|
freq;
|
|
220
281
|
unrefTimers;
|
|
282
|
+
maxTotalSize;
|
|
221
283
|
/* ---- filename-template pieces ---- */
|
|
222
284
|
_tL;
|
|
223
285
|
_tMid;
|
|
@@ -228,6 +290,10 @@ class RotateFileStream extends Writable {
|
|
|
228
290
|
_auditEnabled;
|
|
229
291
|
_auditPath;
|
|
230
292
|
_bootId = randomUUID();
|
|
293
|
+
_onSeal;
|
|
294
|
+
_sealHookTimeoutMs;
|
|
295
|
+
_compressionLevel;
|
|
296
|
+
_symlinkPath = null;
|
|
231
297
|
/* ---- virtual test clock ---- */
|
|
232
298
|
_offsetMs;
|
|
233
299
|
_stepMs;
|
|
@@ -242,6 +308,7 @@ class RotateFileStream extends Writable {
|
|
|
242
308
|
_bumpTimer = null;
|
|
243
309
|
_ended = false;
|
|
244
310
|
_tail = Promise.resolve();
|
|
311
|
+
_warnedBackward = false;
|
|
245
312
|
_audit;
|
|
246
313
|
constructor(opts = {}) {
|
|
247
314
|
super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
|
|
@@ -257,11 +324,16 @@ class RotateFileStream extends Writable {
|
|
|
257
324
|
this.zippedArchive = !!opts.zippedArchive;
|
|
258
325
|
this.maxSize = parseSize(opts.maxSize ?? 0);
|
|
259
326
|
this.maxFiles = parseMaxFiles(opts.maxFiles);
|
|
327
|
+
this.maxTotalSize = parseSize(opts.maxTotalSize ?? 0);
|
|
260
328
|
this.freq = parseFrequency(opts.frequency);
|
|
261
329
|
this.unrefTimers = !!opts.unrefTimers;
|
|
262
330
|
this._offsetMs = parseClockOffset(opts.addHours);
|
|
263
331
|
this._stepMs = parseClockStep(opts.addHoursEveryMin);
|
|
264
332
|
this._bumpEvery = Math.max(50, opts.clockStepIntervalMs ?? BUMP_DEFAULT_MS);
|
|
333
|
+
// compression + seal hook
|
|
334
|
+
this._compressionLevel = Math.min(9, Math.max(0, opts.compressionLevel ?? 6));
|
|
335
|
+
this._onSeal = opts.onSeal;
|
|
336
|
+
this._sealHookTimeoutMs = Math.max(0, opts.sealHookTimeoutMs ?? 30_000);
|
|
265
337
|
/* Split "prefix-%DATE%mid.ext" → pieces so indices land BETWEEN the
|
|
266
338
|
stamp and the extension: prefix-STAMP.N.ext */
|
|
267
339
|
const li = this.template.indexOf("%DATE%");
|
|
@@ -278,6 +350,12 @@ class RotateFileStream extends Writable {
|
|
|
278
350
|
const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log")
|
|
279
351
|
.replace(/^[-.]+|[-.]+$/g, "");
|
|
280
352
|
this._auditPath = path.join(this.dir, opts.auditFile || `${stem}_audit.json`);
|
|
353
|
+
// stable "current" pointer target
|
|
354
|
+
this._symlinkPath = opts.symlink
|
|
355
|
+
? path.join(this.dir, typeof opts.symlink === "string"
|
|
356
|
+
? opts.symlink
|
|
357
|
+
: `${stem}-current.log`)
|
|
358
|
+
: null;
|
|
281
359
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
282
360
|
this._curStamp = this._stampNow();
|
|
283
361
|
this._audit = emptyAudit(this._tL, this._tExt, this.datePattern, this.tz);
|
|
@@ -294,20 +372,37 @@ class RotateFileStream extends Writable {
|
|
|
294
372
|
offsetMs: this._offsetMs,
|
|
295
373
|
}));
|
|
296
374
|
}
|
|
375
|
+
// warn when frequency is finer than the datePattern tokens
|
|
376
|
+
process.nextTick(() => {
|
|
377
|
+
const warn = (m) => this.emit("warn", new Error(m));
|
|
378
|
+
if (this.freq.type === "interval") {
|
|
379
|
+
if (this.freq.ms < 86_400_000 && !this.datePattern.includes("HH")) {
|
|
380
|
+
warn(`frequency "${opts.frequency}" needs HH in datePattern — ` +
|
|
381
|
+
"segments would collapse onto one filename");
|
|
382
|
+
}
|
|
383
|
+
if (this.freq.ms < 60_000 && !this.datePattern.includes("mm")) {
|
|
384
|
+
warn(`frequency "${opts.frequency}" needs mm in datePattern`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else if (!this.datePattern.includes("DD")) {
|
|
388
|
+
warn("daily frequency needs DD in datePattern");
|
|
389
|
+
}
|
|
390
|
+
});
|
|
297
391
|
/* Boot housekeeping is job #1 of the serial queue — chained HERE in the
|
|
298
392
|
constructor so it deterministically precedes any write job: zombie
|
|
299
393
|
demotion sees `_path === null`, gzip repair cannot race a reopen. */
|
|
300
394
|
this._job(async () => {
|
|
395
|
+
// disk-driven, audit-independent (runs even with audit:false)
|
|
396
|
+
await this._sealOrphanedPastStamps();
|
|
301
397
|
if (this._auditEnabled) {
|
|
302
398
|
this._demoteZombieActives();
|
|
303
399
|
this._reconcileWithDisk();
|
|
304
|
-
await this._sealOrphanedPastStamps();
|
|
305
400
|
await this._repairInterruptedGzips();
|
|
306
401
|
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
307
402
|
this._saveAudit();
|
|
308
403
|
}
|
|
309
|
-
if (this.maxFiles)
|
|
310
|
-
this._retire();
|
|
404
|
+
if (this.maxFiles || this.maxTotalSize > 0)
|
|
405
|
+
this._retire(); // budget policy also gates
|
|
311
406
|
}).catch(() => { });
|
|
312
407
|
this._armTimer();
|
|
313
408
|
this._armBump();
|
|
@@ -336,9 +431,7 @@ class RotateFileStream extends Writable {
|
|
|
336
431
|
get currentIndex() {
|
|
337
432
|
return this._index;
|
|
338
433
|
}
|
|
339
|
-
/**
|
|
340
|
-
* Force an immediate rotation and resolve when the next slot is staged.
|
|
341
|
-
*/
|
|
434
|
+
/** Force an immediate rotation and resolve when the next slot is staged. */
|
|
342
435
|
rotateNow() {
|
|
343
436
|
return this._job(() => this._rotate("manual"));
|
|
344
437
|
}
|
|
@@ -350,6 +443,60 @@ class RotateFileStream extends Writable {
|
|
|
350
443
|
advanceClock() {
|
|
351
444
|
return this._job(() => this._applyBump());
|
|
352
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Every family file on disk, oldest stamp first.
|
|
448
|
+
* Disk is truth: untracked files are reported with state "untracked".
|
|
449
|
+
*/
|
|
450
|
+
listSegments() {
|
|
451
|
+
const rx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
|
|
452
|
+
"(?:\\.(\\d+))?" + escRe(this._tExt) + "(?:\\.gz)?$");
|
|
453
|
+
const skip = this._symlinkPath && path.basename(this._symlinkPath);
|
|
454
|
+
const out = [];
|
|
455
|
+
let names = [];
|
|
456
|
+
try {
|
|
457
|
+
names = fs.readdirSync(this.dir);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
for (const n of names) {
|
|
463
|
+
if (n === skip)
|
|
464
|
+
continue;
|
|
465
|
+
const m = rx.exec(n);
|
|
466
|
+
if (!m)
|
|
467
|
+
continue;
|
|
468
|
+
const rec = this._audit.files[n];
|
|
469
|
+
out.push({
|
|
470
|
+
file: path.join(this.dir, n),
|
|
471
|
+
stamp: m[1],
|
|
472
|
+
index: m[2] ? Number.parseInt(m[2], 10) : 0,
|
|
473
|
+
gzipped: n.endsWith(".gz"),
|
|
474
|
+
bytes: statSizeSafe(path.join(this.dir, n)),
|
|
475
|
+
state: rec?.state ?? "untracked",
|
|
476
|
+
openedAt: rec?.openedAt,
|
|
477
|
+
archivedAt: rec?.archivedAt,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
out.sort((a, b) => a.stamp.localeCompare(b.stamp) || a.index - b.index);
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Health-check snapshot for dashboards and support triage.
|
|
485
|
+
*/
|
|
486
|
+
stats() {
|
|
487
|
+
const segs = this.listSegments();
|
|
488
|
+
return {
|
|
489
|
+
dir: this.dir,
|
|
490
|
+
currentStamp: this._curStamp,
|
|
491
|
+
activeFile: this._path,
|
|
492
|
+
activeIndex: this._index,
|
|
493
|
+
segments: segs.length,
|
|
494
|
+
archived: segs.filter((s) => s.gzipped).length,
|
|
495
|
+
totalBytes: segs.reduce((sum, s) => sum + s.bytes, 0),
|
|
496
|
+
clockOffsetMs: this._offsetMs,
|
|
497
|
+
symlink: this._symlinkPath,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
353
500
|
/* ==================================================================
|
|
354
501
|
* Writable contract
|
|
355
502
|
* ================================================================ */
|
|
@@ -361,8 +508,8 @@ class RotateFileStream extends Writable {
|
|
|
361
508
|
const len = chunk.length;
|
|
362
509
|
this._job(async () => {
|
|
363
510
|
try {
|
|
364
|
-
|
|
365
|
-
if (
|
|
511
|
+
// guarded roll (never rotates into a regressed stamp)
|
|
512
|
+
if (this._stampAdvanced(this._stampNow()))
|
|
366
513
|
await this._rotate("time");
|
|
367
514
|
await this._ensureOpen();
|
|
368
515
|
if (this.maxSize > 0 &&
|
|
@@ -433,6 +580,22 @@ class RotateFileStream extends Writable {
|
|
|
433
580
|
? base.replace(/\.[^.]+$/, `.${index}$&`)
|
|
434
581
|
: `${base}.${index}`;
|
|
435
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* true only when the period legally advanced. A regressed
|
|
585
|
+
* clock (NTP correction, manual change) never reopens a past family:
|
|
586
|
+
* writes continue in the current segment until real time catches up.
|
|
587
|
+
* Requires zero-padded datePattern tokens (documented assumption).
|
|
588
|
+
*/
|
|
589
|
+
_stampAdvanced(stamp) {
|
|
590
|
+
if (stamp > this._curStamp)
|
|
591
|
+
return true;
|
|
592
|
+
if (stamp < this._curStamp && !this._warnedBackward) {
|
|
593
|
+
this._warnedBackward = true;
|
|
594
|
+
this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
|
|
595
|
+
"keeping current segment until the clock catches up"));
|
|
596
|
+
}
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
436
599
|
/**
|
|
437
600
|
* Highest viable segment index for `stamp`.
|
|
438
601
|
* Disk is truth: resume a raw tail; start after sealed ones.
|
|
@@ -466,6 +629,57 @@ class RotateFileStream extends Writable {
|
|
|
466
629
|
}
|
|
467
630
|
return idx;
|
|
468
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Atomically flip the "current" symlink to the active segment.
|
|
634
|
+
* tmp + rename so followers (`tail -f`, Filebeat) never observe a gap.
|
|
635
|
+
* Relative target keeps the link portable. Best-effort: filesystems
|
|
636
|
+
* denying symlinks warn once and disable the feature permanently.
|
|
637
|
+
*/
|
|
638
|
+
_updateSymlink() {
|
|
639
|
+
const link = this._symlinkPath;
|
|
640
|
+
if (!link || !this._path)
|
|
641
|
+
return;
|
|
642
|
+
const tmp = `${link}.tmp`;
|
|
643
|
+
try {
|
|
644
|
+
try {
|
|
645
|
+
fs.unlinkSync(tmp);
|
|
646
|
+
}
|
|
647
|
+
catch { /* absent */ }
|
|
648
|
+
fs.symlinkSync(path.basename(this._path), tmp, "file");
|
|
649
|
+
fs.renameSync(tmp, link);
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
this._symlinkPath = null;
|
|
653
|
+
this.emit("warn", new Error(`symlink unavailable ("${link}"): ${e.message} — continuing without it`));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Await the user's onSeal hook with a hard timeout. Result or
|
|
658
|
+
* failure degrades to `warn` — a wedged upload must never stall rotation.
|
|
659
|
+
* Called BEFORE retention so uploads finish before deletion.
|
|
660
|
+
*/
|
|
661
|
+
async _runSealHook(raw, gz) {
|
|
662
|
+
const hook = this._onSeal;
|
|
663
|
+
if (!hook)
|
|
664
|
+
return;
|
|
665
|
+
let timer;
|
|
666
|
+
try {
|
|
667
|
+
await Promise.race([
|
|
668
|
+
Promise.resolve().then(() => hook({ raw, gz })),
|
|
669
|
+
new Promise((_, reject) => {
|
|
670
|
+
timer = setTimeout(() => reject(new Error(`onSeal hook timed out after ${this._sealHookTimeoutMs}ms ` +
|
|
671
|
+
`(${path.basename(gz ?? raw)})`)), this._sealHookTimeoutMs);
|
|
672
|
+
}),
|
|
673
|
+
]);
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
this.emit("warn", err);
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
if (timer)
|
|
680
|
+
clearTimeout(timer);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
469
683
|
async _ensureOpen() {
|
|
470
684
|
if (this._ws)
|
|
471
685
|
return;
|
|
@@ -475,8 +689,12 @@ class RotateFileStream extends Writable {
|
|
|
475
689
|
ws.on("error", (e) => this.emit("error", e));
|
|
476
690
|
this._ws = ws;
|
|
477
691
|
this._path = p;
|
|
692
|
+
this._updateSymlink(); // flip pointer on every open
|
|
478
693
|
if (this._auditEnabled) {
|
|
479
694
|
const base = path.basename(p);
|
|
695
|
+
// reopen = NEW incarnation. Never inherit lifecycle
|
|
696
|
+
// fields from the previous record (observed in the wild as
|
|
697
|
+
// abandonedAt < openedAt on reactivated segments).
|
|
480
698
|
this._audit.files[base] = {
|
|
481
699
|
index: this._index,
|
|
482
700
|
state: "active",
|
|
@@ -514,9 +732,10 @@ class RotateFileStream extends Writable {
|
|
|
514
732
|
rec.bytes = realBytes;
|
|
515
733
|
this._saveAudit();
|
|
516
734
|
}
|
|
517
|
-
const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
|
|
735
|
+
const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e), this._compressionLevel);
|
|
518
736
|
if (ok) {
|
|
519
737
|
this._finishArchivedRecord(oldBase);
|
|
738
|
+
await this._runSealHook(oldPath, archive);
|
|
520
739
|
}
|
|
521
740
|
else {
|
|
522
741
|
if (rec) {
|
|
@@ -525,52 +744,75 @@ class RotateFileStream extends Writable {
|
|
|
525
744
|
}
|
|
526
745
|
this._aevent("gzip-failed", { file: oldBase });
|
|
527
746
|
archive = null;
|
|
747
|
+
await this._runSealHook(oldPath, null);
|
|
528
748
|
}
|
|
529
749
|
}
|
|
530
750
|
else if (realBytes === 0) {
|
|
751
|
+
// a concurrent writer may have archived this segment
|
|
752
|
+
// between our last stat and now — adopt its artifact instead of
|
|
753
|
+
// recording a false `drop-empty` that orphans the .gz.
|
|
531
754
|
const adoptedGz = `${oldPath}.gz`;
|
|
532
755
|
if (existsSafe(adoptedGz)) {
|
|
533
|
-
// A concurrent writer archived this segment between our last stat
|
|
534
|
-
// and now — adopt its artifact instead of dropping the record.
|
|
535
756
|
this._finishArchivedRecord(oldBase);
|
|
536
757
|
archive = adoptedGz;
|
|
758
|
+
await this._runSealHook(oldPath, adoptedGz);
|
|
537
759
|
}
|
|
538
760
|
else {
|
|
539
761
|
try {
|
|
540
762
|
fs.unlinkSync(oldPath);
|
|
541
763
|
}
|
|
542
|
-
catch {
|
|
764
|
+
catch {
|
|
765
|
+
/* already gone */
|
|
766
|
+
}
|
|
543
767
|
if (rec)
|
|
544
768
|
delete this._audit.files[oldBase];
|
|
545
769
|
this._aevent("drop-empty", { file: oldBase });
|
|
546
770
|
}
|
|
547
771
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
: this._index + 1;
|
|
556
|
-
this._bytes = 0;
|
|
557
|
-
if (this._auditEnabled) {
|
|
558
|
-
this._setActivePointer(this._index);
|
|
559
|
-
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
|
|
560
|
-
this._saveAudit();
|
|
772
|
+
else if (realBytes > 0) {
|
|
773
|
+
// uncompressed seal (zippedArchive off): still notify sealers
|
|
774
|
+
if (rec) {
|
|
775
|
+
rec.state = "sealed";
|
|
776
|
+
rec.bytes = realBytes;
|
|
777
|
+
}
|
|
778
|
+
await this._runSealHook(oldPath, null);
|
|
561
779
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
780
|
+
}
|
|
781
|
+
let stamp = this._stampNow();
|
|
782
|
+
// a rotation itself must never move the series backward
|
|
783
|
+
// (covers rotateNow()/interval/size paths the upstream guards miss)
|
|
784
|
+
if (stamp < this._curStamp) {
|
|
785
|
+
if (!this._warnedBackward) {
|
|
786
|
+
this._warnedBackward = true;
|
|
787
|
+
this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
|
|
788
|
+
"rotation clamped to the current period"));
|
|
571
789
|
}
|
|
572
|
-
this.
|
|
790
|
+
stamp = this._curStamp;
|
|
573
791
|
}
|
|
792
|
+
const newPeriod = stamp !== this._curStamp;
|
|
793
|
+
this._curStamp = stamp;
|
|
794
|
+
/* Never blind-reset to 0 — recover what THIS stamp already owns. */
|
|
795
|
+
this._index =
|
|
796
|
+
newPeriod || kind === "time"
|
|
797
|
+
? this._recoverIndexFor(stamp)
|
|
798
|
+
: this._index + 1;
|
|
799
|
+
this._bytes = 0;
|
|
800
|
+
if (this._auditEnabled) {
|
|
801
|
+
this._setActivePointer(this._index);
|
|
802
|
+
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
|
|
803
|
+
this._saveAudit();
|
|
804
|
+
}
|
|
805
|
+
if (kind === "time" || kind === "manual")
|
|
806
|
+
this._armTimer();
|
|
807
|
+
if (oldPath) {
|
|
808
|
+
this.emit("rotated", {
|
|
809
|
+
reason: kind,
|
|
810
|
+
oldFile: oldPath,
|
|
811
|
+
archive,
|
|
812
|
+
newFile: this._resolveName(stamp, this._index),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
this._retire();
|
|
574
816
|
}
|
|
575
817
|
/* ==================================================================
|
|
576
818
|
* Timers
|
|
@@ -588,16 +830,26 @@ class RotateFileStream extends Writable {
|
|
|
588
830
|
const next = this.freq.type === "daily"
|
|
589
831
|
? nextMidnight(this.tz, now)
|
|
590
832
|
: nextAligned(now, this.freq.ms);
|
|
833
|
+
// anti-spin. A backward clock can put the computed
|
|
834
|
+
// boundary in the past; poll gently instead of firing every 100 ms.
|
|
835
|
+
const delta = next - this._now();
|
|
836
|
+
const delay = delta < 0 ? TIMER_POLL_MS : Math.max(100, delta);
|
|
591
837
|
this._timer = setTimeout(() => {
|
|
592
838
|
this._job(async () => {
|
|
593
839
|
const stamp = this._stampNow();
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
840
|
+
if (this.freq.type !== "daily") {
|
|
841
|
+
/* interval rotation is schedule-based; runs regardless of
|
|
842
|
+
stamp movement */
|
|
843
|
+
if (this._hasContent())
|
|
844
|
+
await this._rotate("time");
|
|
845
|
+
}
|
|
846
|
+
else if (this._stampAdvanced(stamp)) {
|
|
847
|
+
// guarded daily branch
|
|
597
848
|
if (this._hasContent()) {
|
|
598
849
|
await this._rotate("time");
|
|
599
850
|
}
|
|
600
|
-
else
|
|
851
|
+
else {
|
|
852
|
+
/* idle boundary slide — no file created for empty days */
|
|
601
853
|
this._curStamp = stamp;
|
|
602
854
|
this._index = this._recoverIndexFor(stamp);
|
|
603
855
|
if (this._auditEnabled) {
|
|
@@ -608,9 +860,10 @@ class RotateFileStream extends Writable {
|
|
|
608
860
|
this.emit("period", stamp);
|
|
609
861
|
}
|
|
610
862
|
}
|
|
863
|
+
/* stamp regressed or unchanged → keep current segment, re-arm */
|
|
611
864
|
this._armTimer();
|
|
612
865
|
}).catch(() => { });
|
|
613
|
-
},
|
|
866
|
+
}, delay);
|
|
614
867
|
if (this.unrefTimers)
|
|
615
868
|
this._timer.unref();
|
|
616
869
|
}
|
|
@@ -634,7 +887,8 @@ class RotateFileStream extends Writable {
|
|
|
634
887
|
this._offsetMs += this._stepMs;
|
|
635
888
|
this._armTimer();
|
|
636
889
|
const stamp = this._stampNow();
|
|
637
|
-
|
|
890
|
+
// guarded crossing (virtual clock can't roll backward either)
|
|
891
|
+
if (this._stampAdvanced(stamp)) {
|
|
638
892
|
if (this._hasContent()) {
|
|
639
893
|
await this._rotate("time");
|
|
640
894
|
}
|
|
@@ -672,7 +926,15 @@ class RotateFileStream extends Writable {
|
|
|
672
926
|
parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
|
|
673
927
|
}
|
|
674
928
|
catch {
|
|
675
|
-
|
|
929
|
+
// unparseable manifest is quarantined too (was silently
|
|
930
|
+
// clobbered by the first save — caught by the quarantine test)
|
|
931
|
+
try {
|
|
932
|
+
fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
|
|
933
|
+
}
|
|
934
|
+
catch {
|
|
935
|
+
/* ignore */
|
|
936
|
+
}
|
|
937
|
+
return false;
|
|
676
938
|
}
|
|
677
939
|
const j = parsed;
|
|
678
940
|
if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
|
|
@@ -809,6 +1071,60 @@ class RotateFileStream extends Writable {
|
|
|
809
1071
|
delete this._audit.files[name];
|
|
810
1072
|
}
|
|
811
1073
|
}
|
|
1074
|
+
/**
|
|
1075
|
+
* DISK-DRIVEN seal. Seal raw segments whose period has
|
|
1076
|
+
* already passed (crash near midnight, virtual-clock leftovers,
|
|
1077
|
+
* untracked/pruned files). Iterates the DIRECTORY, not the manifest:
|
|
1078
|
+
* the filesystem is the source of truth; the manifest is an index.
|
|
1079
|
+
*/
|
|
1080
|
+
async _sealOrphanedPastStamps() {
|
|
1081
|
+
if (!this.zippedArchive)
|
|
1082
|
+
return;
|
|
1083
|
+
const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
|
|
1084
|
+
"(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
|
|
1085
|
+
let names = [];
|
|
1086
|
+
try {
|
|
1087
|
+
names = fs.readdirSync(this.dir);
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
for (const name of names) {
|
|
1093
|
+
if (name.endsWith(".gz"))
|
|
1094
|
+
continue;
|
|
1095
|
+
if (name === (this._path && path.basename(this._path)))
|
|
1096
|
+
continue;
|
|
1097
|
+
const stamp = stampRx.exec(name)?.[1];
|
|
1098
|
+
// lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
|
|
1099
|
+
// also guards the current stamp and anything future-dated
|
|
1100
|
+
if (!stamp || stamp >= this._curStamp)
|
|
1101
|
+
continue;
|
|
1102
|
+
const full = path.join(this.dir, name);
|
|
1103
|
+
if (!existsSafe(full) || statSizeSafe(full) === 0)
|
|
1104
|
+
continue;
|
|
1105
|
+
const gzName = `${full}.gz`;
|
|
1106
|
+
const ok = await gzipMove(full, gzName, (e) => this.emit("warn", e), this._compressionLevel);
|
|
1107
|
+
if (!ok)
|
|
1108
|
+
continue;
|
|
1109
|
+
if (this._auditEnabled && this._audit.files[name]) {
|
|
1110
|
+
// tracked → re-key record name → name.gz (emits archive + journal)
|
|
1111
|
+
this._finishArchivedRecord(name, true);
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
// untracked → record it fresh so future reconciles see truth
|
|
1115
|
+
if (this._auditEnabled) {
|
|
1116
|
+
this._audit.files[gzName] = {
|
|
1117
|
+
state: "archived",
|
|
1118
|
+
gzipPending: false,
|
|
1119
|
+
archivedAt: new Date().toISOString(),
|
|
1120
|
+
};
|
|
1121
|
+
this._aevent("seal-past", { file: gzName });
|
|
1122
|
+
}
|
|
1123
|
+
this.emit("archive", gzName);
|
|
1124
|
+
}
|
|
1125
|
+
await this._runSealHook(full, gzName);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
812
1128
|
async _repairInterruptedGzips() {
|
|
813
1129
|
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
814
1130
|
if (!rec.gzipPending)
|
|
@@ -829,11 +1145,13 @@ class RotateFileStream extends Writable {
|
|
|
829
1145
|
/* ignore */
|
|
830
1146
|
}
|
|
831
1147
|
this._finishArchivedRecord(name, true);
|
|
1148
|
+
await this._runSealHook(src, dst);
|
|
832
1149
|
continue;
|
|
833
1150
|
}
|
|
834
|
-
const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
|
|
1151
|
+
const ok = await gzipMove(src, dst, (e) => this.emit("warn", e), this._compressionLevel);
|
|
835
1152
|
if (ok) {
|
|
836
1153
|
this._finishArchivedRecord(name, true);
|
|
1154
|
+
await this._runSealHook(src, dst);
|
|
837
1155
|
}
|
|
838
1156
|
else {
|
|
839
1157
|
rec.gzipPending = false;
|
|
@@ -861,72 +1179,24 @@ class RotateFileStream extends Writable {
|
|
|
861
1179
|
this.emit("archive", path.join(this.dir, gzName));
|
|
862
1180
|
this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
|
|
863
1181
|
}
|
|
864
|
-
/**
|
|
865
|
-
* Seal raw segments whose period has already passed (crash near midnight,
|
|
866
|
-
* virtual-clock leftovers, untracked files). Disk is the source of truth —
|
|
867
|
-
* manifest-tracked or not, a raw tail nothing will ever write to again
|
|
868
|
-
* must still honour zippedArchive.
|
|
869
|
-
*/
|
|
870
|
-
async _sealOrphanedPastStamps() {
|
|
871
|
-
if (!this.zippedArchive)
|
|
872
|
-
return;
|
|
873
|
-
const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
|
|
874
|
-
"(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
|
|
875
|
-
let names = [];
|
|
876
|
-
try {
|
|
877
|
-
names = fs.readdirSync(this.dir);
|
|
878
|
-
}
|
|
879
|
-
catch {
|
|
880
|
-
return;
|
|
881
|
-
}
|
|
882
|
-
for (const name of names) {
|
|
883
|
-
if (name.endsWith(".gz"))
|
|
884
|
-
continue;
|
|
885
|
-
if (name === (this._path && path.basename(this._path)))
|
|
886
|
-
continue;
|
|
887
|
-
const stamp = stampRx.exec(name)?.[1];
|
|
888
|
-
// lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
|
|
889
|
-
// also guards the current stamp and anything future-dated
|
|
890
|
-
if (!stamp || stamp >= this._curStamp)
|
|
891
|
-
continue;
|
|
892
|
-
const full = path.join(this.dir, name);
|
|
893
|
-
if (!existsSafe(full) || statSizeSafe(full) === 0)
|
|
894
|
-
continue;
|
|
895
|
-
const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
|
|
896
|
-
if (!ok)
|
|
897
|
-
continue;
|
|
898
|
-
if (this._auditEnabled && this._audit.files[name]) {
|
|
899
|
-
// tracked → re-key record name → name.gz (emits archive + journal)
|
|
900
|
-
this._finishArchivedRecord(name, true);
|
|
901
|
-
}
|
|
902
|
-
else {
|
|
903
|
-
// untracked → record it fresh so future reconciles see a truthful state
|
|
904
|
-
const gzName = `${name}.gz`;
|
|
905
|
-
if (this._auditEnabled) {
|
|
906
|
-
this._audit.files[gzName] = {
|
|
907
|
-
state: "archived",
|
|
908
|
-
gzipPending: false,
|
|
909
|
-
archivedAt: new Date().toISOString(),
|
|
910
|
-
};
|
|
911
|
-
this._aevent("seal-past", { file: gzName });
|
|
912
|
-
}
|
|
913
|
-
this.emit("archive", path.join(this.dir, gzName));
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
1182
|
/* ==================================================================
|
|
918
1183
|
* Retention
|
|
919
1184
|
* ================================================================ */
|
|
920
1185
|
async _retire() {
|
|
921
1186
|
const policy = this.maxFiles;
|
|
922
|
-
if (!policy)
|
|
923
|
-
return;
|
|
1187
|
+
if (!policy && this.maxTotalSize <= 0)
|
|
1188
|
+
return; // budget gates too
|
|
924
1189
|
const activeBase = this._path && path.basename(this._path);
|
|
925
1190
|
const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
|
|
926
1191
|
const reserved = [
|
|
927
1192
|
path.basename(this._auditPath),
|
|
928
1193
|
`${path.basename(this._auditPath)}.tmp`,
|
|
929
1194
|
`${path.basename(this._auditPath)}.corrupt`,
|
|
1195
|
+
// never eat the "current" pointer (or its tmp)
|
|
1196
|
+
...(this._symlinkPath
|
|
1197
|
+
? [path.basename(this._symlinkPath),
|
|
1198
|
+
`${path.basename(this._symlinkPath)}.tmp`]
|
|
1199
|
+
: []),
|
|
930
1200
|
];
|
|
931
1201
|
try {
|
|
932
1202
|
const dirEntries = await fs.promises.readdir(this.dir);
|
|
@@ -938,7 +1208,8 @@ class RotateFileStream extends Writable {
|
|
|
938
1208
|
for (const name of candidates) {
|
|
939
1209
|
const full = path.join(this.dir, name);
|
|
940
1210
|
try {
|
|
941
|
-
|
|
1211
|
+
const st = await fs.promises.stat(full);
|
|
1212
|
+
stats.push({ full, mtimeMs: st.mtimeMs, size: st.size }); // size
|
|
942
1213
|
}
|
|
943
1214
|
catch {
|
|
944
1215
|
/* raced deletion */
|
|
@@ -946,18 +1217,30 @@ class RotateFileStream extends Writable {
|
|
|
946
1217
|
}
|
|
947
1218
|
stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
948
1219
|
const kill = new Set();
|
|
949
|
-
if (policy
|
|
1220
|
+
if (policy?.days != null) {
|
|
950
1221
|
const cutoff = this._now() - policy.days * 86_400_000;
|
|
951
1222
|
for (const f of stats)
|
|
952
1223
|
if (f.mtimeMs < cutoff)
|
|
953
1224
|
kill.add(f.full);
|
|
954
1225
|
}
|
|
955
|
-
if (policy
|
|
1226
|
+
if (policy?.count != null) {
|
|
956
1227
|
const survivors = stats.filter((f) => !kill.has(f.full));
|
|
957
1228
|
const excessCount = survivors.length - policy.count;
|
|
958
1229
|
for (let i = 0; i < excessCount; i++)
|
|
959
1230
|
kill.add(survivors[i].full);
|
|
960
1231
|
}
|
|
1232
|
+
// byte budget — delete OLDEST until under budget.
|
|
1233
|
+
// The active segment is never a candidate, so only archives count.
|
|
1234
|
+
if (this.maxTotalSize > 0) {
|
|
1235
|
+
const survivors = stats.filter((f) => !kill.has(f.full)); // oldest→newest
|
|
1236
|
+
let total = survivors.reduce((sum, f) => sum + f.size, 0);
|
|
1237
|
+
let i = 0;
|
|
1238
|
+
while (total > this.maxTotalSize && i < survivors.length) {
|
|
1239
|
+
kill.add(survivors[i].full);
|
|
1240
|
+
total -= survivors[i].size;
|
|
1241
|
+
i++;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
961
1244
|
if (kill.size === 0)
|
|
962
1245
|
return;
|
|
963
1246
|
for (const file of kill) {
|
|
@@ -985,4 +1268,64 @@ function createStream(options) {
|
|
|
985
1268
|
return new RotateFileStream(options);
|
|
986
1269
|
}
|
|
987
1270
|
|
|
988
|
-
|
|
1271
|
+
const registry = new Set();
|
|
1272
|
+
let installed = false;
|
|
1273
|
+
let inProgress = false;
|
|
1274
|
+
/**
|
|
1275
|
+
* Register streams for graceful shutdown: on SIGINT/SIGTERM every
|
|
1276
|
+
* registered stream is ended — flushing buffered writes and finishing the
|
|
1277
|
+
* audit manifest — before the process exits, with a hard deadline.
|
|
1278
|
+
*
|
|
1279
|
+
* Safe to call multiple times: process listeners install once, streams
|
|
1280
|
+
* accumulate in the registry.
|
|
1281
|
+
*
|
|
1282
|
+
* Returns `flush()`, the same graceful end WITHOUT exiting — for tests
|
|
1283
|
+
* and custom signal handling.
|
|
1284
|
+
*
|
|
1285
|
+
* @example
|
|
1286
|
+
* const flush = installShutdown(logStream);
|
|
1287
|
+
* process.on("exit", () => { void flush(); });
|
|
1288
|
+
*/
|
|
1289
|
+
function installShutdown(streams, opts = {}) {
|
|
1290
|
+
const list = Array.isArray(streams) ? streams : [streams];
|
|
1291
|
+
for (const s of list)
|
|
1292
|
+
registry.add(s);
|
|
1293
|
+
if (!installed) {
|
|
1294
|
+
installed = true;
|
|
1295
|
+
const timeoutMs = opts.timeoutMs ?? 5_000;
|
|
1296
|
+
const code = opts.code ?? 0;
|
|
1297
|
+
for (const sig of opts.signals ?? ["SIGINT", "SIGTERM"]) {
|
|
1298
|
+
process.on(sig, () => {
|
|
1299
|
+
void flushAll(timeoutMs).finally(() => process.exit(code));
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return async () => {
|
|
1304
|
+
// flush FIRST — flushAll() drains the registry, so unregistering
|
|
1305
|
+
// before flushing would flush nothing (caught by the shutdown test)
|
|
1306
|
+
await flushAll(opts.timeoutMs ?? 5_000);
|
|
1307
|
+
for (const s of list)
|
|
1308
|
+
registry.delete(s);
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* End every registered stream (flush buffered writes) with a deadline.
|
|
1313
|
+
* Does NOT exit the process. Double-ending an already-ended stream is
|
|
1314
|
+
* harmless — the error lands in the ignored callback.
|
|
1315
|
+
*/
|
|
1316
|
+
async function flushAll(timeoutMs = 5_000) {
|
|
1317
|
+
if (inProgress)
|
|
1318
|
+
return;
|
|
1319
|
+
inProgress = true;
|
|
1320
|
+
const deadline = setTimeout(() => process.exit(1), timeoutMs);
|
|
1321
|
+
deadline.unref();
|
|
1322
|
+
try {
|
|
1323
|
+
await Promise.allSettled([...registry].map((s) => new Promise((res) => s.end(() => res()))));
|
|
1324
|
+
}
|
|
1325
|
+
finally {
|
|
1326
|
+
clearTimeout(deadline);
|
|
1327
|
+
inProgress = false;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
export { RotateFileStream, createStream, flushAll, installShutdown };
|