recovery_exercise.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import argparse
  2. import asyncio
  3. import json
  4. import os
  5. import subprocess
  6. import sys
  7. from contextlib import suppress
  8. from datetime import UTC, datetime
  9. from pathlib import Path
  10. from time import monotonic
  11. from uuid import UUID, uuid4
  12. import mysql_recovery
  13. import psutil
  14. import redis_recovery
  15. from sqlalchemy.ext.asyncio import async_sessionmaker
  16. from zhifu.core.config import Settings
  17. from zhifu.core.database import create_engine
  18. from zhifu.modules.knowledge.config import KnowledgeSettings
  19. from zhifu.modules.knowledge.recovery import rebuild, validate_target
  20. ROOT = Path(__file__).resolve().parents[1]
  21. class RecoveryTimeout(TimeoutError):
  22. def __init__(self, process_ids):
  23. super().__init__("恢复阶段达到最长执行时间,子进程树已终止")
  24. self.process_ids = process_ids
  25. def output_path(value):
  26. path = Path(value).resolve()
  27. if not path.is_relative_to((ROOT / ".work").resolve()):
  28. raise ValueError("恢复编排报告必须保存在项目 .work 内")
  29. return path
  30. async def knowledge(arguments):
  31. engine = create_engine(Settings())
  32. try:
  33. return await rebuild(
  34. async_sessionmaker(engine, expire_on_commit=False), arguments.target_collection,
  35. tenant_id=arguments.tenant_id.bytes if arguments.tenant_id else None,
  36. maximum_documents=arguments.maximum_documents,
  37. maximum_chunks=arguments.maximum_chunks,
  38. timeout_seconds=min(arguments.timeout_seconds, 600),
  39. )
  40. finally:
  41. await engine.dispose()
  42. def phase(arguments):
  43. if arguments.phase == "mysql":
  44. directory = mysql_recovery.backup()
  45. report = mysql_recovery.restore(directory)
  46. elif arguments.phase == "redis":
  47. directory = redis_recovery.backup()
  48. report = redis_recovery.verify(directory)
  49. else:
  50. return asyncio.run(knowledge(arguments))
  51. return {"backup_directory": str(directory.relative_to(ROOT)), **report}
  52. def containers(run_id):
  53. result = subprocess.run(
  54. ["docker", "ps", "-aq", "--filter", "label=zhifu.recovery-run=" + run_id],
  55. check=True, capture_output=True, text=True, timeout=15,
  56. )
  57. return result.stdout.split()
  58. def terminate_tree(process):
  59. """暂停明确子进程树后终止,保证恢复阶段不能在容器清理后继续创建资源。"""
  60. root = psutil.Process(process.pid)
  61. suspended = {}
  62. # 暂停已发现进程后再次检查后代,最多扫描五轮覆盖阶段启动器和 Docker 子进程。
  63. for _ in range(5):
  64. candidates = [root, *root.children(recursive=True)]
  65. additions = [item for item in candidates if item.pid not in suspended]
  66. if not additions:
  67. break
  68. for item in additions:
  69. with suppress(psutil.NoSuchProcess):
  70. item.suspend()
  71. suspended[item.pid] = item
  72. for item in reversed(list(suspended.values())):
  73. with suppress(psutil.NoSuchProcess):
  74. item.kill()
  75. _, alive = psutil.wait_procs(list(suspended.values()), timeout=10)
  76. if alive:
  77. raise RuntimeError("恢复阶段子进程未在十秒内终止")
  78. process.wait(timeout=5)
  79. return sorted(suspended)
  80. def run_phase(command, *, log_path, timeout, environment):
  81. with log_path.open("xb") as log:
  82. process = subprocess.Popen(
  83. command, env=environment, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT,
  84. )
  85. try:
  86. returncode = process.wait(timeout=timeout)
  87. except subprocess.TimeoutExpired:
  88. identifiers = terminate_tree(process)
  89. raise RecoveryTimeout(identifiers) from None
  90. if returncode:
  91. raise subprocess.CalledProcessError(returncode, command)
  92. def exercise(arguments):
  93. if not 60 <= arguments.timeout_seconds <= 1800:
  94. raise ValueError("恢复编排最长执行时间必须在 60–1800 秒之间")
  95. if (
  96. not 1 <= arguments.maximum_documents <= 100000
  97. or not 1 <= arguments.maximum_chunks <= 1000000
  98. ):
  99. raise ValueError("资料与分段数量上限无效")
  100. validate_target(KnowledgeSettings(), arguments.target_collection)
  101. if not 1 <= arguments.phase_timeout_seconds <= 1800:
  102. raise ValueError("单个恢复阶段最长执行时间必须在 1–1800 秒之间")
  103. output = output_path(arguments.output)
  104. output.parent.mkdir(parents=True, exist_ok=True)
  105. run_id = uuid4().hex
  106. directory = output.parent / ("recovery-phases-" + run_id)
  107. directory.mkdir()
  108. report = {
  109. "run_id": run_id, "started_at": datetime.now(UTC).isoformat(), "status": "RUNNING",
  110. "maximum_seconds": arguments.timeout_seconds, "phases": {},
  111. "maximum_phase_seconds": arguments.phase_timeout_seconds,
  112. "generation_attempts": 0, "business_targets_switched": False,
  113. "consistent_cross_system_snapshot": False,
  114. }
  115. started = monotonic()
  116. environment = {**os.environ, "ZHIFU_RECOVERY_RUN_ID": run_id}
  117. try:
  118. for name in ("mysql", "redis", "knowledge"):
  119. remaining = arguments.timeout_seconds - (monotonic() - started)
  120. if remaining <= 0:
  121. raise TimeoutError("恢复编排达到最长执行时间")
  122. result = directory / (name + ".json")
  123. command = [
  124. sys.executable, str(Path(__file__).resolve()), "--phase", name,
  125. "--output", str(result), "--target-collection", arguments.target_collection,
  126. "--maximum-documents", str(arguments.maximum_documents),
  127. "--maximum-chunks", str(arguments.maximum_chunks),
  128. "--timeout-seconds", str(max(1, int(remaining))),
  129. ]
  130. if arguments.tenant_id:
  131. command.extend(["--tenant-id", str(arguments.tenant_id)])
  132. # 超时先终止阶段的整个进程树,再按随机运行标识清理独立恢复容器。
  133. run_phase(command, log_path=directory / (name + ".log"),
  134. timeout=min(remaining, arguments.phase_timeout_seconds),
  135. environment=environment)
  136. report["phases"][name] = json.loads(result.read_text(encoding="utf-8"))
  137. report["status"] = "VERIFIED"
  138. except RecoveryTimeout as error:
  139. report["terminated_process_ids"] = error.process_ids
  140. raise
  141. finally:
  142. remaining_containers = containers(run_id)
  143. report["temporary_containers_cleaned"] = remaining_containers
  144. if remaining_containers:
  145. subprocess.run(
  146. ["docker", "rm", "-fv", *remaining_containers], check=True,
  147. capture_output=True, timeout=30,
  148. )
  149. report["temporary_containers_remaining"] = containers(run_id)
  150. report["seconds"] = monotonic() - started
  151. report["finished_at"] = datetime.now(UTC).isoformat()
  152. if report["status"] == "RUNNING":
  153. report["status"] = "INCOMPLETE"
  154. output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
  155. return report
  156. if __name__ == "__main__":
  157. parser = argparse.ArgumentParser(description="顺序验证 MySQL、Redis 和独立知识集合恢复")
  158. parser.add_argument("--phase", choices=["mysql", "redis", "knowledge"])
  159. parser.add_argument("--output", default=".work/recovery-exercise.json")
  160. parser.add_argument("--target-collection", required=True)
  161. parser.add_argument("--tenant-id", type=UUID)
  162. parser.add_argument("--maximum-documents", type=int, default=1000)
  163. parser.add_argument("--maximum-chunks", type=int, default=10000)
  164. parser.add_argument("--timeout-seconds", type=int, default=600)
  165. parser.add_argument("--phase-timeout-seconds", type=int, default=600)
  166. arguments = parser.parse_args()
  167. if arguments.phase:
  168. output_path(arguments.output).write_text(
  169. json.dumps(phase(arguments), ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
  170. )
  171. else:
  172. print(json.dumps(exercise(arguments), ensure_ascii=False, indent=2))