service.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. """Background task helpers for the single-process zhuce6 runtime."""
  2. from __future__ import annotations
  3. from collections import deque
  4. from datetime import datetime
  5. import threading
  6. import time
  7. from typing import Callable
  8. def _isoformat_timestamp(value: float | None) -> str | None:
  9. if value is None:
  10. return None
  11. return datetime.fromtimestamp(value).isoformat(timespec="seconds")
  12. class RepeatedTask:
  13. def __init__(self, name: str, fn: Callable[[], None], interval_seconds: int) -> None:
  14. self.name = name
  15. self.fn = fn
  16. self.interval_seconds = max(1, int(interval_seconds))
  17. self._thread: threading.Thread | None = None
  18. self._stop_event = threading.Event()
  19. self._lock = threading.Lock()
  20. self._run_count = 0
  21. self._success_count = 0
  22. self._failure_count = 0
  23. self._last_started_at: float | None = None
  24. self._last_finished_at: float | None = None
  25. self._last_duration_seconds: float | None = None
  26. self._last_error: str | None = None
  27. self._next_run_at: float | None = None
  28. self._is_running = False
  29. self._recent_runs: deque[dict[str, object]] = deque(maxlen=20)
  30. def start(self) -> None:
  31. if self._thread and self._thread.is_alive():
  32. return
  33. self._stop_event.clear()
  34. with self._lock:
  35. self._next_run_at = time.time()
  36. self._thread = threading.Thread(target=self._run, daemon=True, name=f"zhuce6-{self.name}")
  37. self._thread.start()
  38. def stop(self) -> None:
  39. self._stop_event.set()
  40. if self._thread and self._thread.is_alive():
  41. self._thread.join(timeout=1)
  42. def _run(self) -> None:
  43. while not self._stop_event.is_set():
  44. cycle_started = time.time()
  45. cycle_error: str | None = None
  46. with self._lock:
  47. self._is_running = True
  48. self._run_count += 1
  49. self._last_started_at = cycle_started
  50. self._last_error = None
  51. try:
  52. self.fn()
  53. except Exception as exc:
  54. with self._lock:
  55. self._failure_count += 1
  56. self._last_error = str(exc)
  57. cycle_error = str(exc)
  58. print(f"[zhuce6:{self.name}] background task error: {exc}")
  59. else:
  60. with self._lock:
  61. self._success_count += 1
  62. self._last_error = None
  63. finally:
  64. cycle_finished = time.time()
  65. with self._lock:
  66. self._is_running = False
  67. self._last_finished_at = cycle_finished
  68. self._last_duration_seconds = round(cycle_finished - cycle_started, 3)
  69. self._recent_runs.append(
  70. {
  71. "started_at": _isoformat_timestamp(cycle_started),
  72. "finished_at": _isoformat_timestamp(cycle_finished),
  73. "duration_seconds": self._last_duration_seconds,
  74. "status": "failed" if cycle_error else "completed",
  75. "error": cycle_error,
  76. }
  77. )
  78. elapsed = time.time() - cycle_started
  79. wait_seconds = max(0.0, self.interval_seconds - elapsed)
  80. with self._lock:
  81. self._next_run_at = time.time() + wait_seconds
  82. if self._stop_event.wait(wait_seconds):
  83. break
  84. def snapshot(self) -> dict[str, object]:
  85. with self._lock:
  86. if self._is_running:
  87. status = "running"
  88. elif self._run_count == 0:
  89. status = "pending"
  90. elif self._last_error:
  91. status = "degraded"
  92. else:
  93. status = "healthy"
  94. return {
  95. "name": self.name,
  96. "status": status,
  97. "interval_seconds": self.interval_seconds,
  98. "run_count": self._run_count,
  99. "success_count": self._success_count,
  100. "failure_count": self._failure_count,
  101. "is_running": self._is_running,
  102. "last_started_at": _isoformat_timestamp(self._last_started_at),
  103. "last_finished_at": _isoformat_timestamp(self._last_finished_at),
  104. "last_duration_seconds": self._last_duration_seconds,
  105. "last_error": self._last_error,
  106. "next_run_at": _isoformat_timestamp(self._next_run_at),
  107. "recent_runs": list(self._recent_runs),
  108. }