Vanzeren 67c8ade375
feat(sandbox): warm pool for boxlite (#3951)
* feat: add boxlite SDK as harness dependency

* test: add fake SimpleBox fixtures for BoxLite warm pool tests

* feat: add deterministic sandbox_id and warm pool fields to BoxliteProvider

* feat: pass deterministic sandbox_id to SimpleBox name

* feat: warm pool lifecycle — park on release, reclaim on acquire

- release(): parks VMs in _warm_pool with timestamp instead of closing
- _reclaim_warm_pool(): health checks warm boxes via echo ok
- acquire(): tries warm pool reclaim before creating new boxes
- Deterministic sandbox_id ensures thread isolation

* feat(boxlite): idle reaper, replica enforcement, warm-pool shutdown/reset

- Task 6: idle reaper daemon thread destroys expired warm-pool boxes
- Task 7: replica enforcement evicts oldest warm-pool box when at capacity
- Task 8: shutdown() stops idle checker first, destroys all boxes (active+warm);
  reset() clears warm pool

Tests: 17 passed (4 new: idle reaper, replica enforcement, shutdown, reset)

* fix(boxlite): harden warm pool lifecycle races

* docs(boxlite): document warm pool configuration

* fix(sandbox): log warning when evicting oldest warm box is failed

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(boxlite): stop provider lifecycle on reset

* fix(boxlite): make runtime an optional dependency

* test(boxlite): remove unused warm pool lookup

* docs(boxlite): clarify optional runtime support

* refactor: extract shared WarmPoolLifecycleMixin for sandbox warm-pool lifecycle

Introduce deerflow.community.warm_pool_lifecycle.WarmPoolLifecycleMixin
owning idle-checker loop, warm-pool expiry, oldest-warm eviction,
replica counting, and soft-cap logging. Move AioSandboxProvider and
BoxliteProvider onto the mixin; keep AIO active-idle cleanup local and
delegate only warm-pool expiry to the shared helper.

BoxliteProvider also gains:
- Prefixed box names (deer-flow-boxlite-*) for startup orphan reconciliation
- _reconcile_orphans() adopting surviving boxes from a prior process
- Pinned timeout forwarding: command timeout now bounds both BoxLite
  SDK exec(timeout=...) and the loop bridge .result(timeout)
- reset() reworked as lightweight registry clear (boxes -> warm pool,
  no close, no idle-reaper stop, no loop close) so reset_sandbox_provider()
  config switches are safe; shutdown() remains the teardown path

Backward-compatible: AIO DEFAULT_IDLE_TIMEOUT/DEFAULT_REPLICAS/
IDLE_CHECK_INTERVAL stay importable; Boxlite IDLE_CHECK_INTERVAL
stays monkeypatchable.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-06 07:21:12 +08:00

128 lines
4.9 KiB
Python

"""Shared warm-pool lifecycle helpers for community sandbox providers."""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
DEFAULT_IDLE_TIMEOUT = 600
DEFAULT_REPLICAS = 3
IDLE_CHECK_INTERVAL = 60
class WarmPoolLifecycleMixin[WarmEntryT]:
"""Mixin for provider warm-pool expiry and replica lifecycle mechanics."""
DEFAULT_IDLE_TIMEOUT = DEFAULT_IDLE_TIMEOUT
DEFAULT_REPLICAS = DEFAULT_REPLICAS
IDLE_CHECK_INTERVAL = IDLE_CHECK_INTERVAL
_idle_checker_thread_name = "warm-pool-idle-checker"
_lock: threading.Lock
_warm_pool: dict[str, tuple[WarmEntryT, float]]
_config: dict[str, Any]
_idle_checker_stop: threading.Event
_idle_checker_thread: threading.Thread | None
def _active_count_locked(self) -> int:
"""Return active entry count while ``_lock`` is held."""
raise NotImplementedError
def _destroy_warm_entry(self, sandbox_id: str, entry: WarmEntryT, *, reason: str) -> None:
"""Destroy a warm-pool entry after it has been removed from the pool."""
raise NotImplementedError
def _replica_count(self) -> tuple[int, int]:
"""Return configured replicas and current active + warm entry count."""
replicas = int(self._config.get("replicas", DEFAULT_REPLICAS))
with self._lock:
total = self._active_count_locked() + len(self._warm_pool)
return replicas, total
def _log_replicas_soft_cap(self, replicas: int, sandbox_id: str, evicted: str | None) -> None:
"""Log the result of enforcing the warm-pool replica soft cap."""
if evicted is not None:
logger.info("Evicted warm-pool sandbox %s to stay within replicas=%s", evicted, replicas)
return
logger.warning(
"All %s replica slots are in active use; creating sandbox %s beyond the soft limit",
replicas,
sandbox_id,
)
def _evict_oldest_warm(self) -> str | None:
"""Remove and destroy the oldest warm entry by timestamp."""
with self._lock:
if not self._warm_pool:
return None
sandbox_id, (entry, _) = min(self._warm_pool.items(), key=lambda item: item[1][1])
self._warm_pool.pop(sandbox_id)
self._destroy_warm_entry(sandbox_id, entry, reason="replica_enforcement")
return sandbox_id
def _reap_expired_warm(self, idle_timeout: float | None = None) -> None:
"""Remove and destroy warm entries older than ``idle_timeout`` seconds."""
timeout = float(self._config.get("idle_timeout", DEFAULT_IDLE_TIMEOUT) if idle_timeout is None else idle_timeout)
if timeout <= 0:
return
now = time.time()
expired: list[tuple[str, WarmEntryT]] = []
with self._lock:
for sandbox_id, (entry, timestamp) in self._warm_pool.items():
if now - timestamp > timeout:
expired.append((sandbox_id, entry))
for sandbox_id, _ in expired:
self._warm_pool.pop(sandbox_id, None)
for sandbox_id, entry in expired:
self._destroy_warm_entry(sandbox_id, entry, reason="idle_timeout")
def _start_idle_checker(self) -> None:
"""Start the daemon thread that periodically cleans idle warm entries."""
if self._idle_checker_thread is not None and self._idle_checker_thread.is_alive():
return
self._idle_checker_stop.clear()
self._idle_checker_thread = threading.Thread(
target=self._idle_checker_loop,
name=self._idle_checker_thread_name,
daemon=True,
)
self._idle_checker_thread.start()
logger.info("Started warm-pool idle checker thread (timeout: %ss)", self._config.get("idle_timeout", DEFAULT_IDLE_TIMEOUT))
def _stop_idle_checker(self) -> None:
"""Stop the idle checker thread and wait for it to exit when running."""
self._idle_checker_stop.set()
thread = self._idle_checker_thread
if thread is not None and thread.is_alive() and thread is not threading.current_thread():
thread.join(timeout=5)
def _idle_checker_loop(self) -> None:
"""Run periodic idle cleanup until the stop event is set."""
idle_timeout = float(self._config.get("idle_timeout", DEFAULT_IDLE_TIMEOUT))
while not self._idle_checker_stop.wait(self.IDLE_CHECK_INTERVAL):
try:
self._cleanup_idle_resources(idle_timeout)
except Exception:
logger.exception("Error in warm-pool idle checker loop")
def _cleanup_idle_resources(self, idle_timeout: float) -> None:
"""Clean resources idle longer than ``idle_timeout`` seconds."""
self._reap_expired_warm(idle_timeout)
__all__ = [
"DEFAULT_IDLE_TIMEOUT",
"DEFAULT_REPLICAS",
"IDLE_CHECK_INTERVAL",
"WarmPoolLifecycleMixin",
]