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.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! logroller v2.0
|
|
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,21 +43,47 @@ 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
|
+
*/
|
|
53
74
|
function parseDuration(value, defaultUnit, fieldName) {
|
|
54
75
|
if (typeof value === "number") {
|
|
55
|
-
return Math.round(value *
|
|
76
|
+
return Math.round(value * DURATION_ALIASES[defaultUnit]);
|
|
56
77
|
}
|
|
57
|
-
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m
|
|
78
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(h(?:our)?s?|ms|s|m)?\s*$/i.exec(value);
|
|
58
79
|
if (!m)
|
|
59
80
|
throw new Error(`Invalid ${fieldName} "${value}"`);
|
|
60
|
-
|
|
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);
|
|
61
87
|
}
|
|
62
88
|
function parseSize(v) {
|
|
63
89
|
if (v === undefined || v === null || v === 0)
|
|
@@ -72,8 +98,11 @@ function parseSize(v) {
|
|
|
72
98
|
function parseMaxFiles(v) {
|
|
73
99
|
if (v === undefined || v === null || v === "" || v === 0)
|
|
74
100
|
return null;
|
|
75
|
-
if (typeof v === "number")
|
|
101
|
+
if (typeof v === "number") {
|
|
102
|
+
if (v < 0)
|
|
103
|
+
throw new Error(`Invalid maxFiles "${v}"`);
|
|
76
104
|
return { count: v };
|
|
105
|
+
}
|
|
77
106
|
const m = /^\s*(\d+)\s*d\s*$/i.exec(v);
|
|
78
107
|
if (m)
|
|
79
108
|
return { days: Number.parseInt(m[1], 10) };
|
|
@@ -97,15 +126,23 @@ function parseFrequency(v) {
|
|
|
97
126
|
throw new Error(`Invalid frequency "${v}"`);
|
|
98
127
|
return { type: "interval", ms: Number.parseInt(m[1], 10) * FREQ_MULT[m[2].toLowerCase()] };
|
|
99
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. */
|
|
100
136
|
function parseClockOffset(v) {
|
|
101
137
|
if (v === undefined || v === null || v === "")
|
|
102
138
|
return 0;
|
|
103
139
|
return parseDuration(v, "h", "addHours");
|
|
104
140
|
}
|
|
141
|
+
/** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
|
|
105
142
|
function parseClockStep(v) {
|
|
106
143
|
if (v === undefined || v === null || v === "")
|
|
107
144
|
return 0;
|
|
108
|
-
return parseDuration(v, "h", "
|
|
145
|
+
return parseDuration(v, "h", "addHoursEveryMin");
|
|
109
146
|
}
|
|
110
147
|
|
|
111
148
|
/* Timezone-aware helpers built exclusively on Intl (zero deps). */
|
|
@@ -212,17 +249,22 @@ const emptyAudit = (prefix, ext, datePattern, tz) => ({
|
|
|
212
249
|
});
|
|
213
250
|
/**
|
|
214
251
|
* A `Writable` that appends to dated log files, rolling segments on time
|
|
215
|
-
* and/or size, optionally gzipping sealed segments, enforcing retention
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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.
|
|
218
257
|
*
|
|
219
258
|
* @example
|
|
220
259
|
* ```ts
|
|
221
260
|
* const log = createStream({
|
|
222
261
|
* filename: "logs/app-%DATE%.log",
|
|
223
262
|
* maxSize: "20m",
|
|
263
|
+
* maxTotalSize: "5g",
|
|
224
264
|
* maxFiles: "90d",
|
|
225
265
|
* zippedArchive: true,
|
|
266
|
+
* symlink: true,
|
|
267
|
+
* onSeal: async ({ gz }) => uploadToS3(gz),
|
|
226
268
|
* });
|
|
227
269
|
* log.write("hello\n"); // or: readable.pipe(log)
|
|
228
270
|
* ```
|
|
@@ -237,6 +279,7 @@ class RotateFileStream extends Writable {
|
|
|
237
279
|
maxFiles;
|
|
238
280
|
freq;
|
|
239
281
|
unrefTimers;
|
|
282
|
+
maxTotalSize;
|
|
240
283
|
/* ---- filename-template pieces ---- */
|
|
241
284
|
_tL;
|
|
242
285
|
_tMid;
|
|
@@ -247,6 +290,10 @@ class RotateFileStream extends Writable {
|
|
|
247
290
|
_auditEnabled;
|
|
248
291
|
_auditPath;
|
|
249
292
|
_bootId = randomUUID();
|
|
293
|
+
_onSeal;
|
|
294
|
+
_sealHookTimeoutMs;
|
|
295
|
+
_compressionLevel;
|
|
296
|
+
_symlinkPath = null;
|
|
250
297
|
/* ---- virtual test clock ---- */
|
|
251
298
|
_offsetMs;
|
|
252
299
|
_stepMs;
|
|
@@ -277,11 +324,16 @@ class RotateFileStream extends Writable {
|
|
|
277
324
|
this.zippedArchive = !!opts.zippedArchive;
|
|
278
325
|
this.maxSize = parseSize(opts.maxSize ?? 0);
|
|
279
326
|
this.maxFiles = parseMaxFiles(opts.maxFiles);
|
|
327
|
+
this.maxTotalSize = parseSize(opts.maxTotalSize ?? 0);
|
|
280
328
|
this.freq = parseFrequency(opts.frequency);
|
|
281
329
|
this.unrefTimers = !!opts.unrefTimers;
|
|
282
330
|
this._offsetMs = parseClockOffset(opts.addHours);
|
|
283
331
|
this._stepMs = parseClockStep(opts.addHoursEveryMin);
|
|
284
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);
|
|
285
337
|
/* Split "prefix-%DATE%mid.ext" → pieces so indices land BETWEEN the
|
|
286
338
|
stamp and the extension: prefix-STAMP.N.ext */
|
|
287
339
|
const li = this.template.indexOf("%DATE%");
|
|
@@ -298,6 +350,12 @@ class RotateFileStream extends Writable {
|
|
|
298
350
|
const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log")
|
|
299
351
|
.replace(/^[-.]+|[-.]+$/g, "");
|
|
300
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;
|
|
301
359
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
302
360
|
this._curStamp = this._stampNow();
|
|
303
361
|
this._audit = emptyAudit(this._tL, this._tExt, this.datePattern, this.tz);
|
|
@@ -314,6 +372,7 @@ class RotateFileStream extends Writable {
|
|
|
314
372
|
offsetMs: this._offsetMs,
|
|
315
373
|
}));
|
|
316
374
|
}
|
|
375
|
+
// warn when frequency is finer than the datePattern tokens
|
|
317
376
|
process.nextTick(() => {
|
|
318
377
|
const warn = (m) => this.emit("warn", new Error(m));
|
|
319
378
|
if (this.freq.type === "interval") {
|
|
@@ -333,6 +392,7 @@ class RotateFileStream extends Writable {
|
|
|
333
392
|
constructor so it deterministically precedes any write job: zombie
|
|
334
393
|
demotion sees `_path === null`, gzip repair cannot race a reopen. */
|
|
335
394
|
this._job(async () => {
|
|
395
|
+
// disk-driven, audit-independent (runs even with audit:false)
|
|
336
396
|
await this._sealOrphanedPastStamps();
|
|
337
397
|
if (this._auditEnabled) {
|
|
338
398
|
this._demoteZombieActives();
|
|
@@ -341,8 +401,8 @@ class RotateFileStream extends Writable {
|
|
|
341
401
|
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
342
402
|
this._saveAudit();
|
|
343
403
|
}
|
|
344
|
-
if (this.maxFiles)
|
|
345
|
-
this._retire();
|
|
404
|
+
if (this.maxFiles || this.maxTotalSize > 0)
|
|
405
|
+
this._retire(); // budget policy also gates
|
|
346
406
|
}).catch(() => { });
|
|
347
407
|
this._armTimer();
|
|
348
408
|
this._armBump();
|
|
@@ -383,6 +443,60 @@ class RotateFileStream extends Writable {
|
|
|
383
443
|
advanceClock() {
|
|
384
444
|
return this._job(() => this._applyBump());
|
|
385
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
|
+
}
|
|
386
500
|
/* ==================================================================
|
|
387
501
|
* Writable contract
|
|
388
502
|
* ================================================================ */
|
|
@@ -515,6 +629,57 @@ class RotateFileStream extends Writable {
|
|
|
515
629
|
}
|
|
516
630
|
return idx;
|
|
517
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
|
+
}
|
|
518
683
|
async _ensureOpen() {
|
|
519
684
|
if (this._ws)
|
|
520
685
|
return;
|
|
@@ -524,6 +689,7 @@ class RotateFileStream extends Writable {
|
|
|
524
689
|
ws.on("error", (e) => this.emit("error", e));
|
|
525
690
|
this._ws = ws;
|
|
526
691
|
this._path = p;
|
|
692
|
+
this._updateSymlink(); // flip pointer on every open
|
|
527
693
|
if (this._auditEnabled) {
|
|
528
694
|
const base = path.basename(p);
|
|
529
695
|
// reopen = NEW incarnation. Never inherit lifecycle
|
|
@@ -566,9 +732,10 @@ class RotateFileStream extends Writable {
|
|
|
566
732
|
rec.bytes = realBytes;
|
|
567
733
|
this._saveAudit();
|
|
568
734
|
}
|
|
569
|
-
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);
|
|
570
736
|
if (ok) {
|
|
571
737
|
this._finishArchivedRecord(oldBase);
|
|
738
|
+
await this._runSealHook(oldPath, archive);
|
|
572
739
|
}
|
|
573
740
|
else {
|
|
574
741
|
if (rec) {
|
|
@@ -577,6 +744,7 @@ class RotateFileStream extends Writable {
|
|
|
577
744
|
}
|
|
578
745
|
this._aevent("gzip-failed", { file: oldBase });
|
|
579
746
|
archive = null;
|
|
747
|
+
await this._runSealHook(oldPath, null);
|
|
580
748
|
}
|
|
581
749
|
}
|
|
582
750
|
else if (realBytes === 0) {
|
|
@@ -587,6 +755,7 @@ class RotateFileStream extends Writable {
|
|
|
587
755
|
if (existsSafe(adoptedGz)) {
|
|
588
756
|
this._finishArchivedRecord(oldBase);
|
|
589
757
|
archive = adoptedGz;
|
|
758
|
+
await this._runSealHook(oldPath, adoptedGz);
|
|
590
759
|
}
|
|
591
760
|
else {
|
|
592
761
|
try {
|
|
@@ -600,9 +769,13 @@ class RotateFileStream extends Writable {
|
|
|
600
769
|
this._aevent("drop-empty", { file: oldBase });
|
|
601
770
|
}
|
|
602
771
|
}
|
|
603
|
-
else if (
|
|
604
|
-
|
|
605
|
-
rec
|
|
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);
|
|
606
779
|
}
|
|
607
780
|
}
|
|
608
781
|
let stamp = this._stampNow();
|
|
@@ -929,7 +1102,8 @@ class RotateFileStream extends Writable {
|
|
|
929
1102
|
const full = path.join(this.dir, name);
|
|
930
1103
|
if (!existsSafe(full) || statSizeSafe(full) === 0)
|
|
931
1104
|
continue;
|
|
932
|
-
const
|
|
1105
|
+
const gzName = `${full}.gz`;
|
|
1106
|
+
const ok = await gzipMove(full, gzName, (e) => this.emit("warn", e), this._compressionLevel);
|
|
933
1107
|
if (!ok)
|
|
934
1108
|
continue;
|
|
935
1109
|
if (this._auditEnabled && this._audit.files[name]) {
|
|
@@ -938,7 +1112,6 @@ class RotateFileStream extends Writable {
|
|
|
938
1112
|
}
|
|
939
1113
|
else {
|
|
940
1114
|
// untracked → record it fresh so future reconciles see truth
|
|
941
|
-
const gzName = `${name}.gz`;
|
|
942
1115
|
if (this._auditEnabled) {
|
|
943
1116
|
this._audit.files[gzName] = {
|
|
944
1117
|
state: "archived",
|
|
@@ -947,8 +1120,9 @@ class RotateFileStream extends Writable {
|
|
|
947
1120
|
};
|
|
948
1121
|
this._aevent("seal-past", { file: gzName });
|
|
949
1122
|
}
|
|
950
|
-
this.emit("archive",
|
|
1123
|
+
this.emit("archive", gzName);
|
|
951
1124
|
}
|
|
1125
|
+
await this._runSealHook(full, gzName);
|
|
952
1126
|
}
|
|
953
1127
|
}
|
|
954
1128
|
async _repairInterruptedGzips() {
|
|
@@ -971,11 +1145,13 @@ class RotateFileStream extends Writable {
|
|
|
971
1145
|
/* ignore */
|
|
972
1146
|
}
|
|
973
1147
|
this._finishArchivedRecord(name, true);
|
|
1148
|
+
await this._runSealHook(src, dst);
|
|
974
1149
|
continue;
|
|
975
1150
|
}
|
|
976
|
-
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);
|
|
977
1152
|
if (ok) {
|
|
978
1153
|
this._finishArchivedRecord(name, true);
|
|
1154
|
+
await this._runSealHook(src, dst);
|
|
979
1155
|
}
|
|
980
1156
|
else {
|
|
981
1157
|
rec.gzipPending = false;
|
|
@@ -1008,14 +1184,19 @@ class RotateFileStream extends Writable {
|
|
|
1008
1184
|
* ================================================================ */
|
|
1009
1185
|
async _retire() {
|
|
1010
1186
|
const policy = this.maxFiles;
|
|
1011
|
-
if (!policy)
|
|
1012
|
-
return;
|
|
1187
|
+
if (!policy && this.maxTotalSize <= 0)
|
|
1188
|
+
return; // budget gates too
|
|
1013
1189
|
const activeBase = this._path && path.basename(this._path);
|
|
1014
1190
|
const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
|
|
1015
1191
|
const reserved = [
|
|
1016
1192
|
path.basename(this._auditPath),
|
|
1017
1193
|
`${path.basename(this._auditPath)}.tmp`,
|
|
1018
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
|
+
: []),
|
|
1019
1200
|
];
|
|
1020
1201
|
try {
|
|
1021
1202
|
const dirEntries = await fs.promises.readdir(this.dir);
|
|
@@ -1027,7 +1208,8 @@ class RotateFileStream extends Writable {
|
|
|
1027
1208
|
for (const name of candidates) {
|
|
1028
1209
|
const full = path.join(this.dir, name);
|
|
1029
1210
|
try {
|
|
1030
|
-
|
|
1211
|
+
const st = await fs.promises.stat(full);
|
|
1212
|
+
stats.push({ full, mtimeMs: st.mtimeMs, size: st.size }); // size
|
|
1031
1213
|
}
|
|
1032
1214
|
catch {
|
|
1033
1215
|
/* raced deletion */
|
|
@@ -1035,18 +1217,30 @@ class RotateFileStream extends Writable {
|
|
|
1035
1217
|
}
|
|
1036
1218
|
stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
1037
1219
|
const kill = new Set();
|
|
1038
|
-
if (policy
|
|
1220
|
+
if (policy?.days != null) {
|
|
1039
1221
|
const cutoff = this._now() - policy.days * 86_400_000;
|
|
1040
1222
|
for (const f of stats)
|
|
1041
1223
|
if (f.mtimeMs < cutoff)
|
|
1042
1224
|
kill.add(f.full);
|
|
1043
1225
|
}
|
|
1044
|
-
if (policy
|
|
1226
|
+
if (policy?.count != null) {
|
|
1045
1227
|
const survivors = stats.filter((f) => !kill.has(f.full));
|
|
1046
1228
|
const excessCount = survivors.length - policy.count;
|
|
1047
1229
|
for (let i = 0; i < excessCount; i++)
|
|
1048
1230
|
kill.add(survivors[i].full);
|
|
1049
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
|
+
}
|
|
1050
1244
|
if (kill.size === 0)
|
|
1051
1245
|
return;
|
|
1052
1246
|
for (const file of kill) {
|
|
@@ -1074,4 +1268,64 @@ function createStream(options) {
|
|
|
1074
1268
|
return new RotateFileStream(options);
|
|
1075
1269
|
}
|
|
1076
1270
|
|
|
1077
|
-
|
|
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 };
|
package/dist/parsers.d.ts
CHANGED
|
@@ -12,5 +12,7 @@ export type ParsedFrequency = {
|
|
|
12
12
|
export declare function parseSize(v: RotateFileStreamOptions["maxSize"]): number;
|
|
13
13
|
export declare function parseMaxFiles(v: RotateFileStreamOptions["maxFiles"]): ParsedMaxFiles | null;
|
|
14
14
|
export declare function parseFrequency(v: RotateFileStreamOptions["frequency"]): ParsedFrequency;
|
|
15
|
+
/** Static virtual-clock shift. Accepts hours by default; h/hours/m/s/ms. */
|
|
15
16
|
export declare function parseClockOffset(v: RotateFileStreamOptions["addHours"]): number;
|
|
17
|
+
/** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
|
|
16
18
|
export declare function parseClockStep(v: RotateFileStreamOptions["addHoursEveryMin"]): number;
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { Writable } from "node:stream";
|
|
2
2
|
import { type ParsedFrequency, type ParsedMaxFiles } from "./parsers.js";
|
|
3
|
-
import type { RotateFileStreamEventMap, RotateFileStreamOptions } from "./types.js";
|
|
3
|
+
import type { RotateFileStreamEventMap, RotateFileStreamOptions, SegmentInfo, StreamStats } from "./types.js";
|
|
4
4
|
/**
|
|
5
5
|
* A `Writable` that appends to dated log files, rolling segments on time
|
|
6
|
-
* and/or size, optionally gzipping sealed segments, enforcing retention
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* and/or size, optionally gzipping sealed segments, enforcing retention
|
|
7
|
+
* (count, age, and total-byte budget), maintaining a stable "current"
|
|
8
|
+
* symlink for log collectors, running an awaited seal hook (upload
|
|
9
|
+
* pipeline), and resuming an unbroken series across restarts via disk
|
|
10
|
+
* scan + audit manifest.
|
|
9
11
|
*
|
|
10
12
|
* @example
|
|
11
13
|
* ```ts
|
|
12
14
|
* const log = createStream({
|
|
13
15
|
* filename: "logs/app-%DATE%.log",
|
|
14
16
|
* maxSize: "20m",
|
|
17
|
+
* maxTotalSize: "5g",
|
|
15
18
|
* maxFiles: "90d",
|
|
16
19
|
* zippedArchive: true,
|
|
20
|
+
* symlink: true,
|
|
21
|
+
* onSeal: async ({ gz }) => uploadToS3(gz),
|
|
17
22
|
* });
|
|
18
23
|
* log.write("hello\n"); // or: readable.pipe(log)
|
|
19
24
|
* ```
|
|
@@ -28,6 +33,7 @@ export declare class RotateFileStream extends Writable {
|
|
|
28
33
|
readonly maxFiles: Readonly<ParsedMaxFiles> | null;
|
|
29
34
|
readonly freq: ParsedFrequency;
|
|
30
35
|
readonly unrefTimers: boolean;
|
|
36
|
+
readonly maxTotalSize: number;
|
|
31
37
|
private readonly _tL;
|
|
32
38
|
private readonly _tMid;
|
|
33
39
|
private readonly _tExt;
|
|
@@ -36,6 +42,10 @@ export declare class RotateFileStream extends Writable {
|
|
|
36
42
|
private readonly _auditEnabled;
|
|
37
43
|
private readonly _auditPath;
|
|
38
44
|
private readonly _bootId;
|
|
45
|
+
private readonly _onSeal?;
|
|
46
|
+
private readonly _sealHookTimeoutMs;
|
|
47
|
+
private readonly _compressionLevel;
|
|
48
|
+
private _symlinkPath;
|
|
39
49
|
private _offsetMs;
|
|
40
50
|
private _stepMs;
|
|
41
51
|
private _bumpEvery;
|
|
@@ -69,6 +79,15 @@ export declare class RotateFileStream extends Writable {
|
|
|
69
79
|
* archive flow as a real midnight roll.
|
|
70
80
|
*/
|
|
71
81
|
advanceClock(): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* Every family file on disk, oldest stamp first.
|
|
84
|
+
* Disk is truth: untracked files are reported with state "untracked".
|
|
85
|
+
*/
|
|
86
|
+
listSegments(): SegmentInfo[];
|
|
87
|
+
/**
|
|
88
|
+
* Health-check snapshot for dashboards and support triage.
|
|
89
|
+
*/
|
|
90
|
+
stats(): StreamStats;
|
|
72
91
|
_write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void;
|
|
73
92
|
_final(callback: (error?: Error | null) => void): void;
|
|
74
93
|
destroy(...args: Parameters<Writable["destroy"]>): this;
|
|
@@ -89,6 +108,19 @@ export declare class RotateFileStream extends Writable {
|
|
|
89
108
|
* The audit pointer (same stamp only) may only RAISE the result.
|
|
90
109
|
*/
|
|
91
110
|
private _recoverIndexFor;
|
|
111
|
+
/**
|
|
112
|
+
* Atomically flip the "current" symlink to the active segment.
|
|
113
|
+
* tmp + rename so followers (`tail -f`, Filebeat) never observe a gap.
|
|
114
|
+
* Relative target keeps the link portable. Best-effort: filesystems
|
|
115
|
+
* denying symlinks warn once and disable the feature permanently.
|
|
116
|
+
*/
|
|
117
|
+
private _updateSymlink;
|
|
118
|
+
/**
|
|
119
|
+
* Await the user's onSeal hook with a hard timeout. Result or
|
|
120
|
+
* failure degrades to `warn` — a wedged upload must never stall rotation.
|
|
121
|
+
* Called BEFORE retention so uploads finish before deletion.
|
|
122
|
+
*/
|
|
123
|
+
private _runSealHook;
|
|
92
124
|
private _ensureOpen;
|
|
93
125
|
private _rotate;
|
|
94
126
|
private _hasContent;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RotateFileStream } from "./rotate-file-stream.js";
|
|
2
|
+
export interface ShutdownOptions {
|
|
3
|
+
/** Signals to listen on. @default ["SIGINT", "SIGTERM"] */
|
|
4
|
+
signals?: NodeJS.Signals[];
|
|
5
|
+
/** Hard-exit deadline (ms) if a stream refuses to end.
|
|
6
|
+
* @default 5000 */
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
/** Process exit code. @default 0 */
|
|
9
|
+
code?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Register streams for graceful shutdown: on SIGINT/SIGTERM every
|
|
13
|
+
* registered stream is ended — flushing buffered writes and finishing the
|
|
14
|
+
* audit manifest — before the process exits, with a hard deadline.
|
|
15
|
+
*
|
|
16
|
+
* Safe to call multiple times: process listeners install once, streams
|
|
17
|
+
* accumulate in the registry.
|
|
18
|
+
*
|
|
19
|
+
* Returns `flush()`, the same graceful end WITHOUT exiting — for tests
|
|
20
|
+
* and custom signal handling.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const flush = installShutdown(logStream);
|
|
24
|
+
* process.on("exit", () => { void flush(); });
|
|
25
|
+
*/
|
|
26
|
+
export declare function installShutdown(streams: RotateFileStream | RotateFileStream[], opts?: ShutdownOptions): () => Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* End every registered stream (flush buffered writes) with a deadline.
|
|
29
|
+
* Does NOT exit the process. Double-ending an already-ended stream is
|
|
30
|
+
* harmless — the error lands in the ignored callback.
|
|
31
|
+
*/
|
|
32
|
+
export declare function flushAll(timeoutMs?: number): Promise<void>;
|