Files
lou 4b25231c01 新增孤儿清理工具 + 看门狗每小时自动清理 + start.sh 无条件补拉看门狗
- clean_orphans.py: 只删数据库无记录的孤儿 (tmp 终态/僵尸任务卷, storage 无记录目录)
  - 目录级判断: files 记录指向的目录整体跳过, 内部文件永不单独删除 (防误删)
  - 路径 resolve 统一, 调用方 cwd 无关
  - 边界: tmp 终态超 1h 删/进行中保留/超 7 天判僵尸; storage 孤儿超 24h 删
- watchdog.sh: 每 30s 保活服务 + 每小时自动跑清理 (失败 30s 后重试)
- start.sh: 看门狗拉起移到无条件位置 (原 main.py 在跑时 exit 短路, 看门狗死亡无人接管)
- tests: 新增 test_clean_orphans 5 项 (含有效目录内 chunk 不删的误删回归断言)
- README: 数据目录补 tmp, 新增运维节, 测试数 13->17
2026-08-11 14:22:28 +08:00

222 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""孤儿文件清理脚本: 删除数据库无记录的传输临时目录与存储残留。
清理目标:
1. data/tmp/<transfer_id>/ 中断/失败/僵尸任务的临时卷目录
2. data/storage/ 下 files 表无记录的孤儿目录与单文件 (含旧架构残留)
安全边界 (只删确认孤儿, 绝不碰有效数据):
- tmp: 状态 uploading/storing 且 mtime 新 (< 7 天) = 进行中, 保留;
终态 (done/failed) 超 1 小时 = 任务已结束, tmp 卷无价值, 删;
无任务记录超 1 小时 = 孤儿, 删; uploading/storing 超 7 天 = 僵尸任务
(客户端早已不在), 删
- storage: 只删 files.path 集合中不存在的目录/文件; 目录删除前检查其下
没有任何 files.path 前缀 (防父目录误删); mtime 超 24 小时才动
(给客户端 complete 重试窗口)
- 删除一律 shutil.rmtree, 失败跳过并记录
用法:
clean_orphans.py # 实际清理
clean_orphans.py --dry-run # 预览不删
clean_orphans.py --quiet # 静默 (日志仍写 /tmp/sz-clean.log, watchdog 调用用)
"""
import argparse
import os
import shutil
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
DB_PATH = Path("data/app.db")
TMP_ROOT = Path("data/tmp")
STORAGE_ROOT = Path("data/storage")
CLEAN_LOG = Path("/tmp/sz-clean.log")
# 终态任务 tmp 保留窗口 (秒): 任务结束 1 小时后卷可删
TMP_FINAL_AGE = 3600
# 进行中任务僵尸判定 (秒): 7 天无动静 = 客户端早死, 卷可删
TMP_STALE_AGE = 7 * 86400
# storage 孤儿 mtime 保留窗口 (秒): 24 小时, 给 complete 重试窗口
STORAGE_MIN_AGE = 86400
# 进行中的任务状态 (tmp 目录保留)
ACTIVE_STATUSES = {"uploading", "storing"}
def log(msg: str, quiet: bool = False) -> None:
"""写日志到 stdout 与 /tmp/sz-clean.log, 中文业务日志。"""
line = f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}"
if not quiet:
print(line)
try:
with CLEAN_LOG.open("a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
def load_db(db_path: Path) -> tuple[dict[str, str], set[str]]:
"""读数据库, 返回 (transfer_id->status 映射, files.path 集合)。
files.path 可能存相对路径 (data/storage/...) 或绝对路径, 统一 resolve
成绝对路径, 与磁盘遍历的 resolve 结果可比。
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
status_map: dict[str, str] = {}
for r in cur.execute("SELECT transfer_id, status FROM transfers"):
status_map[str(r["transfer_id"])] = str(r["status"])
paths: set[str] = set()
for r in cur.execute("SELECT path FROM files"):
paths.add(str(Path(str(r["path"])).resolve()))
conn.close()
return status_map, paths
def dir_age_ok(p: Path, min_age: int) -> bool:
"""路径 mtime 距今超过 min_age 秒才返回 True (过新的一律跳过)。"""
try:
return (datetime.now().timestamp() - p.stat().st_mtime) >= min_age
except OSError:
return False
def dir_size(p: Path) -> int:
"""目录内文件总字节数 (删除失败兜底统计用)。"""
return sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
def clean_tmp(
tmp_root: Path, status_map: dict[str, str], dry: bool, quiet: bool
) -> tuple[int, int]:
"""清理 tmp 下的孤儿任务目录, 返回 (删除数, 释放字节)。"""
removed = 0
freed = 0
if not tmp_root.is_dir():
return removed, freed
for tid_dir in sorted(tmp_root.iterdir()):
name = tid_dir.name
if not tid_dir.is_dir():
continue # 散文件不动
status = status_map.get(name) # None = 无任务记录
if status in ACTIVE_STATUSES:
# 进行中: 超 7 天没动静 = 僵尸任务, 删; 否则保留
if not dir_age_ok(tid_dir, TMP_STALE_AGE):
log(f"[tmp] {name} 任务进行中, 保留", quiet)
continue
log(f"[tmp] {name} 僵尸任务 (进行中超 7 天), 删除", quiet)
else:
# 无记录或终态 (done/failed): 超 1 小时可删
if not dir_age_ok(tid_dir, TMP_FINAL_AGE):
log(f"[tmp] {name} 目录过新, 跳过", quiet)
continue
reason = "无任务记录" if status is None else f"任务已{status}"
log(f"[tmp] {reason}, 删除孤儿任务目录 {name} ({dir_size(tid_dir) / 1024 / 1024:.1f} MB)", quiet)
if not dry:
size = dir_size(tid_dir)
try:
shutil.rmtree(tid_dir)
removed += 1
freed += size
except OSError as e:
log(f"[tmp] 删除失败 {name}: {e}", quiet)
return removed, freed
def clean_storage(
storage_root: Path, file_paths: set[str], dry: bool, quiet: bool
) -> tuple[int, int]:
"""清理 storage 下 files 表无记录的孤儿目录, 返回 (删除数, 释放字节)。
安全边界 (防误删有效数据):
- files.path 记录的是**目录路径**, 目录有效 => 内部所有文件有效,
**文件永不单独判断删除** (曾因逐文件判断把有效目录内的 chunk 误删)
- 目录 rel 在 file_paths 中, 或其下有任意 files.path 前缀 => 有效,
整个子树跳过 (剪枝不深入)
- 目录无记录且无子记录 => 孤儿候选, 整个目录删除 (含旧架构单文件
所在的日期目录, 一并覆盖)
"""
removed = 0
freed = 0
if not storage_root.is_dir():
return removed, freed
# 收集候选 (仅目录), 统一删除, 避免遍历中 rmtree
candidates: list[Path] = []
for dirpath, dirnames, filenames in os.walk(storage_root):
d = Path(dirpath)
if d == storage_root:
continue
rel = str(d.resolve())
if rel in file_paths:
# 有效叶子目录: files 记录精确指向它, 整个子树跳过
dirnames[:] = []
continue
if not any(fp.startswith(rel + "/") for fp in file_paths):
# 无记录且无子记录 => 孤儿候选, 整个目录删除
if dir_age_ok(d, STORAGE_MIN_AGE):
candidates.append(d)
dirnames[:] = [] # 目录将删, 剪枝不再深入
continue
# 有子记录 (父目录层如 user/日期): 不是孤儿, 深入遍历子目录
for p in candidates:
size = dir_size(p)
log(f"[storage] 删除孤儿目录 {p} ({size / 1024 / 1024:.1f} MB)", quiet)
if not dry:
try:
shutil.rmtree(p)
removed += 1
freed += size
except OSError as e:
log(f"[storage] 删除失败 {p}: {e}", quiet)
return removed, freed
def main() -> int:
parser = argparse.ArgumentParser(description="7z-encrypt 服务端孤儿文件清理")
parser.add_argument("--dry-run", action="store_true", help="预览模式, 不实际删除")
parser.add_argument("--quiet", action="store_true", help="静默模式, 只写日志文件")
parser.add_argument("--db", default=str(DB_PATH), help="SQLite 数据库路径")
parser.add_argument("--tmp", default=str(TMP_ROOT), help="临时目录根")
parser.add_argument("--storage", default=str(STORAGE_ROOT), help="存储目录根")
args = parser.parse_args()
# 默认路径 (data/app.db 等) 相对脚本所在目录解析, 调用方 cwd 无关
script_dir = Path(__file__).resolve().parent
db_path = Path(args.db)
if not db_path.is_absolute():
db_path = script_dir / db_path
tmp_root = Path(args.tmp)
if not tmp_root.is_absolute():
tmp_root = script_dir / tmp_root
storage_root = Path(args.storage)
if not storage_root.is_absolute():
storage_root = script_dir / storage_root
dry = args.dry_run
quiet = args.quiet
mode = "预览" if dry else "清理"
log(f"=== {mode}开始 (db={db_path}) ===", quiet)
if not db_path.is_file():
log(f"[错误] 数据库不存在: {db_path}", quiet)
return 1
status_map, file_paths = load_db(db_path)
log(f"[db] 任务 {len(status_map)} 个, 文件记录 {len(file_paths)} 条", quiet)
t_removed, t_freed = clean_tmp(tmp_root, status_map, dry, quiet)
s_removed, s_freed = clean_storage(storage_root, file_paths, dry, quiet)
total_removed = t_removed + s_removed
total_freed = t_freed + s_freed
log(
f"=== {mode}完成: 删除 {total_removed} 项, 释放 {total_freed / 1024 / 1024:.1f} MB ===",
quiet,
)
return 0
if __name__ == "__main__":
sys.exit(main())