| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- import argparse
- import asyncio
- import json
- import os
- import subprocess
- import sys
- from contextlib import suppress
- from datetime import UTC, datetime
- from pathlib import Path
- from time import monotonic
- from uuid import UUID, uuid4
- import mysql_recovery
- import psutil
- import redis_recovery
- from sqlalchemy.ext.asyncio import async_sessionmaker
- from zhifu.core.config import Settings
- from zhifu.core.database import create_engine
- from zhifu.modules.knowledge.config import KnowledgeSettings
- from zhifu.modules.knowledge.recovery import rebuild, validate_target
- ROOT = Path(__file__).resolve().parents[1]
- class RecoveryTimeout(TimeoutError):
- def __init__(self, process_ids):
- super().__init__("恢复阶段达到最长执行时间,子进程树已终止")
- self.process_ids = process_ids
- def output_path(value):
- path = Path(value).resolve()
- if not path.is_relative_to((ROOT / ".work").resolve()):
- raise ValueError("恢复编排报告必须保存在项目 .work 内")
- return path
- async def knowledge(arguments):
- engine = create_engine(Settings())
- try:
- return await rebuild(
- async_sessionmaker(engine, expire_on_commit=False), arguments.target_collection,
- tenant_id=arguments.tenant_id.bytes if arguments.tenant_id else None,
- maximum_documents=arguments.maximum_documents,
- maximum_chunks=arguments.maximum_chunks,
- timeout_seconds=min(arguments.timeout_seconds, 600),
- )
- finally:
- await engine.dispose()
- def phase(arguments):
- if arguments.phase == "mysql":
- directory = mysql_recovery.backup()
- report = mysql_recovery.restore(directory)
- elif arguments.phase == "redis":
- directory = redis_recovery.backup()
- report = redis_recovery.verify(directory)
- else:
- return asyncio.run(knowledge(arguments))
- return {"backup_directory": str(directory.relative_to(ROOT)), **report}
- def containers(run_id):
- result = subprocess.run(
- ["docker", "ps", "-aq", "--filter", "label=zhifu.recovery-run=" + run_id],
- check=True, capture_output=True, text=True, timeout=15,
- )
- return result.stdout.split()
- def terminate_tree(process):
- """暂停明确子进程树后终止,保证恢复阶段不能在容器清理后继续创建资源。"""
- root = psutil.Process(process.pid)
- suspended = {}
- # 暂停已发现进程后再次检查后代,最多扫描五轮覆盖阶段启动器和 Docker 子进程。
- for _ in range(5):
- candidates = [root, *root.children(recursive=True)]
- additions = [item for item in candidates if item.pid not in suspended]
- if not additions:
- break
- for item in additions:
- with suppress(psutil.NoSuchProcess):
- item.suspend()
- suspended[item.pid] = item
- for item in reversed(list(suspended.values())):
- with suppress(psutil.NoSuchProcess):
- item.kill()
- _, alive = psutil.wait_procs(list(suspended.values()), timeout=10)
- if alive:
- raise RuntimeError("恢复阶段子进程未在十秒内终止")
- process.wait(timeout=5)
- return sorted(suspended)
- def run_phase(command, *, log_path, timeout, environment):
- with log_path.open("xb") as log:
- process = subprocess.Popen(
- command, env=environment, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT,
- )
- try:
- returncode = process.wait(timeout=timeout)
- except subprocess.TimeoutExpired:
- identifiers = terminate_tree(process)
- raise RecoveryTimeout(identifiers) from None
- if returncode:
- raise subprocess.CalledProcessError(returncode, command)
- def exercise(arguments):
- if not 60 <= arguments.timeout_seconds <= 1800:
- raise ValueError("恢复编排最长执行时间必须在 60–1800 秒之间")
- if (
- not 1 <= arguments.maximum_documents <= 100000
- or not 1 <= arguments.maximum_chunks <= 1000000
- ):
- raise ValueError("资料与分段数量上限无效")
- validate_target(KnowledgeSettings(), arguments.target_collection)
- if not 1 <= arguments.phase_timeout_seconds <= 1800:
- raise ValueError("单个恢复阶段最长执行时间必须在 1–1800 秒之间")
- output = output_path(arguments.output)
- output.parent.mkdir(parents=True, exist_ok=True)
- run_id = uuid4().hex
- directory = output.parent / ("recovery-phases-" + run_id)
- directory.mkdir()
- report = {
- "run_id": run_id, "started_at": datetime.now(UTC).isoformat(), "status": "RUNNING",
- "maximum_seconds": arguments.timeout_seconds, "phases": {},
- "maximum_phase_seconds": arguments.phase_timeout_seconds,
- "generation_attempts": 0, "business_targets_switched": False,
- "consistent_cross_system_snapshot": False,
- }
- started = monotonic()
- environment = {**os.environ, "ZHIFU_RECOVERY_RUN_ID": run_id}
- try:
- for name in ("mysql", "redis", "knowledge"):
- remaining = arguments.timeout_seconds - (monotonic() - started)
- if remaining <= 0:
- raise TimeoutError("恢复编排达到最长执行时间")
- result = directory / (name + ".json")
- command = [
- sys.executable, str(Path(__file__).resolve()), "--phase", name,
- "--output", str(result), "--target-collection", arguments.target_collection,
- "--maximum-documents", str(arguments.maximum_documents),
- "--maximum-chunks", str(arguments.maximum_chunks),
- "--timeout-seconds", str(max(1, int(remaining))),
- ]
- if arguments.tenant_id:
- command.extend(["--tenant-id", str(arguments.tenant_id)])
- # 超时先终止阶段的整个进程树,再按随机运行标识清理独立恢复容器。
- run_phase(command, log_path=directory / (name + ".log"),
- timeout=min(remaining, arguments.phase_timeout_seconds),
- environment=environment)
- report["phases"][name] = json.loads(result.read_text(encoding="utf-8"))
- report["status"] = "VERIFIED"
- except RecoveryTimeout as error:
- report["terminated_process_ids"] = error.process_ids
- raise
- finally:
- remaining_containers = containers(run_id)
- report["temporary_containers_cleaned"] = remaining_containers
- if remaining_containers:
- subprocess.run(
- ["docker", "rm", "-fv", *remaining_containers], check=True,
- capture_output=True, timeout=30,
- )
- report["temporary_containers_remaining"] = containers(run_id)
- report["seconds"] = monotonic() - started
- report["finished_at"] = datetime.now(UTC).isoformat()
- if report["status"] == "RUNNING":
- report["status"] = "INCOMPLETE"
- output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
- return report
- if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="顺序验证 MySQL、Redis 和独立知识集合恢复")
- parser.add_argument("--phase", choices=["mysql", "redis", "knowledge"])
- parser.add_argument("--output", default=".work/recovery-exercise.json")
- parser.add_argument("--target-collection", required=True)
- parser.add_argument("--tenant-id", type=UUID)
- parser.add_argument("--maximum-documents", type=int, default=1000)
- parser.add_argument("--maximum-chunks", type=int, default=10000)
- parser.add_argument("--timeout-seconds", type=int, default=600)
- parser.add_argument("--phase-timeout-seconds", type=int, default=600)
- arguments = parser.parse_args()
- if arguments.phase:
- output_path(arguments.output).write_text(
- json.dumps(phase(arguments), ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
- )
- else:
- print(json.dumps(exercise(arguments), ensure_ascii=False, indent=2))
|