logroller 2.0.2 → 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 +18 -0
- package/README.md +232 -46
- package/dist/fsio.d.ts +3 -3
- package/dist/index.cjs +289 -33
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +288 -34
- package/dist/parsers.d.ts +2 -0
- package/dist/rotate-file-stream.d.ts +36 -4
- 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.0
|
|
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,21 +45,47 @@ 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
|
+
*/
|
|
55
76
|
function parseDuration(value, defaultUnit, fieldName) {
|
|
56
77
|
if (typeof value === "number") {
|
|
57
|
-
return Math.round(value *
|
|
78
|
+
return Math.round(value * DURATION_ALIASES[defaultUnit]);
|
|
58
79
|
}
|
|
59
|
-
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m
|
|
80
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(h(?:our)?s?|ms|s|m)?\s*$/i.exec(value);
|
|
60
81
|
if (!m)
|
|
61
82
|
throw new Error(`Invalid ${fieldName} "${value}"`);
|
|
62
|
-
|
|
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);
|
|
63
89
|
}
|
|
64
90
|
function parseSize(v) {
|
|
65
91
|
if (v === undefined || v === null || v === 0)
|
|
@@ -74,8 +100,11 @@ function parseSize(v) {
|
|
|
74
100
|
function parseMaxFiles(v) {
|
|
75
101
|
if (v === undefined || v === null || v === "" || v === 0)
|
|
76
102
|
return null;
|
|
77
|
-
if (typeof v === "number")
|
|
103
|
+
if (typeof v === "number") {
|
|
104
|
+
if (v < 0)
|
|
105
|
+
throw new Error(`Invalid maxFiles "${v}"`);
|
|
78
106
|
return { count: v };
|
|
107
|
+
}
|
|
79
108
|
const m = /^\s*(\d+)\s*d\s*$/i.exec(v);
|
|
80
109
|
if (m)
|
|
81
110
|
return { days: Number.parseInt(m[1], 10) };
|
|
@@ -99,15 +128,23 @@ function parseFrequency(v) {
|
|
|
99
128
|
throw new Error(`Invalid frequency "${v}"`);
|
|
100
129
|
return { type: "interval", ms: Number.parseInt(m[1], 10) * FREQ_MULT[m[2].toLowerCase()] };
|
|
101
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. */
|
|
102
138
|
function parseClockOffset(v) {
|
|
103
139
|
if (v === undefined || v === null || v === "")
|
|
104
140
|
return 0;
|
|
105
141
|
return parseDuration(v, "h", "addHours");
|
|
106
142
|
}
|
|
143
|
+
/** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
|
|
107
144
|
function parseClockStep(v) {
|
|
108
145
|
if (v === undefined || v === null || v === "")
|
|
109
146
|
return 0;
|
|
110
|
-
return parseDuration(v, "h", "
|
|
147
|
+
return parseDuration(v, "h", "addHoursEveryMin");
|
|
111
148
|
}
|
|
112
149
|
|
|
113
150
|
/* Timezone-aware helpers built exclusively on Intl (zero deps). */
|
|
@@ -214,17 +251,22 @@ const emptyAudit = (prefix, ext, datePattern, tz) => ({
|
|
|
214
251
|
});
|
|
215
252
|
/**
|
|
216
253
|
* A `Writable` that appends to dated log files, rolling segments on time
|
|
217
|
-
* and/or size, optionally gzipping sealed segments, enforcing retention
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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.
|
|
220
259
|
*
|
|
221
260
|
* @example
|
|
222
261
|
* ```ts
|
|
223
262
|
* const log = createStream({
|
|
224
263
|
* filename: "logs/app-%DATE%.log",
|
|
225
264
|
* maxSize: "20m",
|
|
265
|
+
* maxTotalSize: "5g",
|
|
226
266
|
* maxFiles: "90d",
|
|
227
267
|
* zippedArchive: true,
|
|
268
|
+
* symlink: true,
|
|
269
|
+
* onSeal: async ({ gz }) => uploadToS3(gz),
|
|
228
270
|
* });
|
|
229
271
|
* log.write("hello\n"); // or: readable.pipe(log)
|
|
230
272
|
* ```
|
|
@@ -239,6 +281,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
239
281
|
maxFiles;
|
|
240
282
|
freq;
|
|
241
283
|
unrefTimers;
|
|
284
|
+
maxTotalSize;
|
|
242
285
|
/* ---- filename-template pieces ---- */
|
|
243
286
|
_tL;
|
|
244
287
|
_tMid;
|
|
@@ -249,6 +292,10 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
249
292
|
_auditEnabled;
|
|
250
293
|
_auditPath;
|
|
251
294
|
_bootId = node_crypto.randomUUID();
|
|
295
|
+
_onSeal;
|
|
296
|
+
_sealHookTimeoutMs;
|
|
297
|
+
_compressionLevel;
|
|
298
|
+
_symlinkPath = null;
|
|
252
299
|
/* ---- virtual test clock ---- */
|
|
253
300
|
_offsetMs;
|
|
254
301
|
_stepMs;
|
|
@@ -279,11 +326,16 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
279
326
|
this.zippedArchive = !!opts.zippedArchive;
|
|
280
327
|
this.maxSize = parseSize(opts.maxSize ?? 0);
|
|
281
328
|
this.maxFiles = parseMaxFiles(opts.maxFiles);
|
|
329
|
+
this.maxTotalSize = parseSize(opts.maxTotalSize ?? 0);
|
|
282
330
|
this.freq = parseFrequency(opts.frequency);
|
|
283
331
|
this.unrefTimers = !!opts.unrefTimers;
|
|
284
332
|
this._offsetMs = parseClockOffset(opts.addHours);
|
|
285
333
|
this._stepMs = parseClockStep(opts.addHoursEveryMin);
|
|
286
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);
|
|
287
339
|
/* Split "prefix-%DATE%mid.ext" → pieces so indices land BETWEEN the
|
|
288
340
|
stamp and the extension: prefix-STAMP.N.ext */
|
|
289
341
|
const li = this.template.indexOf("%DATE%");
|
|
@@ -300,6 +352,12 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
300
352
|
const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log")
|
|
301
353
|
.replace(/^[-.]+|[-.]+$/g, "");
|
|
302
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;
|
|
303
361
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
304
362
|
this._curStamp = this._stampNow();
|
|
305
363
|
this._audit = emptyAudit(this._tL, this._tExt, this.datePattern, this.tz);
|
|
@@ -316,6 +374,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
316
374
|
offsetMs: this._offsetMs,
|
|
317
375
|
}));
|
|
318
376
|
}
|
|
377
|
+
// warn when frequency is finer than the datePattern tokens
|
|
319
378
|
process.nextTick(() => {
|
|
320
379
|
const warn = (m) => this.emit("warn", new Error(m));
|
|
321
380
|
if (this.freq.type === "interval") {
|
|
@@ -335,6 +394,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
335
394
|
constructor so it deterministically precedes any write job: zombie
|
|
336
395
|
demotion sees `_path === null`, gzip repair cannot race a reopen. */
|
|
337
396
|
this._job(async () => {
|
|
397
|
+
// disk-driven, audit-independent (runs even with audit:false)
|
|
338
398
|
await this._sealOrphanedPastStamps();
|
|
339
399
|
if (this._auditEnabled) {
|
|
340
400
|
this._demoteZombieActives();
|
|
@@ -343,8 +403,8 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
343
403
|
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
344
404
|
this._saveAudit();
|
|
345
405
|
}
|
|
346
|
-
if (this.maxFiles)
|
|
347
|
-
this._retire();
|
|
406
|
+
if (this.maxFiles || this.maxTotalSize > 0)
|
|
407
|
+
this._retire(); // budget policy also gates
|
|
348
408
|
}).catch(() => { });
|
|
349
409
|
this._armTimer();
|
|
350
410
|
this._armBump();
|
|
@@ -385,6 +445,60 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
385
445
|
advanceClock() {
|
|
386
446
|
return this._job(() => this._applyBump());
|
|
387
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
|
+
}
|
|
388
502
|
/* ==================================================================
|
|
389
503
|
* Writable contract
|
|
390
504
|
* ================================================================ */
|
|
@@ -517,6 +631,57 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
517
631
|
}
|
|
518
632
|
return idx;
|
|
519
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
|
+
}
|
|
520
685
|
async _ensureOpen() {
|
|
521
686
|
if (this._ws)
|
|
522
687
|
return;
|
|
@@ -526,6 +691,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
526
691
|
ws.on("error", (e) => this.emit("error", e));
|
|
527
692
|
this._ws = ws;
|
|
528
693
|
this._path = p;
|
|
694
|
+
this._updateSymlink(); // flip pointer on every open
|
|
529
695
|
if (this._auditEnabled) {
|
|
530
696
|
const base = path.basename(p);
|
|
531
697
|
// reopen = NEW incarnation. Never inherit lifecycle
|
|
@@ -568,9 +734,10 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
568
734
|
rec.bytes = realBytes;
|
|
569
735
|
this._saveAudit();
|
|
570
736
|
}
|
|
571
|
-
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);
|
|
572
738
|
if (ok) {
|
|
573
739
|
this._finishArchivedRecord(oldBase);
|
|
740
|
+
await this._runSealHook(oldPath, archive);
|
|
574
741
|
}
|
|
575
742
|
else {
|
|
576
743
|
if (rec) {
|
|
@@ -579,6 +746,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
579
746
|
}
|
|
580
747
|
this._aevent("gzip-failed", { file: oldBase });
|
|
581
748
|
archive = null;
|
|
749
|
+
await this._runSealHook(oldPath, null);
|
|
582
750
|
}
|
|
583
751
|
}
|
|
584
752
|
else if (realBytes === 0) {
|
|
@@ -589,6 +757,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
589
757
|
if (existsSafe(adoptedGz)) {
|
|
590
758
|
this._finishArchivedRecord(oldBase);
|
|
591
759
|
archive = adoptedGz;
|
|
760
|
+
await this._runSealHook(oldPath, adoptedGz);
|
|
592
761
|
}
|
|
593
762
|
else {
|
|
594
763
|
try {
|
|
@@ -602,9 +771,13 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
602
771
|
this._aevent("drop-empty", { file: oldBase });
|
|
603
772
|
}
|
|
604
773
|
}
|
|
605
|
-
else if (
|
|
606
|
-
|
|
607
|
-
rec
|
|
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);
|
|
608
781
|
}
|
|
609
782
|
}
|
|
610
783
|
let stamp = this._stampNow();
|
|
@@ -931,7 +1104,8 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
931
1104
|
const full = path.join(this.dir, name);
|
|
932
1105
|
if (!existsSafe(full) || statSizeSafe(full) === 0)
|
|
933
1106
|
continue;
|
|
934
|
-
const
|
|
1107
|
+
const gzName = `${full}.gz`;
|
|
1108
|
+
const ok = await gzipMove(full, gzName, (e) => this.emit("warn", e), this._compressionLevel);
|
|
935
1109
|
if (!ok)
|
|
936
1110
|
continue;
|
|
937
1111
|
if (this._auditEnabled && this._audit.files[name]) {
|
|
@@ -940,7 +1114,6 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
940
1114
|
}
|
|
941
1115
|
else {
|
|
942
1116
|
// untracked → record it fresh so future reconciles see truth
|
|
943
|
-
const gzName = `${name}.gz`;
|
|
944
1117
|
if (this._auditEnabled) {
|
|
945
1118
|
this._audit.files[gzName] = {
|
|
946
1119
|
state: "archived",
|
|
@@ -949,8 +1122,9 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
949
1122
|
};
|
|
950
1123
|
this._aevent("seal-past", { file: gzName });
|
|
951
1124
|
}
|
|
952
|
-
this.emit("archive",
|
|
1125
|
+
this.emit("archive", gzName);
|
|
953
1126
|
}
|
|
1127
|
+
await this._runSealHook(full, gzName);
|
|
954
1128
|
}
|
|
955
1129
|
}
|
|
956
1130
|
async _repairInterruptedGzips() {
|
|
@@ -973,11 +1147,13 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
973
1147
|
/* ignore */
|
|
974
1148
|
}
|
|
975
1149
|
this._finishArchivedRecord(name, true);
|
|
1150
|
+
await this._runSealHook(src, dst);
|
|
976
1151
|
continue;
|
|
977
1152
|
}
|
|
978
|
-
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);
|
|
979
1154
|
if (ok) {
|
|
980
1155
|
this._finishArchivedRecord(name, true);
|
|
1156
|
+
await this._runSealHook(src, dst);
|
|
981
1157
|
}
|
|
982
1158
|
else {
|
|
983
1159
|
rec.gzipPending = false;
|
|
@@ -1010,14 +1186,19 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
1010
1186
|
* ================================================================ */
|
|
1011
1187
|
async _retire() {
|
|
1012
1188
|
const policy = this.maxFiles;
|
|
1013
|
-
if (!policy)
|
|
1014
|
-
return;
|
|
1189
|
+
if (!policy && this.maxTotalSize <= 0)
|
|
1190
|
+
return; // budget gates too
|
|
1015
1191
|
const activeBase = this._path && path.basename(this._path);
|
|
1016
1192
|
const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
|
|
1017
1193
|
const reserved = [
|
|
1018
1194
|
path.basename(this._auditPath),
|
|
1019
1195
|
`${path.basename(this._auditPath)}.tmp`,
|
|
1020
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
|
+
: []),
|
|
1021
1202
|
];
|
|
1022
1203
|
try {
|
|
1023
1204
|
const dirEntries = await fs.promises.readdir(this.dir);
|
|
@@ -1029,7 +1210,8 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
1029
1210
|
for (const name of candidates) {
|
|
1030
1211
|
const full = path.join(this.dir, name);
|
|
1031
1212
|
try {
|
|
1032
|
-
|
|
1213
|
+
const st = await fs.promises.stat(full);
|
|
1214
|
+
stats.push({ full, mtimeMs: st.mtimeMs, size: st.size }); // size
|
|
1033
1215
|
}
|
|
1034
1216
|
catch {
|
|
1035
1217
|
/* raced deletion */
|
|
@@ -1037,18 +1219,30 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
1037
1219
|
}
|
|
1038
1220
|
stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
1039
1221
|
const kill = new Set();
|
|
1040
|
-
if (policy
|
|
1222
|
+
if (policy?.days != null) {
|
|
1041
1223
|
const cutoff = this._now() - policy.days * 86_400_000;
|
|
1042
1224
|
for (const f of stats)
|
|
1043
1225
|
if (f.mtimeMs < cutoff)
|
|
1044
1226
|
kill.add(f.full);
|
|
1045
1227
|
}
|
|
1046
|
-
if (policy
|
|
1228
|
+
if (policy?.count != null) {
|
|
1047
1229
|
const survivors = stats.filter((f) => !kill.has(f.full));
|
|
1048
1230
|
const excessCount = survivors.length - policy.count;
|
|
1049
1231
|
for (let i = 0; i < excessCount; i++)
|
|
1050
1232
|
kill.add(survivors[i].full);
|
|
1051
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
|
+
}
|
|
1052
1246
|
if (kill.size === 0)
|
|
1053
1247
|
return;
|
|
1054
1248
|
for (const file of kill) {
|
|
@@ -1076,5 +1270,67 @@ function createStream(options) {
|
|
|
1076
1270
|
return new RotateFileStream(options);
|
|
1077
1271
|
}
|
|
1078
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
|
+
|
|
1079
1333
|
exports.RotateFileStream = RotateFileStream;
|
|
1080
1334
|
exports.createStream = createStream;
|
|
1335
|
+
exports.flushAll = flushAll;
|
|
1336
|
+
exports.installShutdown = installShutdown;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
|
-
export {
|
|
7
|
-
export
|
|
6
|
+
export { createStream, RotateFileStream } from "./rotate-file-stream.js";
|
|
7
|
+
export { flushAll, installShutdown } from "./shutdown.js";
|
|
8
|
+
export type { ShutdownOptions } from "./shutdown.js";
|
|
9
|
+
export type { ClockInfo, RotatedInfo, RotateFileStreamEventMap, RotateFileStreamOptions, RotationReason, SealInfo, SegmentInfo, StreamStats } from "./types.js";
|