agent-Specialization/utils/log_rotation.py

128 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""日志与落盘文件的轮转/清理工具。
提供两类策略:
1. ``RotatingFileHandler`` 风格的"按大小轮转"——用于持续追加的单文件日志
(如 host_workspace_debug.log、chunk_*.log。封装为 :func:`append_line`
单文件超过阈值即滚动,保留固定份数。
2. "按份数保留"——用于一请求一文件的落盘目录(如 api_requests/),写入新文件
后清理最旧的,只保留最近 N 个。
阈值可通过环境变量覆盖:
- ``AGENT_LOG_ROTATE_MAX_BYTES`` 单文件轮转阈值(字节,默认 20MB
- ``AGENT_LOG_ROTATE_BACKUPS`` 追加型日志保留的轮转份数(默认 3
- ``AGENT_DUMP_KEEP`` 按份计量目录保留的文件数(默认 30
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
from typing import Optional
def _env_int(name: str, default: int) -> int:
try:
value = int(str(os.environ.get(name, "")).strip())
return value if value > 0 else default
except (TypeError, ValueError):
return default
DEFAULT_MAX_BYTES = _env_int("AGENT_LOG_ROTATE_MAX_BYTES", 20 * 1024 * 1024) # 20MB
DEFAULT_BACKUPS = _env_int("AGENT_LOG_ROTATE_BACKUPS", 3)
DEFAULT_DUMP_KEEP = _env_int("AGENT_DUMP_KEEP", 30)
# 每个文件路径一把锁,避免并发写入与轮转竞争。
_locks: dict[str, threading.Lock] = {}
_locks_guard = threading.Lock()
def _lock_for(path: Path) -> threading.Lock:
key = str(path)
with _locks_guard:
lock = _locks.get(key)
if lock is None:
lock = threading.Lock()
_locks[key] = lock
return lock
def _rotate(path: Path, backups: int) -> None:
"""执行 ``foo.log`` -> ``foo.log.1`` -> ``foo.log.2`` ... 的滚动。"""
try:
# 删除最旧一份
oldest = path.with_name(f"{path.name}.{backups}")
if oldest.exists():
oldest.unlink()
# 依次后移:.(n-1) -> .n
for idx in range(backups - 1, 0, -1):
src = path.with_name(f"{path.name}.{idx}")
if src.exists():
src.rename(path.with_name(f"{path.name}.{idx + 1}"))
# 当前文件 -> .1
if path.exists():
path.rename(path.with_name(f"{path.name}.1"))
except Exception:
# 轮转失败不应阻断主流程,最坏情况是文件继续增长
pass
def append_line(
path: Path,
line: str,
*,
max_bytes: int = DEFAULT_MAX_BYTES,
backups: int = DEFAULT_BACKUPS,
encoding: str = "utf-8",
) -> None:
"""向 ``path`` 追加一行(自动补换行),超过 ``max_bytes`` 时先轮转。
线程安全。任何异常都被吞掉,调试日志不应影响主流程。
"""
try:
path = Path(path)
text = line if line.endswith("\n") else line + "\n"
with _lock_for(path):
path.parent.mkdir(parents=True, exist_ok=True)
try:
size = path.stat().st_size if path.exists() else 0
except OSError:
size = 0
if size and size + len(text.encode(encoding)) > max_bytes:
_rotate(path, backups)
with path.open("a", encoding=encoding) as fp:
fp.write(text)
except Exception:
return
def prune_dir(
directory: Path,
*,
pattern: str = "*",
keep: int = DEFAULT_DUMP_KEEP,
) -> None:
"""只保留 ``directory`` 下匹配 ``pattern`` 的最近 ``keep`` 个文件,删除其余最旧的。
线程安全。任何异常都被吞掉。
"""
try:
directory = Path(directory)
if keep <= 0 or not directory.is_dir():
return
with _lock_for(directory):
files = [p for p in directory.glob(pattern) if p.is_file()]
if len(files) <= keep:
return
files.sort(key=lambda p: p.stat().st_mtime)
for stale in files[: len(files) - keep]:
try:
stale.unlink()
except OSError:
pass
except Exception:
return