mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 06:28:58 +00:00
* feat(sandbox): share sandbox identity derivation and acquire serialization (#4741) Remote providers (AIO, E2B, BoxLite, Tenki, OpenSandbox) each inlined the same sha256(user:thread)[:16] sandbox-id expression and kept per-scope lock dicts that grew unboundedly until shutdown. This extracts both mechanisms into shared components without changing provider lifecycle, ids, capacity semantics, or public tool behavior: - sandbox/identity.py: keyword-only derive_sandbox_scope_token (byte-pinned compatibility contract) + is_sandbox_scope_token; per-provider golden vectors pin current behavior including BoxLite's raw-None quirk and each provider's private user_id resolution. - sandbox/acquire_serialization.py: AcquireSerializer — per-key lock table with holder/waiter refcount reclamation, bounded dedicated executor (async waits off both the event loop and the default executor), worker-owned cancellation cleanup (no event-loop callback dependency), idempotent close(). - Each provider adopts both components; AIO/E2B key by (user_id, thread_id) with acquire and (E2B) release serialized; BoxLite/Tenki/OpenSandbox key by derived sandbox id and offload the whole sync acquire to the serializer's executor so a cancelled awaiter cannot overlap a retried same-scope body (leaked-remote-VM regression caught in review). - thread_id=None acquires stay unserialized; provider shutdown()/reset() close the serializer; E2B capacity/ledger/reconciliation and AIO ownership/flock machinery untouched. - blocking-IO anchor proves contended OpenSandbox acquire_async stays off the event loop (teeth verified red/green); AGENTS.md documents the shared components. * refactor(sandbox): address review on acquire serialization (#5089) - Replace unreachable checkin branch with an assertion: run() returns False only after abandon(), which the except handler always re-raises; the old _checkin would have double-decremented the refcount. - Document the task.cancelling() == 0 assumption in hold_async. - Drop unused thread_id/user_id kwargs from BoxLite and Tenki _acquire_scope_locked (OpenSandbox still forwards them). * fix(sandbox): preserve request ContextVars in acquire executor bridge (#5089) loop.run_in_executor() does not copy contextvars, unlike the inherited SandboxProvider.acquire_async() which used asyncio.to_thread(). The BoxLite/OpenSandbox/Tenki acquire_async bridges introduced in this PR therefore dropped the request trace id (logged as trace_id=-). Add AcquireSerializer.run_on_executor(), which copies the calling context and runs the callable through ctx.run, and route all three providers through it. Add regression tests binding request_trace_context and verifying the worker thread observes it.
220 lines
8.5 KiB
Python
220 lines
8.5 KiB
Python
"""Per-key acquire serialization with bounded lock-table growth (RFC #4741).
|
|
|
|
Shared component for sandbox providers. Each provider selects the key that
|
|
preserves its current collision and lifecycle semantics (``(user_id,
|
|
thread_id)`` for AIO/E2B, the derived sandbox id for BoxLite/Tenki/
|
|
OpenSandbox); the serializer never interprets it.
|
|
|
|
The async path runs the blocking ``threading.Lock.acquire`` on a bounded
|
|
dedicated executor (never the default executor, never the event loop). A small
|
|
handoff state makes the acquire worker release an abandoned lock itself, so
|
|
cleanup does not depend on the cancelling event loop running a done callback.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextvars
|
|
import os
|
|
import threading
|
|
from collections.abc import AsyncIterator, Callable, Hashable, Iterator
|
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
from contextlib import asynccontextmanager, contextmanager
|
|
from dataclasses import dataclass, field
|
|
from functools import partial
|
|
|
|
DEFAULT_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
|
|
|
|
|
|
@dataclass
|
|
class _Entry:
|
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
refs: int = 0 # holders + waiters
|
|
|
|
|
|
class _AsyncAcquire:
|
|
"""Race-safe ownership handoff between an event loop and acquire worker."""
|
|
|
|
def __init__(self, entry: _Entry, cleanup: Callable[[], None]) -> None:
|
|
self._entry = entry
|
|
self._cleanup = cleanup
|
|
self._state_lock = threading.Lock()
|
|
self._abandoned = False
|
|
self._acquired = False
|
|
self._cleaned = False
|
|
|
|
def run(self) -> bool:
|
|
self._entry.lock.acquire()
|
|
with self._state_lock:
|
|
if self._abandoned:
|
|
cleanup = self._mark_cleaned()
|
|
else:
|
|
self._acquired = True
|
|
cleanup = False
|
|
if cleanup:
|
|
self._entry.lock.release()
|
|
self._cleanup()
|
|
return not cleanup
|
|
|
|
def abandon(self) -> None:
|
|
with self._state_lock:
|
|
self._abandoned = True
|
|
cleanup = self._acquired and self._mark_cleaned()
|
|
if cleanup:
|
|
self._acquired = False
|
|
if cleanup:
|
|
self._entry.lock.release()
|
|
self._cleanup()
|
|
|
|
def cancel_queued(self) -> None:
|
|
"""Clean up an executor job cancelled before its worker started."""
|
|
with self._state_lock:
|
|
cleanup = self._mark_cleaned()
|
|
if cleanup:
|
|
self._cleanup()
|
|
|
|
def worker_done(self, future: Future[bool]) -> None:
|
|
"""Reclaim a checkout if executor shutdown cancelled queued work."""
|
|
if future.cancelled():
|
|
self.cancel_queued()
|
|
|
|
def release(self) -> None:
|
|
with self._state_lock:
|
|
cleanup = self._acquired and self._mark_cleaned()
|
|
if cleanup:
|
|
self._acquired = False
|
|
if not cleanup: # pragma: no cover - indicates an internal ownership bug
|
|
raise RuntimeError("Serializer lock is not held")
|
|
self._entry.lock.release()
|
|
self._cleanup()
|
|
|
|
def _mark_cleaned(self) -> bool:
|
|
if self._cleaned:
|
|
return False
|
|
self._cleaned = True
|
|
return True
|
|
|
|
|
|
class AcquireSerializer[KeyT: Hashable]:
|
|
"""Serialize provider-selected lifecycle transitions per key."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
thread_name_prefix: str = "sandbox-acquire-wait",
|
|
) -> None:
|
|
self._table: dict[KeyT, _Entry] = {}
|
|
self._table_lock = threading.Lock()
|
|
self._closed = False
|
|
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix=thread_name_prefix)
|
|
|
|
@property
|
|
def executor(self) -> ThreadPoolExecutor:
|
|
"""The dedicated bounded executor backing async waits.
|
|
|
|
Providers may offload an entire synchronous acquire (with hold()
|
|
inside) here so a cancelled awaiter abandons the worker, not the
|
|
lock: the hold then follows the body to completion, matching the
|
|
pre-serializer to_thread bridge semantics.
|
|
"""
|
|
return self._executor
|
|
|
|
async def run_on_executor[**P, T](self, func: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> T:
|
|
"""Run a blocking callable on the serializer's dedicated executor.
|
|
|
|
``asyncio.to_thread`` copies ``ContextVar`` values automatically; raw
|
|
``loop.run_in_executor`` does not. The providers whose ``acquire_async``
|
|
offloads the whole synchronous acquire here replaced the inherited
|
|
``to_thread`` bridge, so copy the calling context explicitly: without
|
|
it, request-scoped ContextVars (e.g. the trace id bound by
|
|
``request_trace_context``) read as unset inside the worker thread.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
ctx = contextvars.copy_context()
|
|
call = partial(func, *args, **kwargs)
|
|
return await loop.run_in_executor(self._executor, ctx.run, call)
|
|
|
|
@contextmanager
|
|
def hold(self, key: KeyT) -> Iterator[None]:
|
|
entry = self._checkout(key)
|
|
entry.lock.acquire()
|
|
try:
|
|
yield
|
|
finally:
|
|
entry.lock.release()
|
|
self._checkin(key, entry)
|
|
|
|
@asynccontextmanager
|
|
async def hold_async(self, key: KeyT) -> AsyncIterator[None]:
|
|
entry = self._checkout(key)
|
|
loop = asyncio.get_running_loop()
|
|
acquire = _AsyncAcquire(entry, lambda: self._checkin(key, entry))
|
|
try:
|
|
worker_future = self._executor.submit(acquire.run)
|
|
except RuntimeError: # executor shut down between checkout and scheduling
|
|
self._checkin(key, entry)
|
|
raise RuntimeError("AcquireSerializer is closed") from None
|
|
# concurrent.futures callbacks run in the cancelling/worker thread, so
|
|
# executor-shutdown cleanup does not depend on this loop progressing.
|
|
worker_future.add_done_callback(acquire.worker_done)
|
|
acquire_future = asyncio.wrap_future(worker_future, loop=loop)
|
|
try:
|
|
acquired = await asyncio.shield(acquire_future)
|
|
except asyncio.CancelledError:
|
|
task = asyncio.current_task()
|
|
if acquire_future.cancelled() and task is not None and task.cancelling() == 0:
|
|
# close() cancelled the queued wait via executor shutdown, not
|
|
# the caller: check in exactly once (no done callback — the
|
|
# cancelled future is already done) and report the documented
|
|
# closure error instead of a spurious CancelledError. The
|
|
# `cancelling() == 0` guard assumes this await is the task's
|
|
# only pending cancellation (no stale count left by an outer
|
|
# swallowed CancelledError).
|
|
acquire.cancel_queued()
|
|
raise RuntimeError("AcquireSerializer is closed") from None
|
|
if acquire_future.cancelled():
|
|
acquire.cancel_queued()
|
|
else:
|
|
acquire.abandon()
|
|
raise
|
|
# run() returns False only when the worker observed _abandoned, and
|
|
# abandon() is called solely from the except handler above, which
|
|
# always re-raises — so a normal completion implies the lock is held.
|
|
# If this were reachable, the worker would already have checked in;
|
|
# an extra _checkin here would underflow the refcount, so assert.
|
|
assert acquired, "acquire worker returned without holding the lock"
|
|
try:
|
|
yield
|
|
finally:
|
|
acquire.release()
|
|
|
|
def close(self) -> None:
|
|
"""Reject new holders and release executor resources. Idempotent.
|
|
|
|
A currently executing critical section is not invalidated: its exit
|
|
still releases its lock and reclaims its table entry.
|
|
"""
|
|
with self._table_lock:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
|
|
def _checkout(self, key: KeyT) -> _Entry:
|
|
with self._table_lock:
|
|
if self._closed:
|
|
raise RuntimeError("AcquireSerializer is closed")
|
|
entry = self._table.get(key)
|
|
if entry is None:
|
|
entry = _Entry()
|
|
self._table[key] = entry
|
|
entry.refs += 1
|
|
return entry
|
|
|
|
def _checkin(self, key: KeyT, entry: _Entry) -> None:
|
|
with self._table_lock:
|
|
entry.refs -= 1
|
|
if entry.refs == 0 and not entry.lock.locked():
|
|
self._table.pop(key, None)
|