Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

337 lines
12 KiB
Python

# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Single renew-intent pipeline: Redis BRPOP feeders + proxy submits → one asyncio queue → processors."""
from __future__ import annotations
import asyncio
import logging
import time
from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import partial
from typing import TYPE_CHECKING, Optional
from redis.exceptions import RedisError
from opensandbox_server.config import AppConfig
from opensandbox_server.integrations.renew_intent.constants import (
BRPOP_TIMEOUT_SECONDS,
INTENT_MAX_AGE_SECONDS,
PROXY_RENEW_MAX_TRACKED_SANDBOXES,
)
from opensandbox_server.integrations.renew_intent.controller import AccessRenewController
from opensandbox_server.integrations.renew_intent.intent import parse_renew_intent_json
from opensandbox_server.integrations.renew_intent.logutil import (
RENEW_EVENT_WORKERS_NOT_STARTED,
RENEW_EVENT_WORKERS_STARTED,
RENEW_SOURCE_REDIS_QUEUE,
RENEW_SOURCE_SERVER_PROXY,
renew_bundle,
)
from opensandbox_server.integrations.renew_intent.redis_client import connect_renew_intent_redis_from_config
from opensandbox_server.services.extension_service import ExtensionService, require_extension_service
from opensandbox_server.services.factory import create_sandbox_service
from opensandbox_server.services.sandbox_service import SandboxService
if TYPE_CHECKING:
from redis.asyncio import Redis
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RenewWorkItem:
"""One unit of work for the shared renew pipeline."""
source: str
sandbox_id: str
observed_at: datetime
@dataclass
class _MemSandboxState:
lock: asyncio.Lock
last_success_monotonic: float | None = None
class RenewIntentConsumer:
"""
Feeds renew work from Redis BRPOP (optional) and server-proxy ``schedule`` into one queue.
Per-sandbox ``asyncio.Lock`` serializes work; without Redis, ``min_interval`` throttles proxy
renews (ingress throttling is producer-side).
"""
def __init__(
self,
app_config: AppConfig,
sandbox_service: SandboxService,
extension_service: ExtensionService,
redis_client: Optional["Redis"],
) -> None:
self._app_config = app_config
self._redis = redis_client
ri = app_config.renew_intent
self._queue_key = ri.redis.queue_key
self._feeder_count = ri.redis.consumer_concurrency if redis_client else 0
self._processor_count = max(1, ri.redis.consumer_concurrency)
self._min_interval = float(ri.min_interval_seconds)
self._controller = AccessRenewController(sandbox_service, extension_service)
self._work_queue: asyncio.Queue[RenewWorkItem] = asyncio.Queue()
self._stop = asyncio.Event()
self._tasks: list[asyncio.Task[None]] = []
self._mem_states: OrderedDict[str, _MemSandboxState] = OrderedDict()
self._max_tracked = PROXY_RENEW_MAX_TRACKED_SANDBOXES
@classmethod
async def start(
cls,
app_config: AppConfig,
sandbox_service: SandboxService,
extension_service: ExtensionService,
) -> Optional["RenewIntentConsumer"]:
if not app_config.renew_intent.enabled:
return None
redis_client: Optional["Redis"] = None
if app_config.renew_intent.redis.enabled:
try:
redis_client = await connect_renew_intent_redis_from_config(app_config)
except (RedisError, OSError, TimeoutError) as exc:
line, ex = renew_bundle(
event=RENEW_EVENT_WORKERS_NOT_STARTED,
source=RENEW_SOURCE_REDIS_QUEUE,
skip_reason="redis_connect_failed",
error_type=type(exc).__name__,
)
logger.error(f"renew_intent {line} error={exc!s}", extra=ex)
redis_client = None
if redis_client is None and app_config.renew_intent.redis.enabled:
line, ex = renew_bundle(
event=RENEW_EVENT_WORKERS_NOT_STARTED,
source=RENEW_SOURCE_REDIS_QUEUE,
skip_reason="redis_client_none",
)
logger.warning(
f"renew_intent {line}; continuing with proxy-only renew pipeline",
extra=ex,
)
consumer = cls(app_config, sandbox_service, extension_service, redis_client)
consumer._spawn_tasks()
if redis_client is not None:
line, ex = renew_bundle(
event=RENEW_EVENT_WORKERS_STARTED,
source=RENEW_SOURCE_REDIS_QUEUE,
worker_count=consumer._feeder_count + consumer._processor_count,
queue_key=consumer._queue_key,
)
logger.info(
f"🧪 [EXPERIMENTAL] renew_intent is enabled: Redis BRPOP feeders + "
f"unified processors started ({line})",
extra=ex,
)
else:
logger.info(
"🧪 [EXPERIMENTAL] renew_intent is enabled: unified in-process renew pipeline "
"(proxy path only; no Redis BRPOP)"
)
return consumer
def submit_from_proxy(self, sandbox_id: str) -> None:
"""Enqueue renew work from ``/sandboxes/.../proxy/...`` (non-blocking)."""
if not self._app_config.renew_intent.enabled:
return
asyncio.create_task(
self._enqueue_proxy(sandbox_id),
name=f"renew_intent_proxy_enqueue_{sandbox_id}",
)
async def _enqueue_proxy(self, sandbox_id: str) -> None:
await self._work_queue.put(
RenewWorkItem(
source=RENEW_SOURCE_SERVER_PROXY,
sandbox_id=sandbox_id,
observed_at=datetime.now(timezone.utc),
)
)
def _spawn_tasks(self) -> None:
for i in range(self._processor_count):
self._tasks.append(
asyncio.create_task(
self._processor_loop(i),
name=f"renew_intent_processor_{i}",
)
)
for i in range(self._feeder_count):
self._tasks.append(
asyncio.create_task(
self._brpop_feeder_loop(i),
name=f"renew_intent_brpop_{i}",
)
)
@staticmethod
def _is_stale(observed_at: datetime) -> bool:
now = datetime.now(timezone.utc)
age = (now - observed_at).total_seconds()
return age > INTENT_MAX_AGE_SECONDS
def _ensure_mru_mem(self, sandbox_id: str) -> _MemSandboxState:
if sandbox_id in self._mem_states:
st = self._mem_states[sandbox_id]
self._mem_states.move_to_end(sandbox_id)
else:
st = _MemSandboxState(lock=asyncio.Lock())
self._mem_states[sandbox_id] = st
self._mem_states.move_to_end(sandbox_id)
self._evict_mem_lru_unlocked()
return st
def _evict_mem_lru_unlocked(self) -> None:
rotations = 0
max_rotations = max(len(self._mem_states), 1)
while len(self._mem_states) > self._max_tracked and rotations < max_rotations:
k, st = self._mem_states.popitem(last=False)
if st.lock.locked():
self._mem_states[k] = st
self._mem_states.move_to_end(k)
rotations += 1
else:
rotations = 0
async def _brpop_feeder_loop(self, worker_id: int) -> None:
assert self._redis is not None
while not self._stop.is_set():
try:
result = await self._redis.brpop(
self._queue_key,
BRPOP_TIMEOUT_SECONDS,
)
except asyncio.CancelledError:
raise
except (RedisError, OSError) as exc:
line, ex = renew_bundle(
event="worker_redis_error",
source=RENEW_SOURCE_REDIS_QUEUE,
worker_id=worker_id,
error_type=type(exc).__name__,
)
logger.warning(f"renew_intent {line} error={exc!s}", extra=ex)
await asyncio.sleep(1.0)
continue
if result is None:
continue
_, payload = result
if not isinstance(payload, str):
continue
try:
intent = parse_renew_intent_json(payload)
if intent is None:
continue
if self._is_stale(intent.observed_at):
continue
await self._work_queue.put(
RenewWorkItem(
source=RENEW_SOURCE_REDIS_QUEUE,
sandbox_id=intent.sandbox_id,
observed_at=intent.observed_at,
)
)
except Exception as exc:
line, ex = renew_bundle(
event="worker_handle_error",
source=RENEW_SOURCE_REDIS_QUEUE,
worker_id=worker_id,
error_type=type(exc).__name__,
)
logger.exception(f"renew_intent {line} error={exc!s}", extra=ex)
async def _processor_loop(self, worker_id: int) -> None:
while not self._stop.is_set():
try:
work = await asyncio.wait_for(self._work_queue.get(), timeout=1.0)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
raise
try:
await self._process_work(work)
except Exception as exc:
line, ex = renew_bundle(
event="processor_error",
source=work.source,
sandbox_id=work.sandbox_id,
worker_id=worker_id,
error_type=type(exc).__name__,
)
logger.exception(f"renew_intent {line} error={exc!s}", extra=ex)
finally:
self._work_queue.task_done()
async def _process_work(self, work: RenewWorkItem) -> None:
if self._redis is None and work.source != RENEW_SOURCE_SERVER_PROXY:
return
st = self._ensure_mru_mem(work.sandbox_id)
async with st.lock:
if self._redis is not None:
await self._controller.renew_after_gates(work.sandbox_id, source=work.source)
return
now = time.monotonic()
if (
st.last_success_monotonic is not None
and (now - st.last_success_monotonic) < self._min_interval
):
return
ok = await asyncio.to_thread(
partial(
self._controller.attempt_renew_sync,
work.sandbox_id,
source=work.source,
)
)
if ok:
st.last_success_monotonic = time.monotonic()
async def stop(self) -> None:
self._stop.set()
for t in self._tasks:
t.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
if self._redis is not None:
try:
await self._redis.aclose()
except Exception as exc:
logger.debug(f"renew_intent redis_close error={exc!s}")
async def start_renew_intent_consumer(
app_config: AppConfig,
sandbox_service: SandboxService | None = None,
extension_service: ExtensionService | None = None,
) -> Optional[RenewIntentConsumer]:
"""Start consumer or ``None`` when ``renew_intent.enabled`` is false."""
if sandbox_service is None:
sandbox_service = create_sandbox_service(config=app_config)
if extension_service is None:
extension_service = require_extension_service(sandbox_service)
return await RenewIntentConsumer.start(app_config, sandbox_service, extension_service)