@yixinshuke/node-log 0.0.0-stage → 0.0.1
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/AGENTS.md +449 -0
- package/README.md +195 -2
- package/package.json +38 -6
- package/src/buffer.ts +108 -0
- package/src/config.ts +112 -0
- package/src/index.ts +16 -0
- package/src/lock.ts +69 -0
- package/src/logger.ts +427 -0
- package/src/path.ts +124 -0
- package/src/time.ts +46 -0
- package/src/writer.ts +239 -0
package/src/writer.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件写入器(阶段二):节流后的批量追加写 —— AGENTS.md §2.3。
|
|
3
|
+
*
|
|
4
|
+
* 强制约束:
|
|
5
|
+
* - **只追加**:一律 `open(path,'a')`,绝不 truncate / `'w'` / 删除;
|
|
6
|
+
* - **写完即关**:fd 不常驻;
|
|
7
|
+
* - **按路径串行**:同一文件同一时刻只有一个写入者(进程内);
|
|
8
|
+
* - **跨进程互斥**:可选分布式锁,busy 时不写、返回 'busy'(调用方保留缓冲下轮写);
|
|
9
|
+
* - **写超时**:目标目录若是网络挂载,异常可能表现为长时间 hang,必须超时放弃;
|
|
10
|
+
* - **部分写补全**:单次 write 可能只写一部分,必须循环写完,否则日志被静默截断;
|
|
11
|
+
* - **绝不抛错**:所有异常转成状态码 + 告警。
|
|
12
|
+
*/
|
|
13
|
+
import { open, mkdir, stat } from 'fs/promises';
|
|
14
|
+
import { dirname, join } from 'path';
|
|
15
|
+
import type { FileHandle } from 'fs/promises';
|
|
16
|
+
import type { WriteLocker } from './lock.js';
|
|
17
|
+
|
|
18
|
+
export interface FileWriterOptions {
|
|
19
|
+
baseDir: string;
|
|
20
|
+
/** 单次 open/write 超时(毫秒) */
|
|
21
|
+
writeTimeoutMs?: number;
|
|
22
|
+
/** 文件权限位 */
|
|
23
|
+
fileMode?: number;
|
|
24
|
+
/** 单文件尺寸上限(字节),默认 1MB;达到即滚动到 `<stem>.N.log`;≤0 关闭分割 */
|
|
25
|
+
maxFileSizeBytes?: number;
|
|
26
|
+
onWarn?: (msg: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** ok = 已写入;busy = 锁繁忙(未写入,保留缓冲);error = 写入失败(保留缓冲) */
|
|
30
|
+
export type WriteStatus = 'ok' | 'busy' | 'error';
|
|
31
|
+
|
|
32
|
+
async function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
33
|
+
if (!ms || ms <= 0) return p;
|
|
34
|
+
let timer: NodeJS.Timeout | undefined;
|
|
35
|
+
try {
|
|
36
|
+
return await Promise.race([
|
|
37
|
+
p,
|
|
38
|
+
new Promise<never>((_resolve, reject) => {
|
|
39
|
+
timer = setTimeout(() => reject(new Error(`${label}超时(${ms}ms)`)), ms);
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
} finally {
|
|
43
|
+
if (timer) clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 循环补全:网络文件系统单次 write 可能只写一部分 */
|
|
48
|
+
async function writeAll(fh: FileHandle, chunk: string, timeoutMs: number): Promise<void> {
|
|
49
|
+
const buf = Buffer.from(chunk, 'utf8');
|
|
50
|
+
let off = 0;
|
|
51
|
+
while (off < buf.length) {
|
|
52
|
+
const { bytesWritten } = await withTimeout(fh.write(buf, off), timeoutMs, 'write');
|
|
53
|
+
if (!bytesWritten || bytesWritten <= 0) throw new Error('write 返回 0 字节,放弃本批');
|
|
54
|
+
off += bytesWritten;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class FileWriter {
|
|
59
|
+
private baseDir: string;
|
|
60
|
+
private writeTimeoutMs: number;
|
|
61
|
+
private readonly fileMode: number;
|
|
62
|
+
private readonly onWarn?: (msg: string) => void;
|
|
63
|
+
/** 单文件尺寸上限(字节);≤0 关闭分割 */
|
|
64
|
+
private maxFileSizeBytes: number;
|
|
65
|
+
|
|
66
|
+
/** 按相对路径的串行链(同一文件绝不并发写) */
|
|
67
|
+
private readonly chains = new Map<string, Promise<unknown>>();
|
|
68
|
+
/** 已成功创建的目录(挂载层 mkdir/stat 很慢,必须缓存) */
|
|
69
|
+
private readonly dirs = new Set<string>();
|
|
70
|
+
|
|
71
|
+
private locker: WriteLocker | null = null;
|
|
72
|
+
private lockerTtlMs: number;
|
|
73
|
+
|
|
74
|
+
/** 滚动文件序号上限(防止极端情况下无限循环) */
|
|
75
|
+
private static readonly MAX_ROTATE = 999;
|
|
76
|
+
|
|
77
|
+
constructor(options: FileWriterOptions) {
|
|
78
|
+
this.baseDir = options.baseDir;
|
|
79
|
+
this.writeTimeoutMs = options.writeTimeoutMs ?? 5000;
|
|
80
|
+
this.fileMode = options.fileMode ?? 0o600;
|
|
81
|
+
this.onWarn = options.onWarn;
|
|
82
|
+
this.maxFileSizeBytes = options.maxFileSizeBytes ?? 1024 * 1024;
|
|
83
|
+
this.lockerTtlMs = 10000;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setBaseDir(baseDir: string): void {
|
|
87
|
+
if (baseDir === this.baseDir) return;
|
|
88
|
+
this.baseDir = baseDir;
|
|
89
|
+
this.dirs.clear();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setWriteTimeoutMs(ms: number): void {
|
|
93
|
+
if (ms > 0) this.writeTimeoutMs = ms;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 设置单文件尺寸上限(字节);≤0 关闭分割 */
|
|
97
|
+
setMaxFileSizeBytes(bytes: number): void {
|
|
98
|
+
if (bytes > 0) this.maxFileSizeBytes = bytes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 挂载/卸载分布式锁(Redis 就绪后由 logger 注入) */
|
|
102
|
+
setLocker(locker: WriteLocker | null, ttlMs: number): void {
|
|
103
|
+
this.locker = locker ?? null;
|
|
104
|
+
if (ttlMs > 0) this.lockerTtlMs = ttlMs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 追加写入;同一路径串行执行 */
|
|
108
|
+
async write(rel: string, chunk: string): Promise<WriteStatus> {
|
|
109
|
+
const prev = this.chains.get(rel) ?? Promise.resolve();
|
|
110
|
+
const cur = prev.then(
|
|
111
|
+
() => this.doWrite(rel, chunk),
|
|
112
|
+
() => this.doWrite(rel, chunk)
|
|
113
|
+
);
|
|
114
|
+
// 链上只保留「永不 reject」的版本,避免前一个任务失败污染后续任务
|
|
115
|
+
this.chains.set(
|
|
116
|
+
rel,
|
|
117
|
+
cur.catch(() => undefined)
|
|
118
|
+
);
|
|
119
|
+
return cur;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 等待所有在途写完成(shutdown / flush 用) */
|
|
123
|
+
async drain(): Promise<void> {
|
|
124
|
+
const pending = Array.from(this.chains.values());
|
|
125
|
+
for (const p of pending) {
|
|
126
|
+
await p.catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async doWrite(rel: string, chunk: string): Promise<WriteStatus> {
|
|
131
|
+
const abs = join(this.baseDir, rel);
|
|
132
|
+
try {
|
|
133
|
+
await this.ensureDir(dirname(abs));
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
this.warn(`创建目录失败:${err?.message ?? err}`);
|
|
136
|
+
return 'error';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const write = async (): Promise<void> => {
|
|
140
|
+
// 按尺寸滚动:在互斥锁内(或单进程)解析实际落盘的相对路径,
|
|
141
|
+
// 避免跨进程/跨批对同一 bizKey 落到不同文件造成内容撕裂。
|
|
142
|
+
const targetRel = await this.resolveRotatedRel(rel, Buffer.byteLength(chunk, 'utf8'));
|
|
143
|
+
const targetAbs = join(this.baseDir, targetRel);
|
|
144
|
+
const fh = await withTimeout(open(targetAbs, 'a', this.fileMode), this.writeTimeoutMs, 'open');
|
|
145
|
+
try {
|
|
146
|
+
await writeAll(fh, chunk, this.writeTimeoutMs);
|
|
147
|
+
} finally {
|
|
148
|
+
await fh.close().catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (!this.locker) {
|
|
153
|
+
try {
|
|
154
|
+
await write();
|
|
155
|
+
return 'ok';
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
this.warn(`写入失败:${err?.message ?? err}`);
|
|
158
|
+
return 'error';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 标记 fn 是否已执行:node-lock 会把 fn 的异常向上抛,此时不能「直写重试」造成重复写入
|
|
163
|
+
let executed = false;
|
|
164
|
+
const guarded = async (): Promise<void> => {
|
|
165
|
+
executed = true;
|
|
166
|
+
await write();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let ran = false;
|
|
170
|
+
try {
|
|
171
|
+
ran = await this.locker(rel, guarded, this.lockerTtlMs);
|
|
172
|
+
} catch (err: any) {
|
|
173
|
+
if (executed) {
|
|
174
|
+
this.warn(`写入失败:${err?.message ?? err}`);
|
|
175
|
+
return 'error';
|
|
176
|
+
}
|
|
177
|
+
// 锁组件不可用 → 直写(日志不能因为锁挂了就不写)
|
|
178
|
+
try {
|
|
179
|
+
await write();
|
|
180
|
+
return 'ok';
|
|
181
|
+
} catch (err2: any) {
|
|
182
|
+
this.warn(`写入失败:${err2?.message ?? err2}`);
|
|
183
|
+
return 'error';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!ran) return 'busy'; // 锁繁忙:保留缓冲,下轮再写
|
|
188
|
+
return 'ok';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async ensureDir(dir: string): Promise<void> {
|
|
192
|
+
if (this.dirs.has(dir)) return;
|
|
193
|
+
try {
|
|
194
|
+
await mkdir(dir, { recursive: true });
|
|
195
|
+
this.dirs.add(dir);
|
|
196
|
+
} catch (err: any) {
|
|
197
|
+
if (err?.code === 'EEXIST') {
|
|
198
|
+
this.dirs.add(dir); // 多进程并发创建同一目录 → 视为成功
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 解析实际落盘的相对路径:当前文件达到 `maxFileSizeBytes` 时滚动到 `<stem>.N.log`。
|
|
207
|
+
*
|
|
208
|
+
* - 仅追加、不 rename:已写满的 `xxx.log` 冻结为归档,新内容追加到首个未超限的 `xxx.N.log`;
|
|
209
|
+
* - 自 base 起依次 stat,命中「不存在」或「现有大小 + 本批字节 ≤ 上限」即采用;
|
|
210
|
+
* - stat 非 ENOENT 异常(如权限)保守回退原 `rel`,绝不丢日志;
|
|
211
|
+
* - `maxFileSizeBytes ≤ 0` 直接返回原 `rel`(关闭分割)。
|
|
212
|
+
*/
|
|
213
|
+
private async resolveRotatedRel(rel: string, chunkLen: number): Promise<string> {
|
|
214
|
+
const max = this.maxFileSizeBytes;
|
|
215
|
+
if (!max || max <= 0) return rel;
|
|
216
|
+
const stem = rel.endsWith('.log') ? rel.slice(0, -4) : rel;
|
|
217
|
+
for (let i = 0; i <= FileWriter.MAX_ROTATE; i++) {
|
|
218
|
+
const cand = i === 0 ? rel : `${stem}.${i}.log`;
|
|
219
|
+
let size: number;
|
|
220
|
+
try {
|
|
221
|
+
const st = await stat(join(this.baseDir, cand));
|
|
222
|
+
size = st.size;
|
|
223
|
+
} catch (err: any) {
|
|
224
|
+
if (err?.code === 'ENOENT') return cand; // 该序号文件尚不存在 → 用它
|
|
225
|
+
return rel; // 其他异常保守回退,避免丢日志
|
|
226
|
+
}
|
|
227
|
+
if (size + chunkLen <= max) return cand;
|
|
228
|
+
}
|
|
229
|
+
return `${stem}.${FileWriter.MAX_ROTATE}.log`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private warn(msg: string): void {
|
|
233
|
+
try {
|
|
234
|
+
this.onWarn?.(msg);
|
|
235
|
+
} catch {
|
|
236
|
+
/* 告警本身绝不能抛错 */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|