mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
fix(sandbox): enforce deployment-wide E2B capacity (#4575)
* docs: design deployment-wide E2B capacity * fix(sandbox): enforce deployment-wide E2B capacity * fix(sandbox): address E2B capacity review findings * fix(sandbox): grace stale E2B capacity inventory --------- Co-authored-by: MiaoRuidx <12540796+MiaoRuidx@users.noreply.github.com>
This commit is contained in:
parent
d0957409f1
commit
0cc28d2c42
9
.github/workflows/backend-unit-tests.yml
vendored
9
.github/workflows/backend-unit-tests.yml
vendored
@ -61,6 +61,15 @@ jobs:
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
TEST_POSTGRES_URI: postgresql://deerflow:deerflow@localhost:5432/deerflow_test?sslmode=disable
|
||||
|
||||
12
README.md
12
README.md
@ -907,7 +907,17 @@ Use `burst` with `burst_limit` to permit bounded extra VMs. The `wait` and
|
||||
`reject` policies use only `replicas`. The `reject` policy can remove one warm
|
||||
VM before it returns an error.
|
||||
|
||||
`replicas` limits one Gateway process. It does not limit all Gateway processes.
|
||||
With in-memory ownership, `replicas` limits one Gateway process. With Redis
|
||||
ownership, E2B shares one capacity Hash between workers using the same
|
||||
`sandbox.ownership.key_prefix`; `replicas` (plus a configured burst) is then a
|
||||
deployment-wide hard limit. Use one unique prefix and the same effective limit
|
||||
per deployment. To change the limit, stop its Gateways, delete the capacity
|
||||
Hash, and restart; mismatched workers fail closed.
|
||||
|
||||
The Hash counts remote VMs and in-flight creates, repairs interrupted creates
|
||||
from E2B metadata, grace-protects stale inventory omissions, and blocks new
|
||||
creates while Redis or initial inventory is unavailable. Run Redis with persistence, non-evicting memory, and HA.
|
||||
|
||||
E2B acquisition uses a bounded executor. Waiting acquisitions do not use the
|
||||
default asyncio executor.
|
||||
|
||||
|
||||
@ -616,8 +616,13 @@ that cannot tell sibling branches apart.
|
||||
The `wait` policy fails the turn after `acquire_timeout`. The runtime does not
|
||||
retry the turn automatically. E2B acquisition uses a bounded executor.
|
||||
Waiting calls do not consume the default asyncio executor. The `reject`
|
||||
policy can evict one warm VM before it returns an error. `replicas` limits
|
||||
one Gateway process. It does not provide multi-process capacity control.
|
||||
policy can evict one warm VM before it returns an error. With memory
|
||||
ownership, `replicas` limits one Gateway process. Redis ownership shares one
|
||||
`<ownership.key_prefix>:e2b-capacity` Hash, making the limit (plus a bounded
|
||||
burst) deployment-wide. Lua atomically manages VM and in-flight-create
|
||||
entries; missing or unavailable state fails closed. E2B reservation metadata
|
||||
repairs interrupted creates. Inventory replacement is revision-CAS guarded,
|
||||
and incomplete inventories never remove entries; complete omissions get a grace period.
|
||||
Uncertain cleanup keeps a tombstone slot. Shutdown tracks owned remote
|
||||
operation IDs. Discovery can find a VM from another Gateway. Shutdown closes
|
||||
an unowned discovery client without destroying its VM. Release ends its
|
||||
@ -656,7 +661,7 @@ that cannot tell sibling branches apart.
|
||||
- **Teardown join budget covers refresh plus release.** Redis bounds each ownership operation at five seconds, and context exit can catch the heartbeat in one final refresh before its `finally` performs the final release. `_TEARDOWN_JOIN_TIMEOUT_SECONDS` is therefore 12 seconds — greater than both sequential operation bounds — so a normal pair of socket timeouts does not emit the deferred-release warning; a still-running heartbeat continues to own the release safely.
|
||||
- **An absent lease means the same thing on both paths, and reconciliation must say so too.** The `LAPSED` rule above only covers an owner renewing its *own* lease; on its own it does not make state loss safe, because reconciliation reads the same absent key as "orphan, adopt". After a Redis flush (restart without persistence, or eviction under `maxmemory`) every owner is alive and merely pre-renewal-tick, so whichever instance reconciles first would adopt every live container, each real owner's next renewal would report `LOST`, and it would drop a sandbox mid-turn for the adopter to idle-destroy — #4206 through the back door. `_adoptable_after_grace` closes it: an untracked container must be seen unowned (`owner()`, a read-only peek — the atomic `claim()` is still what actually gates adoption) across a full lease TTL before it can be adopted, tracked per container in `_unowned_since`. That rebuilds the delay the flush erased — a live owner republishes within one renewal interval, shorter than the TTL by construction (`ttl_multiplier >= 2`) — while a genuinely crashed owner never republishes, so its containers are still adopted one grace later rather than leaking. A republished lease **resets** the grace; a pausing-only timer would still expire over a live owner's lease. The grace is skipped when `supports_cross_process` is `False`: no peer can hold a lease such a store would show us, so single-instance deployments keep instant orphan cleanup, and a grace could not help a multi-worker gateway on `memory` anyway (peers are invisible to each other's leases with or without it).
|
||||
- **The `memory` store is single-instance only** and says so via `supports_cross_process = False`; the provider logs a warning at startup when the configured store cannot see peers. A multi-worker gateway on `memory` has no cross-process coordination at all — same contract as `stream_bridge`'s memory backend. This is why the redis inference matters: it reads `app_config.stream_bridge` **and** the env var, in the same order the bridge's own resolver does, so any deployment already pointing the bridge at Redis (i.e. every multi-instance one) gets a redis ownership store without extra config.
|
||||
- `get()` stays a pure in-memory lookup and must never call the store (that is blocking filesystem/network IO on the event loop); anchored by `tests/blocking_io/test_aio_sandbox_get.py`, which injects a deliberately-blocking probe store so the anchor keeps its teeth regardless of the configured backend. Tests: `tests/test_sandbox_ownership_store.py` (store contract, defined once for **both** backends — but the redis tier is `@pytest.mark.integration` + opt-in via `DEER_FLOW_TEST_REDIS_URL` and self-skips, and **CI provisions no redis**, so the merge gate runs the memory tier only and the Lua scripts never execute there; drift between the backends is caught only when the suite runs against a live redis. There is no fake-redis tier because the redis exclusion lives in Lua a fake would not execute) and `tests/test_sandbox_orphan_reconciliation.py` (provider behaviour, two providers sharing one store).
|
||||
- `get()` stays a pure in-memory lookup and must never call the store (that is blocking filesystem/network IO on the event loop); anchored by `tests/blocking_io/test_aio_sandbox_get.py`, which injects a deliberately-blocking probe store so the anchor keeps its teeth regardless of the configured backend. Tests: `tests/test_sandbox_ownership_store.py` (store contract, defined once for **both** backends — the redis tier is `@pytest.mark.integration`, uses `DEER_FLOW_TEST_REDIS_URL` when set, and otherwise self-skips without a reachable Redis. Backend CI provisions Redis, so the merge gate executes the real Lua tier; there is no fake-redis tier because a fake would not execute the Lua exclusions) and `tests/test_sandbox_orphan_reconciliation.py` (provider behaviour, two providers sharing one store).
|
||||
- `BoxliteProvider` (`packages/harness/deerflow/community/boxlite/`) - BoxLite micro-VM isolation. The `boxlite` runtime is optional (`deerflow-harness[boxlite]`) and lazy-imported only when this provider is selected. The provider owns one private asyncio event loop on a daemon thread because BoxLite handles are loop-affine; sync `Sandbox` calls marshal onto that loop with `run_coroutine_threadsafe`.
|
||||
Boxes are named deterministically from `user_id:thread_id`, released into an in-process warm pool after each agent turn, and reclaimed only by the same user/thread. Warm-pool health checks use a short explicit timeout and forward that timeout through both BoxLite `exec(timeout=...)` and the private-loop `.result(timeout)` bridge so a hung VM cannot pin the per-thread acquire lock indefinitely.
|
||||
`sandbox.replicas` caps active + warm VMs per gateway process; if capacity is exhausted, only warm-pool VMs are evicted. `sandbox.idle_timeout` stops idle warm VMs after the configured seconds. `reset()` is intentionally a lightweight registry clear for `reset_sandbox_provider()` and does not close boxes, stop the idle reaper, or close the private loop; full teardown remains `shutdown()`.
|
||||
|
||||
@ -61,7 +61,10 @@ def resolve_ownership_config(config: SandboxOwnershipConfig | None, *, stream_br
|
||||
return SandboxOwnershipConfig()
|
||||
|
||||
|
||||
def _resolve_redis_url(config: SandboxOwnershipConfig) -> str:
|
||||
def resolve_ownership_redis_url(
|
||||
config: SandboxOwnershipConfig,
|
||||
) -> str:
|
||||
"""Resolve the Redis endpoint shared by ownership-adjacent stores."""
|
||||
return config.redis_url or os.getenv(_ENV_OWNERSHIP_REDIS_URL) or os.getenv(_ENV_STREAM_BRIDGE_REDIS_URL) or os.getenv("REDIS_URL") or "redis://localhost:6379/0"
|
||||
|
||||
|
||||
@ -96,7 +99,7 @@ def make_sandbox_ownership_store(config: SandboxOwnershipConfig | None, *, owner
|
||||
if resolved.type == "redis":
|
||||
from .redis import RedisOwnershipStore
|
||||
|
||||
redis_url = _resolve_redis_url(resolved)
|
||||
redis_url = resolve_ownership_redis_url(resolved)
|
||||
logger.info("Sandbox ownership store: redis (ttl=%.1fs, renewal=%.1fs)", ttl, resolved.renewal_interval_seconds)
|
||||
return RedisOwnershipStore(
|
||||
owner_id=effective_owner_id,
|
||||
|
||||
@ -12,8 +12,8 @@ Configuration example (``config.yaml``)::
|
||||
template: code-interpreter-v1 # e2b template id; defaults to e2b code-interpreter
|
||||
domain: e2b.dev # optional e2b domain (e.g. self-hosted)
|
||||
idle_timeout: 600 # forwarded to e2b ``set_timeout`` (seconds)
|
||||
replicas: 3 # max concurrent sandboxes (LRU eviction beyond)
|
||||
ownership: # required for safe multi-worker reconciliation
|
||||
replicas: 3 # hard capacity shared when ownership is Redis
|
||||
ownership: # multi-worker ownership + capacity coordination
|
||||
type: redis
|
||||
redis_url: $REDIS_URL
|
||||
reconciliation_interval_seconds: 60
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
"""Redis-backed deployment-wide E2B capacity."""
|
||||
|
||||
from .redis import CapacityBackendError as CapacityBackendError
|
||||
from .redis import RedisE2BCapacityStore as RedisE2BCapacityStore
|
||||
from .redis import ReserveStatus as ReserveStatus
|
||||
from .redis import make_e2b_capacity_store as make_e2b_capacity_store
|
||||
@ -0,0 +1,279 @@
|
||||
"""Atomic Redis Hash ledger for deployment-wide E2B capacity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
|
||||
from deerflow.community.aio_sandbox.ownership.factory import resolve_ownership_redis_url
|
||||
from deerflow.config.sandbox_config import SandboxOwnershipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SOCKET_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
_LEDGER_SCRIPT = """
|
||||
local function now_ms()
|
||||
local current = redis.call('TIME')
|
||||
return tonumber(current[1]) * 1000 + math.floor(tonumber(current[2]) / 1000)
|
||||
end
|
||||
|
||||
local function initialize(hard_limit)
|
||||
redis.call('HSET', KEYS[1],
|
||||
'meta:state', 'initializing',
|
||||
'meta:hard_limit', hard_limit,
|
||||
'meta:revision', '0')
|
||||
end
|
||||
|
||||
local function mark_present(sandbox_id)
|
||||
local field = 's:' .. sandbox_id
|
||||
if redis.call('HGET', KEYS[1], field) == '1' then
|
||||
return false
|
||||
end
|
||||
redis.call('HSET', KEYS[1], field, '1')
|
||||
return true
|
||||
end
|
||||
|
||||
local operation = ARGV[1]
|
||||
local hard_limit = ARGV[2]
|
||||
local state = redis.call('HGET', KEYS[1], 'meta:state')
|
||||
local stored_limit = redis.call('HGET', KEYS[1], 'meta:hard_limit')
|
||||
if state ~= false and stored_limit ~= hard_limit then
|
||||
return redis.error_reply(
|
||||
'E2B capacity ledger configuration mismatch: configured hard_limit='
|
||||
.. hard_limit .. ', ledger hard_limit=' .. (stored_limit or '')
|
||||
)
|
||||
end
|
||||
|
||||
if operation == 'revision' then
|
||||
return tonumber(redis.call('HGET', KEYS[1], 'meta:revision') or '0')
|
||||
end
|
||||
|
||||
if operation == 'reserve' then
|
||||
if state ~= 'ready' then
|
||||
return 'NOT_READY'
|
||||
end
|
||||
local field = 'r:' .. ARGV[3]
|
||||
if redis.call('HEXISTS', KEYS[1], field) == 1 then
|
||||
return 'GRANTED'
|
||||
end
|
||||
if redis.call('HLEN', KEYS[1]) - 3 >= tonumber(hard_limit) then
|
||||
return 'FULL'
|
||||
end
|
||||
redis.call('HSET', KEYS[1], field, tostring(now_ms()))
|
||||
redis.call('HINCRBY', KEYS[1], 'meta:revision', 1)
|
||||
return 'GRANTED'
|
||||
end
|
||||
|
||||
if operation == 'release' then
|
||||
if state ~= false and redis.call('HDEL', KEYS[1], 's:' .. ARGV[3]) == 1 then
|
||||
redis.call('HINCRBY', KEYS[1], 'meta:revision', 1)
|
||||
end
|
||||
return 'OK'
|
||||
end
|
||||
|
||||
if state == false then
|
||||
if operation == 'reconcile' and tonumber(ARGV[3]) ~= 0 then
|
||||
return 'STALE'
|
||||
end
|
||||
initialize(hard_limit)
|
||||
state = 'initializing'
|
||||
end
|
||||
|
||||
if operation == 'track' then
|
||||
local changed = false
|
||||
if ARGV[3] ~= '' and redis.call('HDEL', KEYS[1], 'r:' .. ARGV[3]) == 1 then
|
||||
changed = true
|
||||
end
|
||||
if mark_present(ARGV[4]) then
|
||||
changed = true
|
||||
end
|
||||
if changed then
|
||||
redis.call('HINCRBY', KEYS[1], 'meta:revision', 1)
|
||||
end
|
||||
return 'OK'
|
||||
end
|
||||
|
||||
if operation ~= 'reconcile' then
|
||||
return redis.error_reply('unknown E2B capacity operation: ' .. operation)
|
||||
end
|
||||
|
||||
local expected_revision = tonumber(ARGV[3])
|
||||
local revision = tonumber(redis.call('HGET', KEYS[1], 'meta:revision') or '0')
|
||||
if revision ~= expected_revision then
|
||||
return 'STALE'
|
||||
end
|
||||
|
||||
local complete = ARGV[4] == '1'
|
||||
local remote_ids = {}
|
||||
local changed = false
|
||||
for index = 6, #ARGV, 2 do
|
||||
local sandbox_id = ARGV[index]
|
||||
local token = ARGV[index + 1]
|
||||
remote_ids[sandbox_id] = true
|
||||
if token ~= '' and redis.call('HDEL', KEYS[1], 'r:' .. token) == 1 then
|
||||
changed = true
|
||||
end
|
||||
if mark_present(sandbox_id) then
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
if complete then
|
||||
local stale_before_ms = now_ms() - tonumber(ARGV[5])
|
||||
for _, field in ipairs(redis.call('HKEYS', KEYS[1])) do
|
||||
local prefix = string.sub(field, 1, 2)
|
||||
local identifier = string.sub(field, 3)
|
||||
if prefix == 's:' and remote_ids[identifier] ~= true then
|
||||
local missing_since_ms = tonumber(string.match(redis.call('HGET', KEYS[1], field) or '', '^m:(%d+)$'))
|
||||
if missing_since_ms == nil then
|
||||
redis.call('HSET', KEYS[1], field, 'm:' .. now_ms())
|
||||
changed = true
|
||||
elseif missing_since_ms <= stale_before_ms then
|
||||
redis.call('HDEL', KEYS[1], field)
|
||||
changed = true
|
||||
end
|
||||
elseif prefix == 'r:' then
|
||||
local created_ms = tonumber(redis.call('HGET', KEYS[1], field))
|
||||
if created_ms ~= nil and created_ms <= stale_before_ms then
|
||||
redis.call('HDEL', KEYS[1], field)
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
if state ~= 'ready' then
|
||||
redis.call('HSET', KEYS[1], 'meta:state', 'ready')
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
if changed then
|
||||
redis.call('HINCRBY', KEYS[1], 'meta:revision', 1)
|
||||
end
|
||||
return 'APPLIED'
|
||||
"""
|
||||
|
||||
|
||||
class CapacityBackendError(RuntimeError):
|
||||
"""Redis could not return a definitive capacity decision."""
|
||||
|
||||
|
||||
class ReserveStatus(enum.StrEnum):
|
||||
GRANTED = "GRANTED"
|
||||
FULL = "FULL"
|
||||
NOT_READY = "NOT_READY"
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return value.decode() if isinstance(value, bytes) else str(value)
|
||||
|
||||
|
||||
class RedisE2BCapacityStore:
|
||||
"""One capacity scope stored in one Redis Hash."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis_url: str,
|
||||
hard_limit: int,
|
||||
key_prefix: str = "deerflow:sandbox:owner",
|
||||
) -> None:
|
||||
if hard_limit < 1:
|
||||
raise ValueError("hard_limit must be at least 1")
|
||||
try:
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
except ImportError: # pragma: no cover - optional extra
|
||||
raise ImportError("Redis E2B capacity requires: cd backend && uv sync --extra redis") from None
|
||||
|
||||
self._hard_limit = hard_limit
|
||||
self._key = f"{key_prefix.rstrip(':')}:e2b-capacity"
|
||||
self._redis_error = RedisError
|
||||
self._redis = Redis.from_url(
|
||||
redis_url,
|
||||
decode_responses=True,
|
||||
socket_timeout=_SOCKET_TIMEOUT_SECONDS,
|
||||
socket_connect_timeout=_SOCKET_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._script = self._redis.register_script(_LEDGER_SCRIPT)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return self._key
|
||||
|
||||
def _run(self, operation: str, *args: object) -> object:
|
||||
try:
|
||||
return self._script(keys=[self._key], args=[operation, self._hard_limit, *args])
|
||||
except self._redis_error as error:
|
||||
raise CapacityBackendError(f"failed to {operation} E2B capacity in Redis: {error}") from error
|
||||
|
||||
def revision(self) -> int:
|
||||
return int(self._run("revision"))
|
||||
|
||||
def reserve(self, token: str) -> ReserveStatus:
|
||||
if not token:
|
||||
raise ValueError("token must not be empty")
|
||||
try:
|
||||
return ReserveStatus(_text(self._run("reserve", token)))
|
||||
except ValueError as error:
|
||||
raise CapacityBackendError("unexpected E2B capacity reserve result") from error
|
||||
|
||||
def track(
|
||||
self,
|
||||
sandbox_id: str,
|
||||
*,
|
||||
reservation_token: str | None = None,
|
||||
) -> None:
|
||||
if not sandbox_id:
|
||||
raise ValueError("sandbox_id must not be empty")
|
||||
self._run("track", reservation_token or "", sandbox_id)
|
||||
|
||||
def release(self, sandbox_id: str) -> None:
|
||||
if sandbox_id:
|
||||
self._run("release", sandbox_id)
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
expected_revision: int,
|
||||
remote_sandboxes: dict[str, str | None],
|
||||
complete: bool,
|
||||
reservation_max_age_ms: int,
|
||||
) -> bool:
|
||||
remote_args = [item for sandbox_id, token in remote_sandboxes.items() for item in (sandbox_id, token or "")]
|
||||
status = _text(
|
||||
self._run(
|
||||
"reconcile",
|
||||
expected_revision,
|
||||
"1" if complete else "0",
|
||||
reservation_max_age_ms,
|
||||
*remote_args,
|
||||
)
|
||||
)
|
||||
if status not in {"APPLIED", "STALE"}:
|
||||
raise CapacityBackendError(f"unexpected E2B capacity reconciliation result: {status}")
|
||||
return status == "APPLIED"
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._redis.close()
|
||||
except Exception as error: # pragma: no cover - teardown best effort
|
||||
logger.warning("Error closing E2B capacity Redis client: %s", error)
|
||||
|
||||
|
||||
def make_e2b_capacity_store(
|
||||
ownership: SandboxOwnershipConfig,
|
||||
*,
|
||||
hard_limit: int,
|
||||
) -> RedisE2BCapacityStore | None:
|
||||
"""Enable the shared ledger only with Redis ownership."""
|
||||
if ownership.type == "memory":
|
||||
return None
|
||||
if ownership.type != "redis":
|
||||
raise ValueError(f"Unknown sandbox ownership type: {ownership.type!r}")
|
||||
logger.info("E2B deployment capacity: redis (key_prefix=%s, hard_limit=%d)", ownership.key_prefix, hard_limit)
|
||||
return RedisE2BCapacityStore(
|
||||
redis_url=resolve_ownership_redis_url(ownership),
|
||||
hard_limit=hard_limit,
|
||||
key_prefix=ownership.key_prefix,
|
||||
)
|
||||
@ -15,6 +15,9 @@ provider fields during startup.
|
||||
overflow_policy: wait # wait | reject | burst (default: wait)
|
||||
acquire_timeout: 30 # seconds for ``wait`` policy (default: 30)
|
||||
burst_limit: 2 # extra slots for ``burst`` policy (default: 0)
|
||||
ownership:
|
||||
type: redis # shares ownership and capacity across Gateways
|
||||
redis_url: redis://redis:6379/0
|
||||
mounts: # one-shot uploads on sandbox start
|
||||
- host_path: /data/skills
|
||||
container_path: /home/user/skills
|
||||
@ -44,6 +47,7 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from e2b import SandboxQuery
|
||||
from e2b_code_interpreter import Sandbox as E2BClientSandbox
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
@ -56,10 +60,16 @@ from ..aio_sandbox.ownership import (
|
||||
OwnershipBackendError,
|
||||
RenewOutcome,
|
||||
SandboxOwnershipStore,
|
||||
compute_lease_ttl,
|
||||
generate_owner_id,
|
||||
make_sandbox_ownership_store,
|
||||
resolve_ownership_config,
|
||||
)
|
||||
from .capacity import (
|
||||
CapacityBackendError,
|
||||
ReserveStatus,
|
||||
make_e2b_capacity_store,
|
||||
)
|
||||
from .e2b_sandbox import DEFAULT_E2B_HOME_DIR, E2BSandbox, _is_sandbox_gone_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -77,6 +87,9 @@ DEFAULT_RECONCILIATION_ORPHAN_TTL_SECONDS = 3600.0
|
||||
DEFAULT_RECONCILIATION_MAX_PAGES = 10
|
||||
DEFAULT_RECONCILIATION_MAX_ITEMS = 200
|
||||
DEFAULT_RECONCILIATION_MAX_SECONDS = 15.0
|
||||
# Twice the E2B SDK's 60-second create request timeout. Short ownership lease
|
||||
# settings must not make an in-flight create look abandoned.
|
||||
MIN_CAPACITY_RESERVATION_SECONDS = 120.0
|
||||
# Hard upper bound for ``set_timeout`` (e2b currently caps at 24h on the
|
||||
# free plan; passing an excessive value is rejected by the control-plane).
|
||||
MAX_E2B_TIMEOUT = 24 * 60 * 60
|
||||
@ -88,6 +101,8 @@ META_KEY_THREAD = "deer_flow_thread"
|
||||
META_KEY_PROVIDER = "deer_flow_provider"
|
||||
META_KEY_GATEWAY = "deer_flow_gateway"
|
||||
META_KEY_CREATED_AT = "deer_flow_created_at"
|
||||
META_KEY_CAPACITY_LEDGER = "deer_flow_capacity_ledger"
|
||||
META_KEY_CAPACITY_RESERVATION = "deer_flow_capacity_reservation"
|
||||
META_VAL_PROVIDER = "e2b_sandbox_provider"
|
||||
E2B_EXTRA_CONFIG_KEYS = frozenset({"api_key", "domain", "home_dir", "template"})
|
||||
|
||||
@ -168,6 +183,10 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
self._ownership_config,
|
||||
owner_id=self._owner_id,
|
||||
)
|
||||
self._deployment_capacity = make_e2b_capacity_store(
|
||||
self._ownership_config,
|
||||
hard_limit=self._capacity_limit(),
|
||||
)
|
||||
if not self._ownership.supports_cross_process:
|
||||
logger.warning("E2B sandbox ownership is process-local. Multi-worker gateways must configure sandbox.ownership.type: redis for safe reconciliation.")
|
||||
|
||||
@ -285,6 +304,24 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
def _stable_seed(thread_id: str, user_id: str) -> str:
|
||||
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
|
||||
|
||||
def _metadata_matches_capacity_ledger(
|
||||
self,
|
||||
metadata: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Include this ledger and legacy sandboxes that predate the tag."""
|
||||
store = self._deployment_capacity
|
||||
if store is None:
|
||||
return True
|
||||
remote_ledger = metadata.get(META_KEY_CAPACITY_LEDGER)
|
||||
return remote_ledger in (None, "", store.key)
|
||||
|
||||
@staticmethod
|
||||
def _capacity_reservation_from_metadata(
|
||||
metadata: dict[str, Any],
|
||||
) -> str | None:
|
||||
token = metadata.get(META_KEY_CAPACITY_RESERVATION)
|
||||
return token if isinstance(token, str) and token else None
|
||||
|
||||
# ── Signal / shutdown handling ───────────────────────────────────────
|
||||
|
||||
def _register_signal_handlers(self) -> None:
|
||||
@ -390,6 +427,7 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
sandbox.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._release_deployment_sandbox(sid)
|
||||
return None
|
||||
|
||||
try:
|
||||
@ -496,7 +534,7 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
"""
|
||||
sandbox_cls = self._get_sandbox_cls()
|
||||
seed = self._stable_seed(thread_id, user_id)
|
||||
entries, _ = self._list_remote_entries(
|
||||
entries, _, _ = self._list_remote_entries(
|
||||
{
|
||||
META_KEY_PROVIDER: META_VAL_PROVIDER,
|
||||
META_KEY_USER: user_id,
|
||||
@ -504,16 +542,21 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
}
|
||||
)
|
||||
candidates = sorted(
|
||||
((sandbox_id, metadata) for entry in entries if (sandbox_id := self._entry_id(entry)) and (metadata := self._entry_metadata(entry)).get(META_KEY_USER) == user_id and metadata.get(META_KEY_THREAD) == thread_id),
|
||||
(
|
||||
(sandbox_id, metadata)
|
||||
for entry in entries
|
||||
if (sandbox_id := self._entry_id(entry)) and (metadata := self._entry_metadata(entry)).get(META_KEY_USER) == user_id and metadata.get(META_KEY_THREAD) == thread_id and self._metadata_matches_capacity_ledger(metadata)
|
||||
),
|
||||
key=lambda item: (item[1].get(META_KEY_CREATED_AT, ""), item[0]),
|
||||
)
|
||||
for target_id, _metadata in candidates:
|
||||
for target_id, metadata in candidates:
|
||||
adopted = self._adopt_remote_candidate(
|
||||
sandbox_cls,
|
||||
target_id,
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
seed=seed,
|
||||
capacity_reservation=(self._capacity_reservation_from_metadata(metadata)),
|
||||
)
|
||||
if adopted is not None:
|
||||
return adopted
|
||||
@ -527,6 +570,7 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
thread_id: str,
|
||||
user_id: str,
|
||||
seed: str,
|
||||
capacity_reservation: str | None = None,
|
||||
) -> str | None:
|
||||
"""Try to adopt one discovered candidate without harming peer-owned VMs."""
|
||||
|
||||
@ -548,6 +592,10 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
return None
|
||||
|
||||
try:
|
||||
self._track_deployment_sandbox(
|
||||
target_id,
|
||||
reservation_token=capacity_reservation,
|
||||
)
|
||||
self._reserve_capacity(
|
||||
thread_id,
|
||||
user_id,
|
||||
@ -664,6 +712,66 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
with self._lock:
|
||||
self._end_transition_locked()
|
||||
|
||||
def _capacity_reservation_max_age_ms(self) -> int:
|
||||
configured = compute_lease_ttl(self._ownership_config) + float(self._config["reconciliation_grace_seconds"])
|
||||
return int(max(configured, MIN_CAPACITY_RESERVATION_SECONDS) * 1_000)
|
||||
|
||||
def _capacity_error(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
reason: str = "capacity",
|
||||
) -> SandboxCapacityExceededError:
|
||||
with self._lock:
|
||||
return SandboxCapacityExceededError(
|
||||
message,
|
||||
active=len(self._sandboxes),
|
||||
warm=len(self._warm_pool),
|
||||
reserved=self._reserved_slots,
|
||||
replicas=int(self._config["replicas"]),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def _track_deployment_sandbox(
|
||||
self,
|
||||
sandbox_id: str,
|
||||
*,
|
||||
reservation_token: str | None = None,
|
||||
required: bool = True,
|
||||
) -> None:
|
||||
store = self._deployment_capacity
|
||||
if store is None:
|
||||
return
|
||||
try:
|
||||
store.track(
|
||||
sandbox_id,
|
||||
reservation_token=reservation_token,
|
||||
)
|
||||
except CapacityBackendError as error:
|
||||
if required:
|
||||
raise self._capacity_error(
|
||||
f"Deployment-wide E2B capacity is unavailable; cannot safely track sandbox {sandbox_id}",
|
||||
reason="capacity_backend",
|
||||
) from error
|
||||
logger.warning(
|
||||
"Could not track E2B sandbox %s in deployment capacity; reconciliation will retry: %s",
|
||||
sandbox_id,
|
||||
error,
|
||||
)
|
||||
|
||||
def _release_deployment_sandbox(self, sandbox_id: str) -> None:
|
||||
store = self._deployment_capacity
|
||||
if store is None:
|
||||
return
|
||||
try:
|
||||
store.release(sandbox_id)
|
||||
except CapacityBackendError as error:
|
||||
logger.warning(
|
||||
"Could not release deployment capacity for destroyed E2B sandbox %s; reconciliation will retry: %s",
|
||||
sandbox_id,
|
||||
error,
|
||||
)
|
||||
|
||||
def _reserve_capacity(
|
||||
self,
|
||||
thread_id: str | None,
|
||||
@ -671,23 +779,71 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
*,
|
||||
remote_id: str | None = None,
|
||||
remote_owned: bool = True,
|
||||
) -> None:
|
||||
"""Acquire a capacity slot, blocking or raising as configured.
|
||||
|
||||
Must be called before ``Sandbox.create()``. The caller MUST call
|
||||
``_commit_capacity()`` on success or ``_release_capacity()`` on
|
||||
failure — otherwise the reserved slot is leaked until shutdown.
|
||||
|
||||
Raises:
|
||||
SandboxCapacityExceededError: when the overflow policy is
|
||||
``reject`` or the wait timeout expires.
|
||||
"""
|
||||
) -> str | None:
|
||||
"""Reserve local capacity, then deployment capacity for a new VM."""
|
||||
store = self._deployment_capacity
|
||||
policy = self._config["overflow_policy"]
|
||||
timeout = float(self._config["acquire_timeout"])
|
||||
deadline = time.monotonic() + timeout
|
||||
token = uuid.uuid4().hex if store is not None and remote_id is None else None
|
||||
|
||||
while True:
|
||||
self._reserve_local_capacity(
|
||||
thread_id,
|
||||
user_id,
|
||||
remote_id=remote_id,
|
||||
remote_owned=remote_owned,
|
||||
deadline=deadline,
|
||||
)
|
||||
if store is None or remote_id is not None:
|
||||
return token
|
||||
|
||||
assert token is not None
|
||||
backend_error = None
|
||||
try:
|
||||
status = store.reserve(token)
|
||||
except CapacityBackendError as error:
|
||||
backend_error = error
|
||||
status = None
|
||||
if status is ReserveStatus.GRANTED:
|
||||
return token
|
||||
self._release_capacity()
|
||||
if status is ReserveStatus.FULL and self._evict_oldest_warm() is not None:
|
||||
continue
|
||||
|
||||
if policy != "wait":
|
||||
if backend_error is not None:
|
||||
raise self._capacity_error(
|
||||
"Deployment-wide E2B capacity is unavailable",
|
||||
reason="capacity_backend",
|
||||
) from backend_error
|
||||
if status is ReserveStatus.NOT_READY:
|
||||
raise self._capacity_error(
|
||||
"Deployment-wide E2B capacity is initializing",
|
||||
reason="capacity_initializing",
|
||||
)
|
||||
raise self._capacity_error("Deployment-wide E2B capacity is full")
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise self._capacity_error(f"Timed out after {timeout}s waiting for deployment-wide E2B capacity")
|
||||
with self._capacity_cond:
|
||||
self._capacity_cond.wait(timeout=min(remaining, 1.0))
|
||||
|
||||
def _reserve_local_capacity(
|
||||
self,
|
||||
thread_id: str | None,
|
||||
user_id: str,
|
||||
*,
|
||||
remote_id: str | None = None,
|
||||
remote_owned: bool = True,
|
||||
deadline: float | None = None,
|
||||
) -> None:
|
||||
"""Acquire the existing process-local lifecycle slot."""
|
||||
policy = self._config["overflow_policy"]
|
||||
timeout = float(self._config["acquire_timeout"])
|
||||
deadline = deadline or time.monotonic() + timeout
|
||||
|
||||
while True:
|
||||
# Reject immediately if the provider is shutting down.
|
||||
with self._lock:
|
||||
if self._shutdown_called:
|
||||
raise SandboxCapacityExceededError(
|
||||
@ -697,7 +853,6 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
reason="shutdown",
|
||||
)
|
||||
|
||||
# 1. Try immediate atomic reservation.
|
||||
with self._lock:
|
||||
if self._shutdown_called:
|
||||
raise SandboxCapacityExceededError(
|
||||
@ -714,7 +869,6 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
remote_ops.add(remote_id)
|
||||
return
|
||||
|
||||
# 2. Try evicting a warm entry to free a slot.
|
||||
evicted = self._evict_oldest_warm()
|
||||
if evicted is not None:
|
||||
with self._lock:
|
||||
@ -731,9 +885,7 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
remote_ops = self._remote_ops_in_progress if remote_owned else self._unowned_remote_ops_in_progress
|
||||
remote_ops.add(remote_id)
|
||||
return
|
||||
# Slot was stolen; fall through to policy / wait.
|
||||
|
||||
# 3. Apply overflow policy.
|
||||
with self._lock:
|
||||
if self._shutdown_called:
|
||||
raise SandboxCapacityExceededError(
|
||||
@ -764,7 +916,6 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
replicas=int(self._config["replicas"]),
|
||||
)
|
||||
|
||||
# policy == "wait": block until a slot frees or timeout.
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise SandboxCapacityExceededError(
|
||||
@ -839,7 +990,7 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
|
||||
Capacity is enforced atomically via :meth:`_reserve_capacity`.
|
||||
"""
|
||||
self._reserve_capacity(thread_id, user_id)
|
||||
reservation_token = self._reserve_capacity(thread_id, user_id)
|
||||
|
||||
sandbox_cls = self._get_sandbox_cls()
|
||||
metadata: dict[str, str] = {
|
||||
@ -847,6 +998,10 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
META_KEY_GATEWAY: self._owner_id,
|
||||
META_KEY_CREATED_AT: str(time.time()),
|
||||
}
|
||||
if self._deployment_capacity is not None:
|
||||
metadata[META_KEY_CAPACITY_LEDGER] = self._deployment_capacity.key
|
||||
if reservation_token is not None:
|
||||
metadata[META_KEY_CAPACITY_RESERVATION] = reservation_token
|
||||
if thread_id:
|
||||
metadata[META_KEY_USER] = user_id
|
||||
metadata[META_KEY_THREAD] = thread_id
|
||||
@ -869,6 +1024,11 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
raise
|
||||
|
||||
sandbox_id: str = getattr(client, "sandbox_id", None) or str(uuid.uuid4())[:8]
|
||||
self._track_deployment_sandbox(
|
||||
sandbox_id,
|
||||
reservation_token=reservation_token,
|
||||
required=False,
|
||||
)
|
||||
if not self._track_reserved_remote_op(sandbox_id):
|
||||
kill_error = self._kill_client(client)
|
||||
cleanup_confirmed = kill_error is None
|
||||
@ -998,26 +1158,25 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
value = entry.get("metadata")
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def _list_remote_entries(self, metadata: dict[str, str]) -> tuple[list[Any], bool]:
|
||||
"""List matching E2B entries within configured page/item/time budgets."""
|
||||
def _list_remote_entries(
|
||||
self,
|
||||
metadata: dict[str, str],
|
||||
) -> tuple[list[Any], bool, bool]:
|
||||
"""List E2B entries and report budget exhaustion and completeness."""
|
||||
sandbox_cls = self._get_sandbox_cls()
|
||||
try:
|
||||
result = sandbox_cls.list(query={"metadata": metadata}, **self._common_kwargs()) # type: ignore[attr-defined]
|
||||
except TypeError:
|
||||
try:
|
||||
result = sandbox_cls.list(metadata=metadata, **self._common_kwargs()) # type: ignore[attr-defined]
|
||||
except Exception as e:
|
||||
logger.warning("E2B reconciliation list failed: %s", e)
|
||||
return [], False
|
||||
query = SandboxQuery(metadata=metadata)
|
||||
result = sandbox_cls.list(query=query, **self._common_kwargs()) # type: ignore[attr-defined]
|
||||
except Exception as e:
|
||||
logger.warning("E2B reconciliation list failed: %s", e)
|
||||
return [], False
|
||||
return [], False, False
|
||||
|
||||
max_pages = int(self._config["reconciliation_max_pages"])
|
||||
max_items = int(self._config["reconciliation_max_items"])
|
||||
deadline = time.monotonic() + float(self._config["reconciliation_max_seconds"])
|
||||
entries: list[Any] = []
|
||||
exhausted = False
|
||||
complete = True
|
||||
|
||||
if hasattr(result, "next_items") and hasattr(result, "has_next"):
|
||||
for page_number in range(max_pages):
|
||||
@ -1028,6 +1187,7 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
page = result.next_items()
|
||||
except Exception as e:
|
||||
logger.warning("E2B reconciliation paginator failed: %s", e)
|
||||
complete = False
|
||||
break
|
||||
if not page:
|
||||
break
|
||||
@ -1045,13 +1205,15 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
all_entries = list(result or [])
|
||||
except TypeError:
|
||||
logger.warning("E2B Sandbox.list returned non-iterable %s", type(result).__name__)
|
||||
return [], False
|
||||
return [], False, False
|
||||
entries = all_entries[:max_items]
|
||||
exhausted = len(all_entries) > max_items
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
exhausted = True
|
||||
return entries, exhausted
|
||||
if exhausted:
|
||||
complete = False
|
||||
return entries, exhausted, complete
|
||||
|
||||
def _publish_ownership(self, sandbox_id: str) -> None:
|
||||
"""Publish acquire-side ownership before exposing a sandbox locally."""
|
||||
@ -1147,8 +1309,21 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
self._lease_thread.start()
|
||||
self._reconcile_thread.start()
|
||||
|
||||
def _reserve_reconciliation_capacity(self, sandbox_id: str) -> bool:
|
||||
def _reserve_reconciliation_capacity(
|
||||
self,
|
||||
sandbox_id: str,
|
||||
*,
|
||||
reservation_token: str | None = None,
|
||||
) -> bool:
|
||||
"""Reserve one local slot for adoption without blocking maintenance."""
|
||||
try:
|
||||
self._track_deployment_sandbox(
|
||||
sandbox_id,
|
||||
reservation_token=reservation_token,
|
||||
)
|
||||
except SandboxCapacityExceededError as error:
|
||||
logger.warning("Could not track discovered E2B sandbox %s: %s", sandbox_id, error)
|
||||
return False
|
||||
with self._lock:
|
||||
if self._shutdown_called or self._total_capacity_used_locked() >= self._capacity_limit():
|
||||
return False
|
||||
@ -1162,8 +1337,38 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
observed_at = time.monotonic() if now is None else now
|
||||
deadline = time.monotonic() + float(self._config["reconciliation_max_seconds"])
|
||||
stats = ReconciliationStats()
|
||||
entries, stats.budget_exhausted = self._list_remote_entries({META_KEY_PROVIDER: META_VAL_PROVIDER})
|
||||
capacity_revision = None
|
||||
capacity_store = self._deployment_capacity
|
||||
if capacity_store is not None:
|
||||
try:
|
||||
capacity_revision = capacity_store.revision()
|
||||
except CapacityBackendError as error:
|
||||
logger.warning(
|
||||
"Could not read E2B capacity before reconciliation: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
entries, stats.budget_exhausted, inventory_complete = self._list_remote_entries({META_KEY_PROVIDER: META_VAL_PROVIDER})
|
||||
entries = [entry for entry in entries if self._metadata_matches_capacity_ledger(self._entry_metadata(entry))]
|
||||
stats.discovered = len(entries)
|
||||
|
||||
if capacity_store is not None and capacity_revision is not None:
|
||||
records = {sandbox_id: self._capacity_reservation_from_metadata(self._entry_metadata(entry)) for entry in entries if (sandbox_id := self._entry_id(entry))}
|
||||
try:
|
||||
applied = capacity_store.reconcile(
|
||||
expected_revision=capacity_revision,
|
||||
remote_sandboxes=records,
|
||||
complete=inventory_complete,
|
||||
reservation_max_age_ms=self._capacity_reservation_max_age_ms(),
|
||||
)
|
||||
if not applied:
|
||||
logger.debug("E2B capacity inventory became stale during reconciliation; retrying on the next pass")
|
||||
except CapacityBackendError as error:
|
||||
logger.warning(
|
||||
"Could not apply E2B capacity inventory: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
groups: dict[tuple[str, str], list[tuple[str, dict[str, Any]]]] = {}
|
||||
orphans: list[tuple[str, dict[str, Any]]] = []
|
||||
present_ids: set[str] = set()
|
||||
@ -1206,12 +1411,15 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
if not live:
|
||||
continue
|
||||
stats.duplicates += max(0, len(live) - 1)
|
||||
canonical_id, _metadata, canonical_client = live[0]
|
||||
canonical_id, canonical_metadata, canonical_client = live[0]
|
||||
with self._lock:
|
||||
already_local = canonical_id in self._sandboxes
|
||||
if already_local:
|
||||
self._safe_close_client(canonical_client)
|
||||
elif not self._reserve_reconciliation_capacity(canonical_id):
|
||||
elif not self._reserve_reconciliation_capacity(
|
||||
canonical_id,
|
||||
reservation_token=self._capacity_reservation_from_metadata(canonical_metadata),
|
||||
):
|
||||
self._safe_close_client(canonical_client)
|
||||
stats.deferred += 1
|
||||
elif not self._claim_ownership(canonical_id):
|
||||
@ -1332,10 +1540,16 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
reaped the VM. Closing that host-side client before returning ``None``
|
||||
keeps both acquire paths from leaking a connection.
|
||||
"""
|
||||
client = self._reconnect_client(sandbox_cls, sandbox_id)
|
||||
try:
|
||||
client = self._reconnect_client(sandbox_cls, sandbox_id)
|
||||
except Exception as error:
|
||||
if _is_sandbox_gone_error(error):
|
||||
self._release_deployment_sandbox(sandbox_id)
|
||||
raise
|
||||
if self._client_alive(client):
|
||||
return client
|
||||
self._safe_close_client(client)
|
||||
self._release_deployment_sandbox(sandbox_id)
|
||||
return None
|
||||
|
||||
def _register_connected_sandbox(
|
||||
@ -2073,13 +2287,14 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _kill_client(
|
||||
self,
|
||||
client: E2BClientSandbox | None,
|
||||
) -> Exception | None:
|
||||
"""Kill a remote VM and return an exception for the caller to log."""
|
||||
if client is None:
|
||||
return RuntimeError("Cannot confirm remote VM destruction without a client")
|
||||
sandbox_id = getattr(client, "sandbox_id", None)
|
||||
try:
|
||||
kill = getattr(client, "kill", None)
|
||||
if not callable(kill):
|
||||
@ -2087,6 +2302,8 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
kill()
|
||||
except Exception as e:
|
||||
return e
|
||||
if sandbox_id is not None:
|
||||
self._release_deployment_sandbox(sandbox_id)
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
@ -2178,3 +2395,11 @@ class E2BSandboxProvider(SandboxProvider):
|
||||
self._ownership.close()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to close E2B ownership store: %s", e)
|
||||
if self._deployment_capacity is not None:
|
||||
try:
|
||||
self._deployment_capacity.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to close E2B deployment capacity store: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
@ -76,8 +76,9 @@ class SandboxConfig(BaseModel):
|
||||
|
||||
AioSandboxProvider, BoxliteProvider, and E2BSandboxProvider shared options:
|
||||
image: Sandbox image to use (Docker/AIO image or BoxLite OCI image)
|
||||
replicas: Positive provider capacity per gateway process. Each provider
|
||||
defines which lifecycle states count toward this limit.
|
||||
replicas: Positive provider capacity. E2B shares it across Gateway
|
||||
workers when ownership uses Redis; other modes/providers keep
|
||||
process-local accounting.
|
||||
idle_timeout: Idle timeout in seconds before released warm sandboxes/VMs are stopped (default: 600 = 10 minutes). Set to 0 to disable.
|
||||
environment: Environment variables to inject into the sandbox (values starting with $ are resolved from host env)
|
||||
|
||||
@ -115,7 +116,7 @@ class SandboxConfig(BaseModel):
|
||||
replicas: int | None = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="Positive provider capacity per gateway process. Each provider defines which lifecycle states count toward this limit.",
|
||||
description=("Positive provider capacity. E2B enforces it deployment-wide when sandbox ownership uses Redis; otherwise accounting is per Gateway process. Each provider defines which lifecycle states count."),
|
||||
)
|
||||
overflow_policy: SandboxOverflowPolicy = Field(
|
||||
default="wait",
|
||||
|
||||
178
backend/tests/test_e2b_capacity_store_redis.py
Normal file
178
backend/tests/test_e2b_capacity_store_redis.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""Integration tests for deployment-wide E2B admission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.community.e2b_sandbox.capacity import (
|
||||
CapacityBackendError,
|
||||
RedisE2BCapacityStore,
|
||||
ReserveStatus,
|
||||
make_e2b_capacity_store,
|
||||
)
|
||||
from deerflow.config.sandbox_config import SandboxOwnershipConfig
|
||||
|
||||
REDIS_URL = os.environ.get("DEER_FLOW_TEST_REDIS_URL", "redis://localhost:6379/15")
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_store():
|
||||
redis = pytest.importorskip("redis")
|
||||
probe = redis.Redis.from_url(REDIS_URL, decode_responses=True, socket_connect_timeout=0.5)
|
||||
try:
|
||||
probe.ping()
|
||||
except Exception:
|
||||
probe.close()
|
||||
pytest.skip(f"Redis not reachable at {REDIS_URL}")
|
||||
|
||||
prefix = f"deerflow:test:{uuid.uuid4().hex}"
|
||||
stores = []
|
||||
|
||||
def make(hard_limit: int = 1):
|
||||
store = RedisE2BCapacityStore(
|
||||
redis_url=REDIS_URL,
|
||||
hard_limit=hard_limit,
|
||||
key_prefix=prefix,
|
||||
)
|
||||
stores.append(store)
|
||||
return store
|
||||
|
||||
try:
|
||||
yield make
|
||||
finally:
|
||||
probe.delete(f"{prefix}:e2b-capacity")
|
||||
probe.close()
|
||||
for store in stores:
|
||||
store.close()
|
||||
|
||||
|
||||
def _initialize(store) -> None:
|
||||
assert store.reconcile(
|
||||
expected_revision=store.revision(),
|
||||
remote_sandboxes={},
|
||||
complete=True,
|
||||
reservation_max_age_ms=0,
|
||||
)
|
||||
|
||||
|
||||
def _counts(store) -> tuple[int, int]:
|
||||
fields = store._redis.hkeys(store.key)
|
||||
return (
|
||||
sum(field.startswith("s:") for field in fields),
|
||||
sum(field.startswith("r:") for field in fields),
|
||||
)
|
||||
|
||||
|
||||
def test_factory_is_lazy_and_backend_errors_fail_closed() -> None:
|
||||
assert make_e2b_capacity_store(SandboxOwnershipConfig(type="memory"), hard_limit=3) is None
|
||||
store = make_e2b_capacity_store(
|
||||
SandboxOwnershipConfig(type="redis", redis_url="redis://127.0.0.1:1/0", key_prefix="test"),
|
||||
hard_limit=3,
|
||||
)
|
||||
assert store is not None and store.key == "test:e2b-capacity"
|
||||
try:
|
||||
with pytest.raises(CapacityBackendError):
|
||||
store.reserve("reservation")
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def test_two_gateways_atomically_share_one_hash(make_store) -> None:
|
||||
gateway_a, gateway_b = make_store(), make_store()
|
||||
assert gateway_a.reserve("not-ready") is ReserveStatus.NOT_READY
|
||||
_initialize(gateway_a)
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def reserve(args):
|
||||
store, token = args
|
||||
barrier.wait()
|
||||
return store.reserve(token)
|
||||
|
||||
tokens = ["reservation-a", "reservation-b"]
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(reserve, zip((gateway_a, gateway_b), tokens)))
|
||||
|
||||
assert results.count(ReserveStatus.GRANTED) == 1
|
||||
assert results.count(ReserveStatus.FULL) == 1
|
||||
assert list(gateway_a._redis.scan_iter(f"{gateway_a.key}")) == [gateway_a.key]
|
||||
winner = tokens[results.index(ReserveStatus.GRANTED)]
|
||||
gateway_a.track("sandbox-a", reservation_token=winner)
|
||||
gateway_a.track("sandbox-a", reservation_token=winner)
|
||||
assert _counts(gateway_a) == (1, 0)
|
||||
# A successful but stale list must not release a just-tracked slot.
|
||||
assert gateway_b.reconcile(
|
||||
expected_revision=gateway_b.revision(),
|
||||
remote_sandboxes={},
|
||||
complete=True,
|
||||
reservation_max_age_ms=120_000,
|
||||
)
|
||||
assert gateway_b.reserve("stale-inventory") is ReserveStatus.FULL
|
||||
gateway_b.release("sandbox-a")
|
||||
gateway_b.release("sandbox-a")
|
||||
assert _counts(gateway_a) == (0, 0)
|
||||
|
||||
|
||||
def test_reconcile_repairs_crashes_without_erasing_concurrent_changes(make_store) -> None:
|
||||
gateway_a, gateway_b = make_store(2), make_store(2)
|
||||
_initialize(gateway_a)
|
||||
assert gateway_a.reserve("crashed-create") is ReserveStatus.GRANTED
|
||||
assert gateway_b.reconcile(
|
||||
expected_revision=gateway_b.revision(),
|
||||
remote_sandboxes={"sandbox-a": "crashed-create"},
|
||||
complete=True,
|
||||
reservation_max_age_ms=0,
|
||||
)
|
||||
stale_revision = gateway_a.revision()
|
||||
assert gateway_b.reserve("concurrent") is ReserveStatus.GRANTED
|
||||
assert not gateway_a.reconcile(
|
||||
expected_revision=stale_revision,
|
||||
remote_sandboxes={},
|
||||
complete=True,
|
||||
reservation_max_age_ms=0,
|
||||
)
|
||||
assert _counts(gateway_a) == (1, 1)
|
||||
|
||||
|
||||
def test_reconcile_keeps_incomplete_inventory_and_fresh_reservations(make_store) -> None:
|
||||
store = make_store(2)
|
||||
_initialize(store)
|
||||
store.track("sandbox-a")
|
||||
assert store.reserve("creating") is ReserveStatus.GRANTED
|
||||
|
||||
assert store.reconcile(
|
||||
expected_revision=store.revision(),
|
||||
remote_sandboxes={},
|
||||
complete=False,
|
||||
reservation_max_age_ms=0,
|
||||
)
|
||||
assert _counts(store) == (1, 1)
|
||||
assert store.reconcile(
|
||||
expected_revision=store.revision(),
|
||||
remote_sandboxes={"sandbox-a": None},
|
||||
complete=True,
|
||||
reservation_max_age_ms=120_000,
|
||||
)
|
||||
assert _counts(store) == (1, 1)
|
||||
store._redis.hset(store.key, "s:sandbox-a", "m:0")
|
||||
assert store.reconcile(
|
||||
expected_revision=store.revision(),
|
||||
remote_sandboxes={},
|
||||
complete=True,
|
||||
reservation_max_age_ms=0,
|
||||
)
|
||||
assert _counts(store) == (0, 0)
|
||||
|
||||
|
||||
def test_mismatched_hard_limits_fail_closed(make_store) -> None:
|
||||
gateway_a, gateway_b = make_store(), make_store(2)
|
||||
_initialize(gateway_a)
|
||||
with pytest.raises(CapacityBackendError, match="configuration mismatch"):
|
||||
gateway_b.revision()
|
||||
with pytest.raises(CapacityBackendError, match="configuration mismatch"):
|
||||
gateway_b.reserve("reservation")
|
||||
@ -17,6 +17,10 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from deerflow.community.e2b_sandbox.capacity import (
|
||||
CapacityBackendError,
|
||||
ReserveStatus,
|
||||
)
|
||||
from deerflow.config.paths import Paths
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.sandbox.exceptions import SandboxCapacityExceededError
|
||||
@ -253,7 +257,12 @@ def _make_provider(*, replicas: int = 3, idle_timeout: int = 1800, overflow_poli
|
||||
provider._shutdown_called = False
|
||||
provider._owner_id = "owner-a"
|
||||
provider._ownership = FakeOwnershipStore({}, owner_id=provider._owner_id)
|
||||
provider._ownership_config = SimpleNamespace(renewal_interval_seconds=60.0)
|
||||
provider._ownership_config = SimpleNamespace(
|
||||
renewal_interval_seconds=60.0,
|
||||
ttl_multiplier=4.0,
|
||||
key_prefix="deerflow:test",
|
||||
)
|
||||
provider._deployment_capacity = None
|
||||
provider._owned_sandbox_ids = set()
|
||||
provider._acquire_inflight = set()
|
||||
provider._orphan_first_seen = {}
|
||||
@ -282,6 +291,22 @@ def _make_provider(*, replicas: int = 3, idle_timeout: int = 1800, overflow_poli
|
||||
return provider
|
||||
|
||||
|
||||
def _install_shared_deployment_capacity(
|
||||
*providers,
|
||||
reserve_results: list[ReserveStatus] | None = None,
|
||||
) -> MagicMock:
|
||||
store = MagicMock()
|
||||
store.key = "deerflow:test:e2b-capacity"
|
||||
store.revision.return_value = 0
|
||||
store.reserve.return_value = ReserveStatus.GRANTED
|
||||
store.reconcile.return_value = True
|
||||
if reserve_results is not None:
|
||||
store.reserve.side_effect = reserve_results
|
||||
for provider in providers:
|
||||
provider._deployment_capacity = store
|
||||
return store
|
||||
|
||||
|
||||
def _install_fake_sdk(monkeypatch, provider) -> FakeSandboxClass:
|
||||
fake_cls = FakeSandboxClass()
|
||||
monkeypatch.setattr(provider, "_get_sandbox_cls", lambda: fake_cls)
|
||||
@ -1081,11 +1106,17 @@ def test_bootstrap_failure_does_not_kill_without_destroy_lease(monkeypatch):
|
||||
|
||||
def test_kill_client_returns_exception_without_raising():
|
||||
p = _make_provider()
|
||||
client = FakeClient()
|
||||
store = _install_shared_deployment_capacity(p)
|
||||
failed_client = FakeClient()
|
||||
error = RuntimeError("already gone")
|
||||
client.kill = MagicMock(side_effect=error)
|
||||
failed_client.kill = MagicMock(side_effect=error)
|
||||
|
||||
assert p._kill_client(client) is error
|
||||
assert p._kill_client(failed_client) is error
|
||||
store.release.assert_not_called()
|
||||
|
||||
client = FakeClient()
|
||||
assert p._kill_client(client) is None
|
||||
store.release.assert_called_once_with(client.sandbox_id)
|
||||
|
||||
|
||||
def test_kill_client_reports_uncertain_cleanup_without_callable_kill():
|
||||
@ -2107,9 +2138,132 @@ def test_grep_single_file_path_with_matching_glob():
|
||||
assert truncated is False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Capacity enforcement tests (#4339)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_deployment_capacity_reserves_commits_and_rejects_globally(monkeypatch) -> None:
|
||||
gateway_a = _make_provider(replicas=1, overflow_policy="reject")
|
||||
gateway_b = _make_provider(replicas=1, overflow_policy="reject")
|
||||
store = _install_shared_deployment_capacity(
|
||||
gateway_a,
|
||||
gateway_b,
|
||||
reserve_results=[ReserveStatus.GRANTED, ReserveStatus.FULL],
|
||||
)
|
||||
sdk_a = _install_fake_sdk(monkeypatch, gateway_a)
|
||||
sdk_b = FakeSandboxClass()
|
||||
monkeypatch.setattr(gateway_b, "_get_sandbox_cls", lambda: sdk_b)
|
||||
|
||||
sandbox_id = gateway_a.acquire("thread-a", user_id="user-a")
|
||||
with pytest.raises(SandboxCapacityExceededError):
|
||||
gateway_b.acquire("thread-b", user_id="user-b")
|
||||
|
||||
metadata = sdk_a.create_calls[0]["metadata"]
|
||||
assert metadata["deer_flow_capacity_ledger"] == store.key
|
||||
assert metadata["deer_flow_capacity_reservation"]
|
||||
store.track.assert_called_once_with(
|
||||
sandbox_id,
|
||||
reservation_token=metadata["deer_flow_capacity_reservation"],
|
||||
)
|
||||
assert len(sdk_a.create_calls) == 1
|
||||
assert sdk_b.create_calls == []
|
||||
assert store.reserve.call_count == 2
|
||||
|
||||
|
||||
def test_ambiguous_create_failure_retains_deployment_reservation(monkeypatch) -> None:
|
||||
provider = _make_provider(replicas=1, overflow_policy="reject")
|
||||
store = _install_shared_deployment_capacity(provider)
|
||||
sdk = _install_fake_sdk(monkeypatch, provider)
|
||||
sdk.create_factory = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("control-plane timeout"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="control-plane timeout"):
|
||||
provider.acquire("thread-a", user_id="user-a")
|
||||
|
||||
assert provider._reserved_slots == 0
|
||||
store.reserve.assert_called_once()
|
||||
store.track.assert_not_called()
|
||||
store.release.assert_not_called()
|
||||
|
||||
|
||||
def test_discovery_uses_sdk_query_and_tracks_without_reserving(monkeypatch) -> None:
|
||||
provider = _make_provider(replicas=1, overflow_policy="reject")
|
||||
store = _install_shared_deployment_capacity(provider)
|
||||
sdk = _install_fake_sdk(monkeypatch, provider)
|
||||
entry = SimpleNamespace(
|
||||
sandbox_id="sandbox-existing",
|
||||
metadata={
|
||||
"deer_flow_provider": "e2b_sandbox_provider",
|
||||
"deer_flow_user": "user-a",
|
||||
"deer_flow_thread": "thread-a",
|
||||
"deer_flow_capacity_ledger": store.key,
|
||||
},
|
||||
)
|
||||
expected_query = {key: entry.metadata[key] for key in ("deer_flow_provider", "deer_flow_user", "deer_flow_thread")}
|
||||
sdk.list_return = SimpleNamespace(
|
||||
has_next=False,
|
||||
next_items=lambda: [entry] if sdk.list_calls[-1]["query"].metadata == expected_query else [],
|
||||
)
|
||||
assert provider.acquire("thread-a", user_id="user-a") == entry.sandbox_id
|
||||
assert sdk.create_calls == []
|
||||
store.reserve.assert_not_called()
|
||||
store.track.assert_called_once_with(entry.sandbox_id, reservation_token=None)
|
||||
|
||||
|
||||
def test_reconciliation_repairs_crash_and_uses_safe_reservation_age(monkeypatch) -> None:
|
||||
provider = _make_provider(replicas=1, overflow_policy="reject")
|
||||
provider._ownership_config.renewal_interval_seconds = 1.0
|
||||
provider._ownership_config.ttl_multiplier = 2.0
|
||||
provider._config["reconciliation_grace_seconds"] = 0.0
|
||||
store = _install_shared_deployment_capacity(provider)
|
||||
sdk = _install_fake_sdk(monkeypatch, provider)
|
||||
sdk.list_return = [
|
||||
{
|
||||
"sandbox_id": "sandbox-existing",
|
||||
"metadata": {
|
||||
"deer_flow_provider": "e2b_sandbox_provider",
|
||||
"deer_flow_capacity_ledger": store.key,
|
||||
"deer_flow_capacity_reservation": "reservation-crashed",
|
||||
},
|
||||
},
|
||||
{
|
||||
"sandbox_id": "sandbox-other-deployment",
|
||||
"metadata": {
|
||||
"deer_flow_provider": "e2b_sandbox_provider",
|
||||
"deer_flow_capacity_ledger": "deerflow:other:e2b-capacity",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
stats = provider._reconcile_remote_sandboxes(now=100.0)
|
||||
args = store.reconcile.call_args.kwargs
|
||||
assert stats.discovered == 1
|
||||
assert args["remote_sandboxes"] == {"sandbox-existing": "reservation-crashed"}
|
||||
assert args["complete"] is True
|
||||
assert args["reservation_max_age_ms"] == 120_000
|
||||
|
||||
|
||||
def test_failed_inventory_and_redis_error_both_prevent_create(monkeypatch) -> None:
|
||||
provider = _make_provider(replicas=1, overflow_policy="reject")
|
||||
store = _install_shared_deployment_capacity(
|
||||
provider,
|
||||
reserve_results=[ReserveStatus.NOT_READY],
|
||||
)
|
||||
sdk = _install_fake_sdk(monkeypatch, provider)
|
||||
sdk.list = MagicMock(side_effect=RuntimeError("E2B unavailable"))
|
||||
|
||||
provider._reconcile_remote_sandboxes(now=100.0)
|
||||
|
||||
reconcile_args = store.reconcile.call_args.kwargs
|
||||
assert reconcile_args["complete"] is False
|
||||
assert reconcile_args["remote_sandboxes"] == {}
|
||||
with pytest.raises(SandboxCapacityExceededError):
|
||||
provider._create_sandbox("thread-a", user_id="user-a")
|
||||
store.reserve.side_effect = CapacityBackendError("Redis unavailable")
|
||||
with pytest.raises(SandboxCapacityExceededError) as error:
|
||||
provider._create_sandbox("thread-a", user_id="user-a")
|
||||
|
||||
assert error.value.reason == "capacity_backend"
|
||||
assert sdk.create_calls == []
|
||||
assert provider._reserved_slots == 0
|
||||
|
||||
|
||||
def test_capacity_reject_policy_raises_when_full(monkeypatch):
|
||||
@ -2146,7 +2300,11 @@ def test_capacity_reject_frees_slot_on_release(monkeypatch):
|
||||
|
||||
def test_capacity_reject_evicts_other_thread_warm_entry_before_create(monkeypatch):
|
||||
"""Reject policy can evict one warm VM before it rejects new capacity."""
|
||||
p = _make_provider(replicas=1, overflow_policy="reject")
|
||||
p = _make_provider(replicas=3, overflow_policy="reject")
|
||||
store = _install_shared_deployment_capacity(
|
||||
p,
|
||||
reserve_results=[ReserveStatus.GRANTED, ReserveStatus.FULL, ReserveStatus.GRANTED],
|
||||
)
|
||||
fake_cls = _install_fake_sdk(monkeypatch, p)
|
||||
|
||||
sid1 = p.acquire("t1", user_id="u1")
|
||||
@ -2158,6 +2316,7 @@ def test_capacity_reject_evicts_other_thread_warm_entry_before_create(monkeypatc
|
||||
assert sid2 != sid1
|
||||
assert len(p._warm_pool) == 0
|
||||
assert len(fake_cls.create_calls) == 2
|
||||
store.release.assert_called_once_with(sid1)
|
||||
|
||||
|
||||
def test_capacity_wait_policy_times_out(monkeypatch):
|
||||
@ -3330,8 +3489,14 @@ def test_shutdown_during_discovery_does_not_kill_unowned_vm(monkeypatch):
|
||||
allow_commit = threading.Event()
|
||||
reserve_capacity = p._reserve_capacity
|
||||
|
||||
def pause_after_reserve(thread_id, user_id, *, remote_id=None, remote_owned=True):
|
||||
reserve_capacity(
|
||||
def pause_after_reserve(
|
||||
thread_id,
|
||||
user_id,
|
||||
*,
|
||||
remote_id=None,
|
||||
remote_owned=True,
|
||||
):
|
||||
reservation = reserve_capacity(
|
||||
thread_id,
|
||||
user_id,
|
||||
remote_id=remote_id,
|
||||
@ -3339,6 +3504,7 @@ def test_shutdown_during_discovery_does_not_kill_unowned_vm(monkeypatch):
|
||||
)
|
||||
reserved.set()
|
||||
assert allow_commit.wait(timeout=2)
|
||||
return reservation
|
||||
|
||||
monkeypatch.setattr(p, "_reserve_capacity", pause_after_reserve)
|
||||
result: list[str | None] = []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user