authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:24:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:24:38-07:00
loge042c25f568f297402ddfa76477e29aecddaa85f
tree2bcc8c2af1bd5c326ebae408d19a226d7b030b28
parent90f1936fade32015253aadff6d43f2271580f4c3
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 234 insertions(+), 13 deletions(-)

packages/shared/src/logging.test.ts created+151
...@@ -0,0 +1,151 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { afterEach, describe, expect, it } from "vite-plus/test";
6
7import {
8 RotatingFileSink,
9 RotatingFileSinkConfigurationError,
10 RotatingFileSinkError,
11} from "./logging.ts";
12
13const tempDirectories: string[] = [];
14
15const makeTempDirectory = (): string => {
16 const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-logging-"));
17 tempDirectories.push(directory);
18 return directory;
19};
20
21const 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
30afterEach(() => {
31 for (const directory of tempDirectories.splice(0)) {
32 NodeFS.rmSync(directory, { recursive: true, force: true });
33 }
34});
35
36describe("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 @@...@@ -1,6 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";3import * as NodePath from "node:path";
4import * as Schema from "effect/Schema";
45
5export interface RotatingFileSinkOptions {6export interface RotatingFileSinkOptions {
6 readonly filePath: string;7 readonly filePath: string;
...@@ -9,6 +10,37 @@ export interface RotatingFileSinkOptions {...@@ -9,6 +10,37 @@ export interface RotatingFileSinkOptions {
9 readonly throwOnError?: boolean;10 readonly throwOnError?: boolean;
10}11}
1112
13export 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
26export 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
39const isRotatingFileSinkError = Schema.is(RotatingFileSinkError);
40
41const isFileNotFoundError = (cause: unknown): cause is NodeJS.ErrnoException =>
42 cause instanceof Error && "code" in cause && cause.code === "ENOENT";
43
12export class RotatingFileSink {44export class RotatingFileSink {
13 private readonly filePath: string;45 private readonly filePath: string;
14 private readonly maxBytes: number;46 private readonly maxBytes: number;
...@@ -18,10 +50,18 @@ export class RotatingFileSink {...@@ -18,10 +50,18 @@ export class RotatingFileSink {
1850
19 constructor(options: RotatingFileSinkOptions) {51 constructor(options: RotatingFileSinkOptions) {
20 if (options.maxBytes < 1) {52 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 });
22 }58 }
23 if (options.maxFiles < 1) {59 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 });
25 }65 }
2666
27 this.filePath = options.filePath;67 this.filePath = options.filePath;
...@@ -29,7 +69,15 @@ export class RotatingFileSink {...@@ -29,7 +69,15 @@ export class RotatingFileSink {
29 this.maxFiles = options.maxFiles;69 this.maxFiles = options.maxFiles;
30 this.throwOnError = options.throwOnError ?? false;70 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 }
33 this.pruneOverflowBackups();81 this.pruneOverflowBackups();
34 this.currentSize = this.readCurrentSize();82 this.currentSize = this.readCurrentSize();
35 }83 }
...@@ -49,11 +97,18 @@ export class RotatingFileSink {...@@ -49,11 +97,18 @@ export class RotatingFileSink {
49 if (this.currentSize > this.maxBytes) {97 if (this.currentSize > this.maxBytes) {
50 this.rotate();98 this.rotate();
51 }99 }
52 } catch {100 } catch (cause) {
53 this.currentSize = this.readCurrentSize();101 if (isRotatingFileSinkError(cause)) {
102 throw cause;
103 }
54 if (this.throwOnError) {104 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 });
56 }110 }
111 this.currentSize = this.readCurrentSize();
57 }112 }
58 }113 }
59114
...@@ -77,11 +132,15 @@ export class RotatingFileSink {...@@ -77,11 +132,15 @@ export class RotatingFileSink {
77 }132 }
78133
79 this.currentSize = 0;134 this.currentSize = 0;
80 } catch {135 } catch (cause) {
81 this.currentSize = this.readCurrentSize();
82 if (this.throwOnError) {136 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 });
84 }142 }
143 this.currentSize = this.readCurrentSize();
85 }144 }
86 }145 }
87146
...@@ -95,9 +154,13 @@ export class RotatingFileSink {...@@ -95,9 +154,13 @@ export class RotatingFileSink {
95 if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue;154 if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue;
96 NodeFS.rmSync(NodePath.join(dir, entry), { force: true });155 NodeFS.rmSync(NodePath.join(dir, entry), { force: true });
97 }156 }
98 } catch {157 } catch (cause) {
99 if (this.throwOnError) {158 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 });
101 }164 }
102 }165 }
103 }166 }
...@@ -105,8 +168,15 @@ export class RotatingFileSink {...@@ -105,8 +168,15 @@ export class RotatingFileSink {
105 private readCurrentSize(): number {168 private readCurrentSize(): number {
106 try {169 try {
107 return NodeFS.statSync(this.filePath).size;170 return NodeFS.statSync(this.filePath).size;
108 } catch {171 } catch (cause) {
109 return 0;172 if (isFileNotFoundError(cause)) {
173 return 0;
174 }
175 throw new RotatingFileSinkError({
176 operation: "read",
177 filePath: this.filePath,
178 cause,
179 });
110 }180 }
111 }181 }
112182