Skip to content

Commit 7930675

Browse files
committed
fix(logging): rotate the shared log without dropping the other writer
A flush stats the active file under an exclusive lock and adopts a replaced file before rotating. The active file is renamed aside, the previous generation is removed, and that temp is published as .1, so an existing generation does not stop the cap. A second rotation in the same flush waits until the new file is already over the cap.
1 parent b004cef commit 7930675

2 files changed

Lines changed: 315 additions & 16 deletions

File tree

‎src/logging/sink.test.ts‎

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ describe("installFileLogSink", () => {
9999
const later = lineIndex(active);
100100
expect(earlier).toBeLessThan(later);
101101
expect(active).not.toContain(`rotate-line-${earlier}-`);
102-
expect(Buffer.byteLength(previous)).toBeLessThanOrEqual(500);
102+
// The retained generation is the file that crossed the cap, so it can
103+
// pass the cap by the record that crossed it. The active file stays bounded.
104+
expect(Buffer.byteLength(previous)).toBeGreaterThan(500);
105+
expect(Buffer.byteLength(previous)).toBeLessThanOrEqual(500 + 200);
103106
expect(Buffer.byteLength(active)).toBeLessThanOrEqual(500);
104107
expect(previous + active).not.toContain("rotate-line-0-");
105108
expect(active).toContain("rotate-line-39-");
@@ -108,6 +111,121 @@ describe("installFileLogSink", () => {
108111
}
109112
});
110113

114+
test("a second writer flushing one line keeps the other writer's rotated log", () => {
115+
const { dir, file } = tempLogFile();
116+
try {
117+
installFileLogSink(file, { maxBytes: 300 });
118+
const first = getLogger(["logging", "share-a"]);
119+
first.info("first-marker");
120+
first.info("first-marker-b");
121+
first.info("short-active");
122+
flushFileLogSinkSync();
123+
const rotated = `${file}.1`;
124+
const marker = fs.readFileSync(rotated, "utf8");
125+
const activeBefore = fs.readFileSync(file, "utf8");
126+
expect(marker).toContain("first-marker");
127+
expect(activeBefore).toContain("short-active");
128+
expect(Buffer.byteLength(activeBefore)).toBeLessThan(300);
129+
130+
installFileLogSink(file, { maxBytes: 300 });
131+
getLogger(["logging", "share-b"]).info("b-one");
132+
flushFileLogSinkSync();
133+
134+
expect(fs.readFileSync(rotated, "utf8")).toBe(marker);
135+
const active = fs.readFileSync(file, "utf8");
136+
expect(active.startsWith(activeBefore)).toBe(true);
137+
expect(active).toContain("b-one");
138+
} finally {
139+
fs.rmSync(dir, { recursive: true, force: true });
140+
}
141+
});
142+
143+
test("crossing the cap once keeps the previous active file as .1", () => {
144+
const { dir, file } = tempLogFile();
145+
try {
146+
installFileLogSink(file, { maxBytes: 300 });
147+
const first = getLogger(["logging", "cross-a"]);
148+
first.info("first-marker");
149+
first.info("first-marker-b");
150+
first.info("short-active");
151+
flushFileLogSinkSync();
152+
const rotated = `${file}.1`;
153+
const activeBefore = fs.readFileSync(file, "utf8");
154+
expect(activeBefore).toContain("short-active");
155+
156+
installFileLogSink(file, { maxBytes: 300 });
157+
const second = getLogger(["logging", "cross-b"]);
158+
const wide = `b-wide-${"x".repeat(80)}`;
159+
second.info(wide);
160+
second.info(`${wide}-2`);
161+
flushFileLogSinkSync();
162+
163+
const kept = fs.readFileSync(rotated, "utf8");
164+
expect(kept).toContain("short-active");
165+
expect(kept).not.toContain("b-wide-");
166+
const active = fs.readFileSync(file, "utf8");
167+
expect(active).toContain(wide);
168+
expect(active).toContain(`${wide}-2`);
169+
expect(Buffer.byteLength(activeBefore)).toBeLessThan(300);
170+
} finally {
171+
fs.rmSync(dir, { recursive: true, force: true });
172+
}
173+
});
174+
175+
test("a stale size does not rotate a short file another process just created", () => {
176+
const { dir, file } = tempLogFile();
177+
try {
178+
installFileLogSink(file, { maxBytes: 500 });
179+
const logger = getLogger(["logging", "stale"]);
180+
logger.info("x".repeat(600));
181+
flushFileLogSinkSync();
182+
expect(fs.statSync(file).size).toBeGreaterThan(500);
183+
expect(fs.existsSync(`${file}.1`)).toBe(false);
184+
185+
fs.rmSync(file);
186+
fs.writeFileSync(file, "short-marker\n");
187+
logger.info("one-line");
188+
flushFileLogSinkSync();
189+
190+
expect(fs.existsSync(`${file}.1`)).toBe(false);
191+
const active = fs.readFileSync(file, "utf8");
192+
expect(active).toContain("short-marker");
193+
expect(active).toContain("one-line");
194+
} finally {
195+
fs.rmSync(dir, { recursive: true, force: true });
196+
}
197+
});
198+
199+
test("renaming onto an existing generation still bounds the active file", () => {
200+
const { dir, file } = tempLogFile();
201+
const renameSync = fs.renameSync.bind(fs);
202+
const rename = spyOn(fs, "renameSync").mockImplementation((from, to) => {
203+
if (fs.existsSync(to)) {
204+
throw Object.assign(new Error("EPERM, rename"), { code: "EPERM" });
205+
}
206+
return renameSync(from, to);
207+
});
208+
try {
209+
installFileLogSink(file, { maxBytes: 500 });
210+
const logger = getLogger(["logging", "eperm"]);
211+
for (let i = 0; i < 40; i++) {
212+
logger.info(`eperm-line-${i}-xxxxxxxxxxxxxxxxxxxx`);
213+
}
214+
flushFileLogSinkSync();
215+
expect(fs.statSync(`${file}.1`).isFile()).toBe(true);
216+
expect(fs.statSync(file).size).toBeLessThanOrEqual(500 + 200);
217+
for (let i = 0; i < 10; i++) {
218+
logger.info(`eperm-more-${i}-xxxxxxxxxxxxxxxxxxxx`);
219+
}
220+
flushFileLogSinkSync();
221+
expect(fs.statSync(file).size).toBeLessThanOrEqual(500 + 200);
222+
expect(fs.readFileSync(file, "utf8").length).toBeGreaterThan(0);
223+
} finally {
224+
rename.mockRestore();
225+
fs.rmSync(dir, { recursive: true, force: true });
226+
}
227+
});
228+
111229
test("a failed rotation keeps already-written records in the active file", () => {
112230
const { dir, file } = tempLogFile();
113231
try {

0 commit comments

Comments
 (0)