fix(log): rotate core/sub-store logs by date and avoid O(n²) rewrite at size cap (#1913)

内核与 sub-store 日志通过长连接 Writable 流写入,其路径在流创建时只解析一次,
因此永远不按日期轮转——所有输出都持续追加到以内核启动日命名的文件(例如
core-2026-05-27.log 里写满了 06-12 的日志)。

当该文件达到大小上限后,之后每写一行都会触发「读取尾部 + 重写整个文件」
(每行约 2×maxBytes 的 I/O)。配合 info 级别内核日志(每秒数百行),这会打满
磁盘 I/O 并反压内核进程,导致整个 App 卡顿。

- createCappedLogWritableStream 现在接受路径工厂函数 `() => string`,在每次写入
  时求值,使内核/sub-store 日志能像 app 日志一样按日期轮转;仍兼容字符串路径。
- 触顶压缩后只保留约一半上限的内容,使后续写入回到廉价的 appendFile 分支,把
  压缩频率从「每次写入」降到「每写满约半个上限一次」,整体 I/O 由 O(n²) 降为 O(n)。

Refs #1912

Co-authored-by: Tinglong Yang <11896584+ytlm@users.noreply.github.com>
This commit is contained in:
Tinglong Yang
2026-06-12 18:09:55 +08:00
committed by GitHub
parent 1128616a57
commit 38e33eea2a
3 changed files with 24 additions and 10 deletions

View File

@@ -248,8 +248,8 @@ function spawnCoreProcess(config: CoreConfig): ChildProcess {
}
if (!detached) {
const stdout = createCappedLogWritableStream(coreLogPath())
const stderr = createCappedLogWritableStream(coreLogPath())
const stdout = createCappedLogWritableStream(coreLogPath)
const stderr = createCappedLogWritableStream(coreLogPath)
proc.stdout?.pipe(stdout)
proc.stderr?.pipe(stderr)
}

View File

@@ -110,8 +110,8 @@ export async function startSubStoreBackendServer(): Promise<void> {
subStorePort = await findAvailablePort(38324)
const icon = nativeImage.createFromPath(subStoreIcon)
icon.toDataURL()
const stdout = createCappedLogWritableStream(substoreLogPath())
const stderr = createCappedLogWritableStream(substoreLogPath())
const stdout = createCappedLogWritableStream(substoreLogPath)
const stderr = createCappedLogWritableStream(substoreLogPath)
const env = {
SUB_STORE_BACKEND_API_PORT: subStorePort.toString(),
SUB_STORE_BACKEND_API_HOST: subStoreHost,

View File

@@ -5,6 +5,15 @@ const MB = 1024 * 1024
const DEFAULT_MAX_LOG_FILE_SIZE_MB = 10
const MIN_MAX_LOG_FILE_SIZE_MB = 1
const TRUNCATE_MARKER = Buffer.from('\n[LOG] File truncated because size limit reached.\n')
// 触顶压缩后保留的内容比例。压缩后文件回落到约 maxBytes * RATIO 大小,
// 使后续写入重新走廉价的 appendFile 分支,把压缩频率从「每次写入」降到
// 「每写满约该比例的上限一次」,整体 I/O 从 O(n²) 降为 O(n)。
const COMPACTION_RETAIN_RATIO = 0.5
// 日志文件路径既可以是固定字符串,也可以是一个在每次写入时求值的工厂函数。
// 对于内核 / sub-store 这类长连接日志流,必须传入工厂函数,否则路径会在流创建
// 时被固化,导致日志永远写入同一个文件、无法按日期轮转。
type LogFilePath = string | (() => string)
interface LogFileState {
queue: Promise<void>
@@ -105,7 +114,11 @@ async function appendToFileWithLimitInternal(
return
}
const keepBytes = Math.max(0, maxBytes - data.length - TRUNCATE_MARKER.length)
// 触顶后只保留约 maxBytes * COMPACTION_RETAIN_RATIO 的最近内容,使压缩后文件
// 远低于上限,接下来的多次写入都能走廉价的 appendFile避免「文件一旦到达上限后
// 每写一行都重读 + 重写整个文件」的 O(n²) I/O 放大。
const retainBudget = Math.floor(maxBytes * COMPACTION_RETAIN_RATIO)
const keepBytes = Math.max(0, retainBudget - data.length - TRUNCATE_MARKER.length)
const tail = await readTail(filePath, keepBytes)
let rewritten = Buffer.concat([tail, TRUNCATE_MARKER, data])
@@ -135,12 +148,12 @@ export async function appendToFileWithLimit(
}
class CappedLogWritable extends Writable {
private readonly filePath: string
private readonly resolvePath: () => string
private readonly maxBytes: number
constructor(filePath: string, maxBytes: number) {
constructor(filePath: LogFilePath, maxBytes: number) {
super()
this.filePath = filePath
this.resolvePath = typeof filePath === 'function' ? filePath : (): string => filePath
this.maxBytes = maxBytes
}
@@ -150,7 +163,8 @@ class CappedLogWritable extends Writable {
callback: (error?: Error | null) => void
): void {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
appendToFileWithLimit(this.filePath, buffer, this.maxBytes).then(
// 每次写入时重新解析路径,使日志能按当前日期自动轮转(与 app 日志一致)。
appendToFileWithLimit(this.resolvePath(), buffer, this.maxBytes).then(
() => callback(),
(error) => callback(error as Error)
)
@@ -158,7 +172,7 @@ class CappedLogWritable extends Writable {
}
export function createCappedLogWritableStream(
filePath: string,
filePath: LogFilePath,
maxBytes = getGlobalMaxLogFileSizeBytes()
): Writable {
return new CappedLogWritable(filePath, maxBytes)