History

[codex] Structure rotating log sink errors (#3279)

Julius Marminge committed 4 months ago

Commit e042c251 parent 90f19362 files · +234 −13Browse files

Unified · Split

2 files
  1. packages/shared/src/logging.test.ts +151 −0
  2. packages/shared/src/logging.ts +83 −13
packages/shared/src/logging.test.ts+151−0
@@ -0,0 +1,151 @@
1+// @effect-diagnostics nodeBuiltinImport:off
2+import * as NodeFS from "node:fs";
3+import * as NodeOS from "node:os";
4+import * as NodePath from "node:path";
5+import { afterEach, describe, expect, it } from "vite-plus/test";
6+
7+import {
8+ RotatingFileSink,
9+ RotatingFileSinkConfigurationError,
10+ RotatingFileSinkError,
11+} from "./logging.ts";
12+
13+const tempDirectories: string[] = [];
14+
15+const makeTempDirectory = (): string => {
16+ const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-"));
17+ tempDirectories.push(directory);
18+ return directory;
19+};
20+
21+const captureError = (run: () => unknown): unknown => {
22+ try {
23+ run();
24+ } catch (cause) {
25+ return cause;
26+ }
27+ throw new Error("Expected operation to throw");
28+};
29+
30+afterEach(() => {
31+ for (const directory of tempDirectories.splice(0)) {
32+ NodeFS.rmSync(directory, { recursive: true, force: true });
33+ }
34+});
35+
36+describe("RotatingFileSink", () => {
37+ it.each([
38+ { option: "maxBytes" as const, maxBytes: 0, maxFiles: 1 },
39+ { option: "maxFiles" as const, maxBytes: 1, maxFiles: 0 },
40+ ])("reports invalid $option configuration structurally", (input) => {
41+ const thrown = captureError(
42+ () =>
43+ new RotatingFileSink({
44+ filePath: "/unused/log.ndjson",
45+ maxBytes: input.maxBytes,
46+ maxFiles: input.maxFiles,
47+ }),
48+ );
49+
50+ expect(thrown).toBeInstanceOf(RotatingFileSinkConfigurationError);
51+ expect(thrown).toMatchObject({
52+ option: input.option,
53+ received: 0,
54+ minimum: 1,
55+ });
56+ expect((thrown as Error).message).toBe(`${input.option} must be >= 1 (received 0)`);
57+ });
58+
59+ it("preserves directory initialization failures", () => {
60+ const directory = makeTempDirectory();
61+ const parentFile = NodePath.join(directory, "not-a-directory");
62+ const filePath = NodePath.join(parentFile, "log.ndjson");
63+ NodeFS.writeFileSync(parentFile, "occupied");
64+
65+ const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 }));
66+
67+ expect(thrown).toBeInstanceOf(RotatingFileSinkError);
68+ expect(thrown).toMatchObject({ operation: "initialize", filePath });
69+ expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
70+ });
71+
72+ it("only treats a missing log file as an empty current size", () => {
73+ const directory = makeTempDirectory();
74+ const filePath = NodePath.join(directory, "a".repeat(300));
75+
76+ const thrown = captureError(() => new RotatingFileSink({ filePath, maxBytes: 1, maxFiles: 1 }));
77+
78+ expect(thrown).toBeInstanceOf(RotatingFileSinkError);
79+ expect(thrown).toMatchObject({ operation: "read", filePath });
80+ expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "ENAMETOOLONG" });
81+ });
82+
83+ it("starts an absent log file at zero bytes", () => {
84+ const directory = makeTempDirectory();
85+ const filePath = NodePath.join(directory, "log.ndjson");
86+ const sink = new RotatingFileSink({ filePath, maxBytes: 100, maxFiles: 1 });
87+
88+ sink.write("entry");
89+
90+ expect(NodeFS.readFileSync(filePath, "utf8")).toBe("entry");
91+ });
92+
93+ it("preserves write failures", () => {
94+ const directory = makeTempDirectory();
95+ const filePath = NodePath.join(directory, "log.ndjson");
96+ NodeFS.mkdirSync(filePath);
97+ const sink = new RotatingFileSink({
98+ filePath,
99+ maxBytes: Number.MAX_SAFE_INTEGER,
100+ maxFiles: 1,
101+ throwOnError: true,
102+ });
103+
104+ const thrown = captureError(() => sink.write("entry"));
105+
106+ expect(thrown).toBeInstanceOf(RotatingFileSinkError);
107+ expect(thrown).toMatchObject({ operation: "write", filePath });
108+ expect((thrown as RotatingFileSinkError).cause).toMatchObject({ code: "EISDIR" });
109+ });
110+
111+ it("preserves rotation failures without an artificial write wrapper", () => {
112+ const directory = makeTempDirectory();
113+ const filePath = NodePath.join(directory, "log.ndjson");
114+ NodeFS.writeFileSync(filePath, "a");
115+ NodeFS.mkdirSync(`${filePath}.1`);
116+ const sink = new RotatingFileSink({
117+ filePath,
118+ maxBytes: 1,
119+ maxFiles: 1,
120+ throwOnError: true,
121+ });
122+
123+ const thrown = captureError(() => sink.write("b"));
124+
125+ expect(thrown).toBeInstanceOf(RotatingFileSinkError);
126+ expect(thrown).toMatchObject({ operation: "rotate", filePath });
127+ expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
128+ });
129+
130+ it("preserves backup pruning failures", () => {
131+ const directory = makeTempDirectory();
132+ const filePath = NodePath.join(directory, "log.ndjson");
133+ const overflowBackup = `${filePath}.2`;
134+ NodeFS.mkdirSync(overflowBackup);
135+ NodeFS.writeFileSync(NodePath.join(overflowBackup, "entry"), "occupied");
136+
137+ const thrown = captureError(
138+ () =>
139+ new RotatingFileSink({
140+ filePath,
141+ maxBytes: 1,
142+ maxFiles: 1,
143+ throwOnError: true,
144+ }),
145+ );
146+
147+ expect(thrown).toBeInstanceOf(RotatingFileSinkError);
148+ expect(thrown).toMatchObject({ operation: "prune", filePath });
149+ expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
150+ });
151+});
packages/shared/src/logging.ts+83−13
@@ -1,6 +1,7 @@
11 // @effect-diagnostics nodeBuiltinImport:off
22 import * as NodeFS from "node:fs";
33 import * as NodePath from "node:path";
4+import * as Schema from "effect/Schema";
45
56 export interface RotatingFileSinkOptions {
67 readonly filePath: string;
@@ -9,6 +10,37 @@ export interface RotatingFileSinkOptions { · 2 unmodified lines
910 readonly throwOnError?: boolean;
1011 }
1112
13+export class RotatingFileSinkConfigurationError extends Schema.TaggedErrorClass<RotatingFileSinkConfigurationError>()(
14+ "RotatingFileSinkConfigurationError",
15+ {
16+ option: Schema.Literals(["maxBytes", "maxFiles"]),
17+ received: Schema.Number,
18+ minimum: Schema.Number,
19+ },
20+) {
21+ override get message(): string {
22+ return `${this.option} must be >= ${this.minimum} (received ${this.received})`;
23+ }
24+}
25+
26+export class RotatingFileSinkError extends Schema.TaggedErrorClass<RotatingFileSinkError>()(
27+ "RotatingFileSinkError",
28+ {
29+ operation: Schema.Literals(["initialize", "read", "write", "rotate", "prune"]),
30+ filePath: Schema.String,
31+ cause: Schema.Defect(),
32+ },
33+) {
34+ override get message(): string {
35+ return `Failed to ${this.operation} rotating log file ${this.filePath}`;
36+ }
37+}
38+
39+const isRotatingFileSinkError = Schema.is(RotatingFileSinkError);
40+
41+const isFileNotFoundError = (cause: unknown): cause is NodeJS.ErrnoException =>
42+ cause instanceof Error && "code" in cause && cause.code === "ENOENT";
43+
1244 export class RotatingFileSink {
1345 private readonly filePath: string;
1446 private readonly maxBytes: number;
@@ -18,10 +50,18 @@ export class RotatingFileSink { · 3 unmodified lines
1850
1951 constructor(options: RotatingFileSinkOptions) {
2052 if (options.maxBytes < 1) {
21− throw new Error(`maxBytes must be >= 1 (received ${options.maxBytes})`);
53+ throw new RotatingFileSinkConfigurationError({
54+ option: "maxBytes",
55+ received: options.maxBytes,
56+ minimum: 1,
57+ });
2258 }
2359 if (options.maxFiles < 1) {
24− throw new Error(`maxFiles must be >= 1 (received ${options.maxFiles})`);
60+ throw new RotatingFileSinkConfigurationError({
61+ option: "maxFiles",
62+ received: options.maxFiles,
63+ minimum: 1,
64+ });
2565 }
2666
2767 this.filePath = options.filePath;
@@ -29,7 +69,15 @@ export class RotatingFileSink { · 1 unmodified line
2969 this.maxFiles = options.maxFiles;
3070 this.throwOnError = options.throwOnError ?? false;
3171
32− NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true });
72+ try {
73+ NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true });
74+ } catch (cause) {
75+ throw new RotatingFileSinkError({
76+ operation: "initialize",
77+ filePath: this.filePath,
78+ cause,
79+ });
80+ }
3381 this.pruneOverflowBackups();
3482 this.currentSize = this.readCurrentSize();
3583 }
@@ -49,11 +97,18 @@ export class RotatingFileSink { · 13 unmodified lines
4997 if (this.currentSize > this.maxBytes) {
5098 this.rotate();
5199 }
52− } catch {
53− this.currentSize = this.readCurrentSize();
100+ } catch (cause) {
101+ if (isRotatingFileSinkError(cause)) {
102+ throw cause;
103+ }
54104 if (this.throwOnError) {
55− throw new Error(`Failed to write log chunk to ${this.filePath}`);
105+ throw new RotatingFileSinkError({
106+ operation: "write",
107+ filePath: this.filePath,
108+ cause,
109+ });
56110 }
111+ this.currentSize = this.readCurrentSize();
57112 }
58113 }
59114
@@ -77,11 +132,15 @@ export class RotatingFileSink { · 17 unmodified lines
77132 }
78133
79134 this.currentSize = 0;
80− } catch {
81− this.currentSize = this.readCurrentSize();
135+ } catch (cause) {
82136 if (this.throwOnError) {
83− throw new Error(`Failed to rotate log file ${this.filePath}`);
137+ throw new RotatingFileSinkError({
138+ operation: "rotate",
139+ filePath: this.filePath,
140+ cause,
141+ });
84142 }
143+ this.currentSize = this.readCurrentSize();
85144 }
86145 }
87146
@@ -95,9 +154,13 @@ export class RotatingFileSink { · 7 unmodified lines
95154 if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue;
96155 NodeFS.rmSync(NodePath.join(dir, entry), { force: true });
97156 }
98− } catch {
157+ } catch (cause) {
99158 if (this.throwOnError) {
100− throw new Error(`Failed to prune log backups for ${this.filePath}`);
159+ throw new RotatingFileSinkError({
160+ operation: "prune",
161+ filePath: this.filePath,
162+ cause,
163+ });
101164 }
102165 }
103166 }
@@ -105,8 +168,15 @@ export class RotatingFileSink { · 1 unmodified line
105168 private readCurrentSize(): number {
106169 try {
107170 return NodeFS.statSync(this.filePath).size;
108− } catch {
109− return 0;
171+ } catch (cause) {
172+ if (isFileNotFoundError(cause)) {
173+ return 0;
174+ }
175+ throw new RotatingFileSinkError({
176+ operation: "read",
177+ filePath: this.filePath,
178+ cause,
179+ });
110180 }
111181 }
112182