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