与最新 origin/staging 合并。report_reference_cache 写入 OFFLINE_SCRATCH_NAMES。清理仍由每次读取前删掉两天前的文件完成,两个缓存模块的行为没改。 自测:test_api_scratch_retention 10、test_report_reference_perf 15、test_consult_card_full_source 12,共 37 通过。 Co-Authored-By: Grok (agent loop)
612 lines
20 KiB
Python
612 lines
20 KiB
Python
"""Retention rules for scratch/local caches.
|
|
|
|
Counts only. Never prints file names or cache payloads.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import secrets
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
UNREADABLE_KEEP_SECONDS = 24 * 60 * 60
|
|
_HEX64_JSON = re.compile(r"^[0-9a-f]{64}\.json$")
|
|
_ARTIFACT_JSON = re.compile(r"^[A-Za-z0-9_]+-[0-9a-f]{12}-[0-9a-f]{12}\.json$")
|
|
_GATEWAY_JOB_JSON = re.compile(r"^vgw_[0-9a-f]{20}\.json$")
|
|
_PRUNE_THREAD_NAME = "jyotish-scratch-prune"
|
|
|
|
# Directory names under scratch/local that this module already knows how to prune.
|
|
# Subdirectories of vedastro_adapter are listed so a future writer is not a new top-level dir.
|
|
REGISTERED_DIR_NAMES = frozenset(
|
|
{
|
|
"api_chart_cache",
|
|
"high_rigor_jobs",
|
|
"api_chart_response_jobs",
|
|
"async_jobs.sqlite3",
|
|
"vedastro_adapter",
|
|
"vedastro_snapshot_cache",
|
|
"vedastro_snapshot_negative_cache",
|
|
"vedastro_gateway_jobs",
|
|
"varga_segments",
|
|
}
|
|
)
|
|
|
|
# Offline tools or caches that already prune themselves. Not a new retention rule.
|
|
OFFLINE_SCRATCH_NAMES = frozenset(
|
|
{
|
|
"consult_full_data_cache",
|
|
# 与 consult_full_data_cache 同走 get_or_build,每次调用先 prune_cache(name=…) 删两天前的文件,自己会清;含出生资料,保留期 2 天。
|
|
"report_reference_cache",
|
|
"ocr_extract",
|
|
"three_engine_parity",
|
|
"public_release_deny_patterns.txt",
|
|
"pdf_review_123456",
|
|
}
|
|
)
|
|
|
|
REGISTRY: tuple[dict[str, Any], ...] = (
|
|
{
|
|
"name": "api_chart_cache",
|
|
"holds_birth_data": True,
|
|
"deletable": "created_at plus chart TTL, or unreadable json older than 24h",
|
|
},
|
|
{
|
|
"name": "high_rigor_jobs",
|
|
"holds_birth_data": True,
|
|
"deletable": "expires_at_unix has passed; bad json is kept",
|
|
},
|
|
{
|
|
"name": "api_chart_response_jobs",
|
|
"holds_birth_data": True,
|
|
"deletable": "expires_at_unix has passed; bad json is kept",
|
|
},
|
|
{
|
|
"name": "async_jobs.sqlite3",
|
|
"holds_birth_data": True,
|
|
"deletable": "sqlite rows whose expires_at has passed",
|
|
},
|
|
{
|
|
"name": "vedastro_adapter",
|
|
"holds_birth_data": True,
|
|
"deletable": "audit artifacts, response_cache, and official_full_snapshot_cache",
|
|
},
|
|
{
|
|
"name": "vedastro_snapshot_cache",
|
|
"holds_birth_data": True,
|
|
"deletable": "reference_date before today UTC minus MAX_STALE_DAYS minus 1",
|
|
},
|
|
{
|
|
"name": "vedastro_snapshot_negative_cache",
|
|
"holds_birth_data": True,
|
|
"deletable": "reference_date before today UTC",
|
|
},
|
|
{
|
|
"name": "vedastro_gateway_jobs",
|
|
"holds_birth_data": True,
|
|
"deletable": "completed or failed jobs older than the keep window, plus their raw archive",
|
|
},
|
|
{
|
|
"name": "varga_segments",
|
|
"holds_birth_data": True,
|
|
"deletable": "mtime older than JYOTISH_VARGA_SEGMENTS_KEEP_DAYS",
|
|
},
|
|
)
|
|
|
|
|
|
def write_private_json(path: Path, payload: Any) -> None:
|
|
"""Atomic UTF-8 JSON, mode 0600. Compact, stable key order."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temp = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
|
|
temp.write_text(
|
|
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
|
|
encoding="utf-8",
|
|
)
|
|
try:
|
|
os.chmod(temp, 0o600)
|
|
except OSError:
|
|
pass
|
|
os.replace(temp, path)
|
|
try:
|
|
os.chmod(path, 0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _finite_float(value: Any) -> float | None:
|
|
if isinstance(value, bool) or value is None:
|
|
return None
|
|
if isinstance(value, (int, float)):
|
|
number = float(value)
|
|
elif isinstance(value, str):
|
|
text = value.strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
number = float(text)
|
|
except ValueError:
|
|
return None
|
|
else:
|
|
return None
|
|
if not math.isfinite(number):
|
|
return None
|
|
return number
|
|
|
|
|
|
def _exact_int(value: Any) -> int | None:
|
|
if isinstance(value, bool) or value is None:
|
|
return None
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, float):
|
|
if not math.isfinite(value) or not value.is_integer():
|
|
return None
|
|
return int(value)
|
|
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
|
|
return int(value.strip())
|
|
return None
|
|
|
|
|
|
def normalize_chart_cache_birth(body: dict | None) -> dict[str, Any]:
|
|
"""Cache-key birth fields. Unparseable values stay as they arrived."""
|
|
payload = body if isinstance(body, dict) else {}
|
|
normalized: dict[str, Any] = {}
|
|
for key in ("year", "month", "day"):
|
|
parsed = _exact_int(payload.get(key))
|
|
normalized[key] = payload.get(key) if parsed is None else parsed
|
|
for key in ("hour", "minute", "lat", "lon", "tz"):
|
|
raw = payload.get(key)
|
|
parsed = _finite_float(raw)
|
|
normalized[key] = raw if parsed is None else parsed
|
|
second_raw = payload.get("second", 0)
|
|
second = _finite_float(second_raw)
|
|
normalized["second"] = second_raw if second is None else second
|
|
return normalized
|
|
|
|
|
|
def normalize_chart_cache_day(value: Any) -> Any:
|
|
if value is None or value == "":
|
|
return value
|
|
return str(value)[:10]
|
|
|
|
|
|
def _blank() -> dict[str, int]:
|
|
return {"scanned": 0, "removed": 0, "errors": 0}
|
|
|
|
|
|
def _older_than(path: Path, moment: float, seconds: float) -> bool:
|
|
try:
|
|
return moment - path.stat().st_mtime > seconds
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _unlink(path: Path, stats: dict[str, int]) -> None:
|
|
try:
|
|
path.unlink()
|
|
stats["removed"] += 1
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
return ROOT
|
|
|
|
|
|
def _api_module(api_module=None):
|
|
if api_module is not None:
|
|
return api_module
|
|
import jyotish_api_server as api
|
|
|
|
return api
|
|
|
|
|
|
def prune_async_job_records(now: float | None = None, api_module=None) -> dict[str, Any]:
|
|
"""Same file and sqlite rules as the old startup cleanup. Bad job JSON is kept."""
|
|
api = _api_module(api_module)
|
|
moment = time.time() if now is None else float(now)
|
|
scanned = 0
|
|
removed = 0
|
|
errors = 0
|
|
directories: dict[str, dict[str, int]] = {}
|
|
if api._async_job_backend() == "sqlite":
|
|
try:
|
|
with api._sqlite_job_connection() as connection:
|
|
scanned = int(connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0])
|
|
deleted = connection.execute(
|
|
"DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?",
|
|
(moment,),
|
|
).rowcount
|
|
removed = int(deleted or 0)
|
|
except OSError:
|
|
errors += 1
|
|
directories["high_rigor_jobs"] = _blank()
|
|
directories["api_chart_response_jobs"] = _blank()
|
|
directories["async_jobs_sqlite"] = {"scanned": scanned, "removed": removed, "errors": errors}
|
|
return {
|
|
"scope": "async_job_cleanup",
|
|
"scanned": scanned,
|
|
"removed": removed,
|
|
"errors": errors,
|
|
"directories": directories,
|
|
}
|
|
|
|
for scope, label in (
|
|
(api._HIGH_RIGOR_JOB_SCOPE, "high_rigor_jobs"),
|
|
(api._API_CHART_CACHE_SCOPE, "api_chart_response_jobs"),
|
|
):
|
|
stats = _blank()
|
|
try:
|
|
directory = api._async_job_dir(scope)
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
directories[label] = stats
|
|
errors += stats["errors"]
|
|
continue
|
|
if directory.is_dir():
|
|
for path in directory.glob("*.json"):
|
|
stats["scanned"] += 1
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
continue
|
|
expires_at = record.get("expires_at_unix") if isinstance(record, dict) else None
|
|
if isinstance(expires_at, (int, float)) and not isinstance(expires_at, bool) and moment >= float(expires_at):
|
|
_unlink(path, stats)
|
|
directories[label] = stats
|
|
scanned += stats["scanned"]
|
|
removed += stats["removed"]
|
|
errors += stats["errors"]
|
|
return {
|
|
"scope": "async_job_cleanup",
|
|
"scanned": scanned,
|
|
"removed": removed,
|
|
"errors": errors,
|
|
"directories": directories,
|
|
}
|
|
|
|
|
|
def _prune_chart_cache(moment: float, api_module=None) -> dict[str, int]:
|
|
api = _api_module(api_module)
|
|
stats = _blank()
|
|
try:
|
|
directory = api._api_chart_cache_dir()
|
|
ttl = api._api_chart_cache_ttl_seconds()
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
return stats
|
|
if not directory.is_dir():
|
|
return stats
|
|
for path in directory.glob("*.json"):
|
|
if not _HEX64_JSON.match(path.name):
|
|
continue
|
|
stats["scanned"] += 1
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
|
|
_unlink(path, stats)
|
|
continue
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
continue
|
|
created_at = record.get("created_at") if isinstance(record, dict) else None
|
|
if not isinstance(created_at, (int, float)) or isinstance(created_at, bool):
|
|
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
|
|
_unlink(path, stats)
|
|
continue
|
|
if moment - float(created_at) > ttl:
|
|
_unlink(path, stats)
|
|
return stats
|
|
|
|
|
|
def _adapter():
|
|
import vedastro_service_adapter as adapter
|
|
|
|
return adapter
|
|
|
|
|
|
def _prune_created_at_dir(directory: Path, moment: float, ttl_for_record: Callable[[dict], float]) -> dict[str, int]:
|
|
stats = _blank()
|
|
if not directory.is_dir():
|
|
return stats
|
|
for path in directory.glob("*.json"):
|
|
if not _HEX64_JSON.match(path.name):
|
|
continue
|
|
stats["scanned"] += 1
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
|
|
_unlink(path, stats)
|
|
continue
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
continue
|
|
created_at = record.get("created_at") if isinstance(record, dict) else None
|
|
if not isinstance(created_at, (int, float)) or isinstance(created_at, bool):
|
|
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
|
|
_unlink(path, stats)
|
|
continue
|
|
try:
|
|
ttl = float(ttl_for_record(record))
|
|
except (TypeError, ValueError):
|
|
stats["errors"] += 1
|
|
continue
|
|
if moment - float(created_at) > ttl:
|
|
_unlink(path, stats)
|
|
return stats
|
|
|
|
|
|
def _prune_response_cache(moment: float) -> dict[str, int]:
|
|
adapter = _adapter()
|
|
return _prune_created_at_dir(
|
|
Path(adapter.ARTIFACT_DIR) / "response_cache",
|
|
moment,
|
|
lambda _record: adapter._cache_ttl_seconds(),
|
|
)
|
|
|
|
|
|
def _prune_snapshot_semantic_cache(moment: float) -> dict[str, int]:
|
|
adapter = _adapter()
|
|
|
|
def ttl_for(record: dict) -> float:
|
|
if record.get("negative") is True:
|
|
return adapter._official_full_snapshot_negative_ttl_seconds()
|
|
return adapter._official_full_snapshot_cache_ttl_seconds()
|
|
|
|
return _prune_created_at_dir(
|
|
Path(adapter.ARTIFACT_DIR) / "official_full_snapshot_cache",
|
|
moment,
|
|
ttl_for,
|
|
)
|
|
|
|
|
|
def _audit_keep_seconds() -> float:
|
|
raw = os.environ.get("VEDASTRO_AUDIT_ARTIFACT_KEEP_DAYS", "7").strip()
|
|
try:
|
|
days = float(raw) if raw else 7.0
|
|
except ValueError:
|
|
days = 7.0
|
|
return max(0.0, days) * 86400.0
|
|
|
|
|
|
def _prune_audit_artifacts(moment: float) -> dict[str, int]:
|
|
adapter = _adapter()
|
|
stats = _blank()
|
|
directory = Path(adapter.ARTIFACT_DIR)
|
|
if not directory.is_dir():
|
|
return stats
|
|
enabled = adapter._audit_artifacts_enabled()
|
|
max_age = _audit_keep_seconds() if enabled else UNREADABLE_KEEP_SECONDS
|
|
for path in directory.glob("*.json"):
|
|
if not path.is_file() or not _ARTIFACT_JSON.match(path.name):
|
|
continue
|
|
stats["scanned"] += 1
|
|
if _older_than(path, moment, max_age):
|
|
_unlink(path, stats)
|
|
return stats
|
|
|
|
|
|
def _parse_day(value: Any):
|
|
if not isinstance(value, str):
|
|
return None
|
|
raw = value[:10]
|
|
try:
|
|
return datetime.strptime(raw, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _prune_reference_dated(directory: Path, moment: float, *, earliest_kept) -> dict[str, int]:
|
|
stats = _blank()
|
|
if not directory.is_dir():
|
|
return stats
|
|
for path in directory.glob("*.json"):
|
|
if not _HEX64_JSON.match(path.name):
|
|
continue
|
|
stats["scanned"] += 1
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
|
|
_unlink(path, stats)
|
|
continue
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
continue
|
|
ref = _parse_day(record.get("reference_date") if isinstance(record, dict) else None)
|
|
if ref is None:
|
|
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
|
|
_unlink(path, stats)
|
|
continue
|
|
if ref < earliest_kept:
|
|
_unlink(path, stats)
|
|
return stats
|
|
|
|
|
|
def _prune_vedastro_snapshot_cache(moment: float) -> dict[str, int]:
|
|
from vedastro_snapshot_cache import MAX_STALE_DAYS
|
|
|
|
today = datetime.fromtimestamp(moment, timezone.utc).date()
|
|
earliest = today - timedelta(days=int(MAX_STALE_DAYS) + 1)
|
|
raw = os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", "").strip()
|
|
directory = Path(raw) if raw else _repo_root() / "scratch" / "local" / "vedastro_snapshot_cache"
|
|
return _prune_reference_dated(directory, moment, earliest_kept=earliest)
|
|
|
|
|
|
def _prune_vedastro_negative_cache(moment: float) -> dict[str, int]:
|
|
today = datetime.fromtimestamp(moment, timezone.utc).date()
|
|
raw = os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", "").strip()
|
|
directory = Path(raw) if raw else _repo_root() / "scratch" / "local" / "vedastro_snapshot_negative_cache"
|
|
return _prune_reference_dated(directory, moment, earliest_kept=today)
|
|
|
|
|
|
def _parse_updated_at(value: Any) -> float | None:
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
text = value.strip().replace("Z", "+00:00")
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.timestamp()
|
|
|
|
|
|
def _gateway_keep_seconds() -> float:
|
|
raw = os.environ.get("VEDASTRO_GATEWAY_JOB_KEEP_SECONDS", "86400").strip()
|
|
try:
|
|
return max(0.0, float(raw) if raw else 86400.0)
|
|
except ValueError:
|
|
return 86400.0
|
|
|
|
|
|
def _prune_gateway_jobs(moment: float) -> dict[str, int]:
|
|
stats = _blank()
|
|
raw = os.environ.get("VEDASTRO_GATEWAY_QUEUE_DIR", "").strip()
|
|
directory = Path(raw).expanduser() if raw else _repo_root() / "scratch" / "local" / "vedastro_gateway_jobs"
|
|
if not directory.is_dir():
|
|
return stats
|
|
keep = _gateway_keep_seconds()
|
|
for path in directory.glob("*.json"):
|
|
if not _GATEWAY_JOB_JSON.match(path.name):
|
|
continue
|
|
stats["scanned"] += 1
|
|
try:
|
|
record = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
except OSError:
|
|
stats["errors"] += 1
|
|
continue
|
|
if not isinstance(record, dict):
|
|
continue
|
|
status = str(record.get("status") or "")
|
|
if status not in {"completed", "failed"}:
|
|
continue
|
|
updated = _parse_updated_at(record.get("updated_at"))
|
|
if updated is None or moment - updated <= keep:
|
|
continue
|
|
companion = directory / f"{path.stem}.official_raw_response.json"
|
|
_unlink(path, stats)
|
|
if companion.is_file():
|
|
stats["scanned"] += 1
|
|
_unlink(companion, stats)
|
|
return stats
|
|
|
|
|
|
def _varga_keep_seconds() -> float:
|
|
raw = os.environ.get("JYOTISH_VARGA_SEGMENTS_KEEP_DAYS", "7").strip()
|
|
try:
|
|
days = float(raw) if raw else 7.0
|
|
except ValueError:
|
|
days = 7.0
|
|
return max(0.0, days) * 86400.0
|
|
|
|
|
|
def _prune_varga_segments(moment: float) -> dict[str, int]:
|
|
stats = _blank()
|
|
raw = os.environ.get("JYOTISH_CACHE_DIR", "").strip()
|
|
root = Path(raw) if raw else _repo_root() / "scratch" / "local"
|
|
directory = root / "varga_segments"
|
|
if not directory.is_dir():
|
|
return stats
|
|
max_age = _varga_keep_seconds()
|
|
for path in directory.glob("*.json"):
|
|
if not _HEX64_JSON.match(path.name):
|
|
continue
|
|
stats["scanned"] += 1
|
|
if _older_than(path, moment, max_age):
|
|
_unlink(path, stats)
|
|
return stats
|
|
|
|
|
|
def _stamp(stats: dict[str, int], started: float) -> dict[str, Any]:
|
|
return {
|
|
"scanned": int(stats.get("scanned", 0) or 0),
|
|
"removed": int(stats.get("removed", 0) or 0),
|
|
"errors": int(stats.get("errors", 0) or 0),
|
|
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
|
|
}
|
|
|
|
|
|
def prune_all(now: float | None = None, api_module=None) -> dict[str, Any]:
|
|
"""Prune every registered directory. Exceptions become error counts."""
|
|
moment = time.time() if now is None else float(now)
|
|
api = _api_module(api_module)
|
|
started = time.perf_counter()
|
|
directories: dict[str, dict[str, Any]] = {}
|
|
|
|
def _run(name: str, func: Callable[[float], dict[str, int]]) -> None:
|
|
t0 = time.perf_counter()
|
|
try:
|
|
stats = func(moment)
|
|
except Exception:
|
|
stats = {"scanned": 0, "removed": 0, "errors": 1}
|
|
directories[name] = _stamp(stats, t0)
|
|
|
|
_run("api_chart_cache", lambda moment: _prune_chart_cache(moment, api))
|
|
job_started = time.perf_counter()
|
|
try:
|
|
job = prune_async_job_records(moment, api)
|
|
job_dirs = job.get("directories") or {}
|
|
except Exception:
|
|
job_dirs = {
|
|
"high_rigor_jobs": {"scanned": 0, "removed": 0, "errors": 1},
|
|
"api_chart_response_jobs": _blank(),
|
|
}
|
|
job_elapsed = round((time.perf_counter() - job_started) * 1000, 3)
|
|
for name, stats in job_dirs.items():
|
|
stamped = _stamp(stats, job_started)
|
|
stamped["elapsed_ms"] = job_elapsed
|
|
directories[name] = stamped
|
|
_run("vedastro_response_cache", _prune_response_cache)
|
|
_run("vedastro_official_full_snapshot_cache", _prune_snapshot_semantic_cache)
|
|
_run("vedastro_audit_artifacts", _prune_audit_artifacts)
|
|
_run("vedastro_snapshot_cache", _prune_vedastro_snapshot_cache)
|
|
_run("vedastro_snapshot_negative_cache", _prune_vedastro_negative_cache)
|
|
_run("vedastro_gateway_jobs", _prune_gateway_jobs)
|
|
_run("varga_segments", _prune_varga_segments)
|
|
return {
|
|
"scanned": sum(item["scanned"] for item in directories.values()),
|
|
"removed": sum(item["removed"] for item in directories.values()),
|
|
"errors": sum(item["errors"] for item in directories.values()),
|
|
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
|
|
"directories": directories,
|
|
}
|
|
|
|
|
|
def start_periodic_prune(interval_s: float | None = None):
|
|
"""Daemon thread. interval <= 0 starts nothing. Startup prune is separate."""
|
|
if interval_s is None:
|
|
raw = os.environ.get("JYOTISH_SCRATCH_PRUNE_INTERVAL_SECONDS", "1800").strip()
|
|
try:
|
|
interval_s = float(raw) if raw else 1800.0
|
|
except ValueError:
|
|
interval_s = 1800.0
|
|
if interval_s <= 0:
|
|
return None
|
|
|
|
def _loop() -> None:
|
|
while True:
|
|
time.sleep(interval_s)
|
|
try:
|
|
prune_all()
|
|
except Exception:
|
|
continue
|
|
|
|
thread = threading.Thread(target=_loop, name=_PRUNE_THREAD_NAME, daemon=True)
|
|
thread.start()
|
|
return thread
|