Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899864c696 | ||
|
|
cc0070ae16 |
No files matched your search
+16
-13
@@ -495,8 +495,11 @@ class HyMemoryClient:
|
||||
|
||||
# 进程级初始化日志强制不带业务 request_id(哨兵 "-"),避免污染 App
|
||||
# 请求上下文里按 request_id 的过滤。
|
||||
#
|
||||
# 用 debug 而非 info:全组件冷启动本就该是进程级一次性事件,
|
||||
# 一旦有人在高频路径上重建 client,info 会把这条噪音放大几十万倍。
|
||||
with request_id_scope():
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"Initializing HyMemoryClient: mode={self._mode} "
|
||||
f"enable_graph={self.enable_graph}"
|
||||
)
|
||||
@@ -523,7 +526,7 @@ class HyMemoryClient:
|
||||
# 同步初始化所有组件
|
||||
self._loop_thread.run(self._initialize())
|
||||
|
||||
logger.info("HyMemoryClient ready")
|
||||
logger.debug("HyMemoryClient ready")
|
||||
|
||||
async def _initialize(self):
|
||||
"""初始化所有核心组件"""
|
||||
@@ -542,7 +545,7 @@ class HyMemoryClient:
|
||||
loop.set_default_executor(
|
||||
concurrent.futures.ThreadPoolExecutor(max_workers=_max_workers)
|
||||
)
|
||||
logger.info(f"[init] Default ThreadPoolExecutor max_workers={_max_workers}")
|
||||
logger.debug(f"[init] Default ThreadPoolExecutor max_workers={_max_workers}")
|
||||
|
||||
# 1. EmbedService(注入发生在 ComponentFactory / pipeline 预热之前)
|
||||
self._embed_service = (
|
||||
@@ -557,7 +560,7 @@ class HyMemoryClient:
|
||||
# 2. VectorStore(根据配置选择后端)
|
||||
self._vector_store = create_vector_store(self._config)
|
||||
await self._vector_store.initialize()
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"[init] VectorStore ready: provider={self._config.vector_store.provider} "
|
||||
f"collection={self._vector_store._collection_name}"
|
||||
)
|
||||
@@ -574,7 +577,7 @@ class HyMemoryClient:
|
||||
f"GraphStore ({self._config.graph_store.provider}) init failed. "
|
||||
f"Use mode='pro' instead of mode='ultra' to disable Graph, or check logs for details."
|
||||
)
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"[init] GraphStore ready: provider={self._config.graph_store.provider}"
|
||||
)
|
||||
|
||||
@@ -594,10 +597,10 @@ class HyMemoryClient:
|
||||
if self._owns_runtime:
|
||||
self._cache = create_cache(self._config)
|
||||
await self._cache.initialize()
|
||||
logger.info(f"[init] Cache ready (backend={self._config.cache.backend})")
|
||||
logger.debug(f"[init] Cache ready (backend={self._config.cache.backend})")
|
||||
else:
|
||||
self._cache = self._runtime.cache
|
||||
logger.info("[init] Cache reused from SharedRuntime")
|
||||
logger.debug("[init] Cache reused from SharedRuntime")
|
||||
|
||||
self._registry._shared_cache = self._cache
|
||||
|
||||
@@ -610,14 +613,14 @@ class HyMemoryClient:
|
||||
# 6. 预热组件(确保首次 add/search 不需要等待初始化)
|
||||
await self._registry.get_writer()
|
||||
await self._registry.get_reader()
|
||||
logger.info(f"[init] Writer & Reader ready (mode={self._mode})")
|
||||
logger.debug(f"[init] Writer & Reader ready (mode={self._mode})")
|
||||
|
||||
await self._initialize_local_reranker()
|
||||
|
||||
if self._mode == "ultra":
|
||||
try:
|
||||
await self._registry.get_system2_writer()
|
||||
logger.info("[init] System2Writer ready (ultra mode)")
|
||||
logger.debug("[init] System2Writer ready (ultra mode)")
|
||||
except Exception as e:
|
||||
logger.warning(f"[init] System2Writer warmup failed: {e}")
|
||||
|
||||
@@ -626,7 +629,7 @@ class HyMemoryClient:
|
||||
if self._config.history.enable:
|
||||
self._history_store = HistoryStore(self._config)
|
||||
await self._history_store.initialize()
|
||||
logger.info("[init] HistoryStore ready")
|
||||
logger.debug("[init] HistoryStore ready")
|
||||
|
||||
# 8. MetricsCollector
|
||||
# - solo 模式:bind cache 到 metrics 单例 + 启 background flush
|
||||
@@ -636,9 +639,9 @@ class HyMemoryClient:
|
||||
_metrics = MetricsCollector.get()
|
||||
_metrics.bind_cache(self._cache)
|
||||
await _metrics.start_background_tasks()
|
||||
logger.info("[init] MetricsCollector ready")
|
||||
logger.debug("[init] MetricsCollector ready")
|
||||
else:
|
||||
logger.info("[init] MetricsCollector already bound by SharedRuntime")
|
||||
logger.debug("[init] MetricsCollector already bound by SharedRuntime")
|
||||
|
||||
# 9. Coding memory 链路占位(lazy 初始化)
|
||||
# 仅在 add(messages_with_tool) 首次命中 is_coding=True 时才真正初始化。
|
||||
@@ -691,7 +694,7 @@ class HyMemoryClient:
|
||||
"Local rerank is enabled but initialization failed: "
|
||||
f"model={reranker.model_name!r}, error={exc}"
|
||||
) from exc
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[init] Local reranker ready: model=%s device=%s "
|
||||
"load_ms=%s warmup_ms=%s",
|
||||
reranker.model_name,
|
||||
|
||||
@@ -15,6 +15,15 @@ class DataPlaneSnapshotRejected(RuntimeError):
|
||||
self.code = code
|
||||
|
||||
|
||||
# Runtime logs live beside the data plane but are not data: they are written by
|
||||
# a rotating handler, carry no recovery value, and can grow to gigabytes. The
|
||||
# migration snapshot is a recovery artifact, so copying them both doubles the
|
||||
# disk cost and makes every migration run fail on a nearly-full volume.
|
||||
# Only top-level entries are excluded so a business directory that happens to be
|
||||
# named "logs" deeper in the tree is still copied.
|
||||
SNAPSHOT_EXCLUDED_TOP_LEVEL: frozenset[str] = frozenset({"logs"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataPlaneSnapshot:
|
||||
source_root: Path
|
||||
@@ -64,6 +73,14 @@ def snapshot_local_data_plane(
|
||||
for root, directories, files in os.walk(source, followlinks=False):
|
||||
root_path = Path(root)
|
||||
relative = root_path.relative_to(source)
|
||||
if relative == Path("."):
|
||||
# Prune before descending: an excluded directory is never walked,
|
||||
# so its files are neither checked nor copied.
|
||||
directories[:] = [
|
||||
name
|
||||
for name in directories
|
||||
if name not in SNAPSHOT_EXCLUDED_TOP_LEVEL
|
||||
]
|
||||
target_directory = target / relative
|
||||
target_directory.mkdir(parents=True, exist_ok=True)
|
||||
for name in directories:
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
HY Memory - 日志系统配置
|
||||
|
||||
标准 Python logging + TimedRotatingFileHandler,
|
||||
每天一个文件,保留 15 天。支持 request_id 注入,
|
||||
可按 request_id grep 日志过滤完整请求链路。
|
||||
每天一个文件,默认保留 3 天(可用 MEMORY_LOG_RETENTION_DAYS 覆盖)。
|
||||
支持 request_id 注入,可按 request_id grep 日志过滤完整请求链路。
|
||||
|
||||
Usage:
|
||||
from hy_memory.utils.log_setup import setup_logging, set_request_id
|
||||
@@ -18,6 +18,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import datetime as _datetime
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -25,6 +26,113 @@ from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ================================================================
|
||||
# 日志保留策略
|
||||
# ================================================================
|
||||
|
||||
# 默认只保留 3 天。INFO 级日志单日可涨到 2 GB 以上,15 天窗口在真实使用中
|
||||
# 会累积到 10 GB 量级,而这份体积会被迁移快照整份复制一遍。
|
||||
DEFAULT_LOG_RETENTION_DAYS = 3
|
||||
LOG_RETENTION_ENV = "MEMORY_LOG_RETENTION_DAYS"
|
||||
_ROTATION_SUFFIX_FORMAT = "%Y-%m-%d"
|
||||
|
||||
|
||||
def _is_rotation_date(value: str) -> bool:
|
||||
"""Return True when value is a rotation date suffix such as 2026-10-19."""
|
||||
if len(value) != len("2026-10-19"):
|
||||
return False
|
||||
try:
|
||||
_datetime.datetime.strptime(value, _ROTATION_SUFFIX_FORMAT)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_retention_days(explicit: Optional[int] = None) -> int:
|
||||
"""Resolve the retention window: explicit arg > env var > default."""
|
||||
if explicit is not None:
|
||||
try:
|
||||
return max(1, int(explicit))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
raw = os.getenv(LOG_RETENTION_ENV, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_LOG_RETENTION_DAYS
|
||||
|
||||
|
||||
class GzipAwareTimedRotatingFileHandler(TimedRotatingFileHandler):
|
||||
"""TimedRotatingFileHandler that still expires files after they are gzipped.
|
||||
|
||||
The stdlib implementation builds its deletion list by matching the active
|
||||
file name against a date-suffixed pattern. Once an operator gzips a rotated
|
||||
file the suffix no longer matches, so that file drops out of the list; when
|
||||
fewer files than ``backupCount`` match, ``getFilesToDelete()`` returns ``[]``
|
||||
and nothing is ever deleted again. The directory then grows forever while
|
||||
looking correctly configured.
|
||||
"""
|
||||
|
||||
def getFilesToDelete(self) -> list[str]: # noqa: N802 - stdlib naming
|
||||
directory, base = os.path.split(self.baseFilename)
|
||||
if not os.path.isdir(directory):
|
||||
return []
|
||||
prefix = base + "."
|
||||
candidates: list[str] = []
|
||||
for name in os.listdir(directory):
|
||||
if not name.startswith(prefix):
|
||||
continue
|
||||
suffix = name[len(prefix) :]
|
||||
if suffix.endswith(".gz"):
|
||||
suffix = suffix[: -len(".gz")]
|
||||
if _is_rotation_date(suffix):
|
||||
candidates.append(os.path.join(directory, name))
|
||||
candidates.sort()
|
||||
if self.backupCount <= 0 or len(candidates) < self.backupCount:
|
||||
return []
|
||||
return candidates[: len(candidates) - self.backupCount]
|
||||
|
||||
|
||||
def purge_expired_logs(
|
||||
log_dir: str,
|
||||
retention_days: int,
|
||||
*,
|
||||
base_name: str = "hy_memory.log",
|
||||
today: Optional[_datetime.date] = None,
|
||||
) -> list[str]:
|
||||
"""Delete rotated log files older than the retention window.
|
||||
|
||||
Runs once at startup in addition to the handler's own rotation cleanup: a
|
||||
directory that already accumulated weeks of files (or gzipped files the
|
||||
handler cannot match) would otherwise never converge. Age comes from the
|
||||
date in the file name, not mtime, because copied files carry a new mtime.
|
||||
"""
|
||||
directory = Path(log_dir)
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
reference = today or _datetime.date.today()
|
||||
prefix = base_name + "."
|
||||
removed: list[str] = []
|
||||
for path in directory.iterdir():
|
||||
if not path.name.startswith(prefix) or not path.is_file():
|
||||
continue
|
||||
suffix = path.name[len(prefix) :]
|
||||
if suffix.endswith(".gz"):
|
||||
suffix = suffix[: -len(".gz")]
|
||||
if not _is_rotation_date(suffix):
|
||||
continue
|
||||
written = _datetime.datetime.strptime(suffix, _ROTATION_SUFFIX_FORMAT).date()
|
||||
if (reference - written).days > retention_days:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
removed.append(str(path))
|
||||
return removed
|
||||
|
||||
|
||||
# ================================================================
|
||||
# request_id 上下文变量 (协程安全)
|
||||
# ================================================================
|
||||
@@ -184,7 +292,7 @@ def _default_log_dir() -> str:
|
||||
|
||||
def setup_logging(
|
||||
log_dir: Optional[str] = None,
|
||||
retention_days: int = 15,
|
||||
retention_days: Optional[int] = None,
|
||||
level: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -198,7 +306,8 @@ def setup_logging(
|
||||
|
||||
Args:
|
||||
log_dir: 日志目录。默认 {MEMORY_DATA_DIR}/logs/
|
||||
retention_days: 日志保留天数,默认 15
|
||||
retention_days: 日志保留天数。默认读 MEMORY_LOG_RETENTION_DAYS,
|
||||
再缺省为 DEFAULT_LOG_RETENTION_DAYS(3 天)
|
||||
level: 日志级别。默认从环境变量 MEMORY_LOG_LEVEL 读取,缺省 INFO
|
||||
|
||||
可多次调用,但只有首次生效。
|
||||
@@ -208,6 +317,8 @@ def setup_logging(
|
||||
return
|
||||
_logging_configured = True
|
||||
|
||||
resolved_retention = resolve_retention_days(retention_days)
|
||||
|
||||
if level is None:
|
||||
level = getattr(logging, os.getenv("MEMORY_LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||
|
||||
@@ -215,6 +326,9 @@ def setup_logging(
|
||||
resolved_dir = log_dir or _default_log_dir()
|
||||
os.makedirs(resolved_dir, exist_ok=True)
|
||||
|
||||
# 先按策略清一次历史文件:轮转清理只在滚动时触发,已经积压的目录不会自己收敛
|
||||
purge_expired_logs(resolved_dir, resolved_retention)
|
||||
|
||||
# hy_memory 根 logger
|
||||
root_logger = logging.getLogger("hy_memory")
|
||||
root_logger.setLevel(level)
|
||||
@@ -231,11 +345,11 @@ def setup_logging(
|
||||
|
||||
# ---- 文件 Handler ----
|
||||
log_file = os.path.join(resolved_dir, "hy_memory.log")
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
file_handler = GzipAwareTimedRotatingFileHandler(
|
||||
filename=log_file,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=retention_days,
|
||||
backupCount=resolved_retention,
|
||||
encoding="utf-8",
|
||||
)
|
||||
# 滚动后文件名: hy_memory.log.2026-03-21
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable, Coroutine
|
||||
@@ -40,6 +41,8 @@ BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
|
||||
_LOCAL_ONNX_PLACEHOLDER_URL = "http://127.0.0.1/local-onnx"
|
||||
DEFAULT_RELEVANCE_FLOOR = 0.35
|
||||
|
||||
logger = logging.getLogger("one_memory.hy_sdk_runtime")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RuntimeEntry:
|
||||
@@ -120,6 +123,15 @@ class ClientLease:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if _lease_trace_enabled():
|
||||
# 一个进程只应持有极少数 client lease。若这里被高频调用,
|
||||
# 说明某条请求路径在请求结束时释放了共享句柄,下一次调用
|
||||
# 只能整份重建 client(连带 3 套组件与线程池)。
|
||||
logger.warning(
|
||||
"sdk client lease released: data_dir=%s",
|
||||
self.data_dir,
|
||||
stack_info=True,
|
||||
)
|
||||
try:
|
||||
self.client.close()
|
||||
finally:
|
||||
@@ -132,6 +144,15 @@ class ClientLease:
|
||||
self.close()
|
||||
|
||||
|
||||
def _lease_trace_enabled() -> bool:
|
||||
return os.getenv("ONE_MEMORY_TRACE_CLIENT_LEASE", "").strip() not in {
|
||||
"",
|
||||
"0",
|
||||
"false",
|
||||
"False",
|
||||
}
|
||||
|
||||
|
||||
class SdkClientHandle:
|
||||
"""Lazy async owner for one profile-bound public SDK client lease."""
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import sqlite3
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
@@ -28,11 +27,11 @@ from one_memory.sdk_data_plane_migration import (
|
||||
)
|
||||
from one_memory.sdk_migration_bundle import inspect_sdk_migration_bundle
|
||||
from one_memory.sdk_migration_inventory import (
|
||||
LegacyRawInventory,
|
||||
SdkMigrationClient,
|
||||
execute_memory_migration_plan,
|
||||
execute_raw_migration_plan,
|
||||
read_legacy_memory_inventory,
|
||||
read_legacy_raw_inventory,
|
||||
read_sdk_memory_inventory,
|
||||
read_sdk_raw_inventory,
|
||||
record_memory_migration_plan,
|
||||
@@ -414,20 +413,13 @@ async def run_sdk_data_plane_backfill(
|
||||
legacy=legacy_memories,
|
||||
sdk=sdk_memories,
|
||||
)
|
||||
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
|
||||
started = database.execute(
|
||||
"SELECT created_at FROM sdk_data_plane_migrations WHERE migration_id=?",
|
||||
(migration_id,),
|
||||
).fetchone()
|
||||
if started is None:
|
||||
raise DataPlaneMigrationError("sdk_data_plane_migration_start_missing")
|
||||
started_at = datetime.fromisoformat(str(started["created_at"]))
|
||||
if started_at.tzinfo is None:
|
||||
started_at = started_at.replace(tzinfo=UTC)
|
||||
legacy_raw = read_legacy_raw_inventory(
|
||||
database_path,
|
||||
cutoff=started_at.astimezone(UTC) - timedelta(days=3),
|
||||
)
|
||||
# raw 全丢:迁移只搬记忆,不再搬原始对话。
|
||||
#
|
||||
# 仍然走同一套 plan/execute 路径,classify_raw_inventory 的判定规则不动:
|
||||
# 新数据面里已有的 raw 因为拿不到 legacy 对应项,会按原有规则被判为窗口外
|
||||
# 并删除。这里只做一件事——不再从 legacy 读取、不再把三天窗口内的对话
|
||||
# 重建成一份新的 raw,省掉几十万行的读写与重建成本。
|
||||
legacy_raw: tuple[LegacyRawInventory, ...] = ()
|
||||
sdk_raw = await read_sdk_raw_inventory(client)
|
||||
raw_decisions = record_raw_migration_plan(
|
||||
store,
|
||||
|
||||
@@ -1028,28 +1028,16 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
|
||||
|
||||
assert result.state == "ready"
|
||||
assert result.phase == "reconciled"
|
||||
assert result.counts == {"verified": 2}
|
||||
assert result.counts == {"verified": 1}
|
||||
assert [item["memory_id"] for item in client.imported] == ["memory-1"]
|
||||
assert len(client.imported_raw) == 1
|
||||
assert client.imported_raw[0]["content"] == "[user]: verbatim\n[assistant]: reply"
|
||||
migrated_raw_id = client.imported_raw[0]["raw_id"]
|
||||
# raw 全丢:迁移只搬记忆,不再把 legacy 的对话重建成新数据面的 raw
|
||||
assert client.imported_raw == []
|
||||
assert store.authority() is MemoryAuthority.LEGACY
|
||||
# Simulate a process dying after both SDK side effects but before startup
|
||||
# could finish. The next inventory observes them as matched rather than
|
||||
# Simulate a process dying after the SDK side effect but before startup
|
||||
# could finish. The next inventory observes it as matched rather than
|
||||
# import_exact; that reclassification must resume the settled ledger
|
||||
# instead of conflicting with its original operation key.
|
||||
client.memories = (_record("memory-1", content="preference"),)
|
||||
client.raw = (
|
||||
HySdkRawRecord(
|
||||
raw_id=migrated_raw_id,
|
||||
content="[user]: verbatim\n[assistant]: reply",
|
||||
user_id="local",
|
||||
agent_id="all-apps",
|
||||
session_id="session",
|
||||
source="cursor",
|
||||
custom={"input_kind": "legacy_turn"},
|
||||
),
|
||||
)
|
||||
resumed = asyncio.run(
|
||||
run_sdk_data_plane_backfill(
|
||||
database_path=database,
|
||||
@@ -1064,9 +1052,9 @@ def test_offline_backfill_reaches_ready_without_committing_cutover(store):
|
||||
)
|
||||
)
|
||||
assert resumed.state == "ready"
|
||||
assert resumed.counts == {"verified": 2}
|
||||
assert resumed.counts == {"verified": 1}
|
||||
assert len(client.imported) == 1
|
||||
assert len(client.imported_raw) == 1
|
||||
assert client.imported_raw == []
|
||||
|
||||
|
||||
def test_offline_backfill_blocks_cutover_when_an_item_is_quarantined(store):
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"release": {
|
||||
"build_number": 17,
|
||||
"build_number": 18,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -39,6 +39,37 @@ def test_snapshot_copies_and_verifies_local_sdk_tree(tmp_path):
|
||||
assert result.total_bytes == len(b"vector") + len(b"cache")
|
||||
|
||||
|
||||
def test_snapshot_skips_top_level_runtime_logs(tmp_path):
|
||||
"""Runtime logs sit beside the data plane but are not data.
|
||||
|
||||
Copying them doubled a 15.8 GB migration snapshot, most of it rotated log
|
||||
files, and made migrations fail with ENOSPC on an otherwise healthy volume.
|
||||
Only the top-level directory is pruned: a business directory that happens to
|
||||
be called "logs" deeper in the tree must still be copied.
|
||||
"""
|
||||
source = tmp_path / "source"
|
||||
(source / "logs").mkdir(parents=True)
|
||||
(source / "logs" / "hy_memory.log").write_bytes(b"x" * 4096)
|
||||
(source / "logs" / "hy_memory.log.2026-10-19").write_bytes(b"y" * 4096)
|
||||
(source / "vector" / "logs").mkdir(parents=True)
|
||||
(source / "vector" / "logs" / "keep.bin").write_bytes(b"keep")
|
||||
(source / "cache.sqlite3").write_bytes(b"cache")
|
||||
target = tmp_path / "snapshot"
|
||||
|
||||
result = snapshot_local_data_plane(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
writers_stopped=True,
|
||||
)
|
||||
|
||||
assert {item["path"] for item in result.files} == {
|
||||
"cache.sqlite3",
|
||||
"vector/logs/keep.bin",
|
||||
}
|
||||
assert not (target / "logs").exists()
|
||||
assert result.total_bytes == len(b"keep") + len(b"cache")
|
||||
|
||||
|
||||
def test_snapshot_refuses_active_writers(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
|
||||
@@ -222,3 +222,72 @@ class TestSingleLineAndJsonFormat:
|
||||
monkeypatch.delenv("MEMORY_LOG_FORMAT", raising=False)
|
||||
fmt = log_setup._make_formatter()
|
||||
assert isinstance(fmt, log_setup.SingleLineFormatter)
|
||||
|
||||
|
||||
class TestLogRetention:
|
||||
"""保留窗口必须能真正收敛,而不是只在配置里写着 3 天。"""
|
||||
|
||||
def test_default_retention_is_three_days(self, monkeypatch):
|
||||
monkeypatch.delenv("MEMORY_LOG_RETENTION_DAYS", raising=False)
|
||||
assert log_setup.resolve_retention_days() == 3
|
||||
|
||||
def test_env_overrides_default(self, monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_LOG_RETENTION_DAYS", "7")
|
||||
assert log_setup.resolve_retention_days() == 7
|
||||
|
||||
def test_explicit_argument_wins_over_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_LOG_RETENTION_DAYS", "7")
|
||||
assert log_setup.resolve_retention_days(2) == 2
|
||||
|
||||
def test_invalid_env_falls_back_to_default(self, monkeypatch):
|
||||
monkeypatch.setenv("MEMORY_LOG_RETENTION_DAYS", "not-a-number")
|
||||
assert log_setup.resolve_retention_days() == 3
|
||||
|
||||
def test_purge_removes_expired_including_gzipped_files(self, tmp_path):
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
log_dir = tmp_path / "logs"
|
||||
log_dir.mkdir()
|
||||
expired = ["hy_memory.log.2026-10-01", "hy_memory.log.2026-10-19.gz"]
|
||||
fresh = ["hy_memory.log.2026-10-21", "hy_memory.log.2026-10-22.gz"]
|
||||
for name in expired + fresh:
|
||||
(log_dir / name).write_text("x")
|
||||
(log_dir / "hy_memory.log").write_text("active")
|
||||
(log_dir / "unrelated.log").write_text("keep")
|
||||
|
||||
removed = log_setup.purge_expired_logs(
|
||||
str(log_dir), 3, today=_dt.date(2026, 10, 23)
|
||||
)
|
||||
|
||||
assert sorted(Path(p).name for p in removed) == sorted(expired)
|
||||
assert {p.name for p in log_dir.iterdir()} == {
|
||||
"hy_memory.log",
|
||||
"unrelated.log",
|
||||
*fresh,
|
||||
}
|
||||
|
||||
def test_rotation_delete_list_includes_gzipped_files(self, tmp_path):
|
||||
from pathlib import Path
|
||||
|
||||
log_dir = tmp_path / "logs"
|
||||
log_dir.mkdir()
|
||||
base = log_dir / "hy_memory.log"
|
||||
base.write_text("active")
|
||||
for day in ("01", "02", "03", "04"):
|
||||
(log_dir / f"hy_memory.log.2026-10-{day}").write_text("x")
|
||||
(log_dir / "hy_memory.log.2026-09-30.gz").write_text("x")
|
||||
|
||||
handler = log_setup.GzipAwareTimedRotatingFileHandler(
|
||||
filename=str(base), when="midnight", backupCount=2
|
||||
)
|
||||
try:
|
||||
to_delete = {Path(p).name for p in handler.getFilesToDelete()}
|
||||
finally:
|
||||
handler.close()
|
||||
|
||||
assert to_delete == {
|
||||
"hy_memory.log.2026-09-30.gz",
|
||||
"hy_memory.log.2026-10-01",
|
||||
"hy_memory.log.2026-10-02",
|
||||
}
|
||||
Reference in new issue
Block a user