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

480 lines
16 KiB
Python

from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from typing import Any
import pytest
from redis import Redis
from redis.asyncio import Redis as AsyncRedis
from opensandbox.exceptions import (
PoolDestroyedException,
PoolStateStoreUnavailableException,
)
from opensandbox.pool_redis import RedisPoolStateStore
from opensandbox.pool_types import PoolDestroyState
@pytest.fixture()
def redis_store() -> tuple[RedisPoolStateStore, Any, str]:
redis_client = _FakeRedis()
key_prefix = "opensandbox:test"
store = RedisPoolStateStore(redis_client, key_prefix=key_prefix)
return store, redis_client, key_prefix
def test_redis_store_put_and_take_idle_fifo(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.put_idle("pool", "id-1")
store.put_idle("pool", "id-2")
store.put_idle("pool", "id-3")
assert store.try_take_idle("pool") == "id-1"
assert store.try_take_idle("pool") == "id-2"
assert store.try_take_idle("pool") == "id-3"
assert store.try_take_idle("pool") is None
def test_redis_store_put_idle_is_idempotent(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.put_idle("pool", "id-1")
store.put_idle("pool", "id-1")
assert store.snapshot_counters("pool").idle_count == 1
assert store.try_take_idle("pool") == "id-1"
assert store.try_take_idle("pool") is None
def test_redis_store_reap_expired_idle(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.set_idle_entry_ttl("pool", timedelta(milliseconds=50))
store.put_idle("pool", "id-1")
time.sleep(0.1)
assert store.snapshot_counters("pool").idle_count == 1
store.reap_expired_idle("pool", datetime.now(timezone.utc))
assert store.snapshot_counters("pool").idle_count == 0
assert store.try_take_idle("pool") is None
def test_redis_store_try_take_idle_min_ttl_surfaces_alive_below_threshold(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.set_idle_entry_ttl("pool", timedelta(seconds=5))
store.put_idle("pool", "id-1")
store.put_idle("pool", "id-2")
result = store.try_take_idle_min_ttl("pool", timedelta(seconds=60))
assert result.sandbox_id is None
assert set(result.discarded_alive_sandbox_ids) == {"id-1", "id-2"}
assert store.snapshot_counters("pool").idle_count == 0
def test_redis_store_try_take_idle_min_ttl_returns_entries_above_threshold(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.set_idle_entry_ttl("pool", timedelta(minutes=10))
store.put_idle("pool", "id-1")
result = store.try_take_idle_min_ttl("pool", timedelta(seconds=60))
assert result.sandbox_id == "id-1"
assert result.discarded_alive_sandbox_ids == ()
def test_redis_store_try_take_idle_min_ttl_zero_falls_back_to_base(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.put_idle("pool", "id-1")
taken = store.try_take_idle_min_ttl("pool", timedelta(0))
assert taken.sandbox_id == "id-1"
assert taken.discarded_alive_sandbox_ids == ()
empty = store.try_take_idle_min_ttl("pool", timedelta(0))
assert empty.sandbox_id is None
assert empty.discarded_alive_sandbox_ids == ()
def test_redis_store_reap_expired_idle_min_ttl_returns_alive_evicted(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.set_idle_entry_ttl("pool", timedelta(seconds=5))
store.put_idle("pool", "id-1")
store.put_idle("pool", "id-2")
discarded_alive = store.reap_expired_idle_min_ttl(
"pool", datetime.now(timezone.utc), timedelta(seconds=60)
)
assert set(discarded_alive) == {"id-1", "id-2"}
assert store.snapshot_counters("pool").idle_count == 0
def test_redis_store_primary_lock_owner_semantics(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
assert store.try_acquire_primary_lock("pool", "owner-1", timedelta(seconds=60))
assert store.try_acquire_primary_lock("pool", "owner-1", timedelta(seconds=60))
assert store.renew_primary_lock("pool", "owner-1", timedelta(seconds=60))
assert not store.try_acquire_primary_lock("pool", "owner-2", timedelta(seconds=60))
assert not store.renew_primary_lock("pool", "owner-2", timedelta(seconds=60))
store.release_primary_lock("pool", "owner-2")
assert not store.try_acquire_primary_lock("pool", "owner-2", timedelta(seconds=60))
store.release_primary_lock("pool", "owner-1")
assert store.try_acquire_primary_lock("pool", "owner-2", timedelta(seconds=60))
def test_redis_store_max_idle_is_shared(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
assert store.get_max_idle("pool") is None
store.set_max_idle("pool", 7)
assert store.get_max_idle("pool") == 7
store.set_max_idle("pool", 0)
assert store.get_max_idle("pool") == 0
def test_redis_store_destroy_fences_writes_and_preserves_tombstone(
redis_store: tuple[RedisPoolStateStore, Any, str],
) -> None:
store, _, _ = redis_store
store.put_idle("pool", "id-1")
assert store.try_acquire_primary_lock("pool", "owner-1", timedelta(seconds=60))
store.begin_destroy("pool", "destroyer")
assert store.get_destroy_state("pool") == PoolDestroyState.DESTROYING
assert not store.try_acquire_primary_lock("pool", "owner-1", timedelta(seconds=60))
assert not store.renew_primary_lock("pool", "owner-1", timedelta(seconds=60))
with pytest.raises(PoolDestroyedException):
store.put_idle("pool", "id-2")
with pytest.raises(PoolDestroyedException):
store.set_max_idle("pool", 2)
store.clear_pool_state("pool")
assert store.snapshot_counters("pool").idle_count == 0
assert store.get_destroy_state("pool") == PoolDestroyState.DESTROYING
store.mark_destroyed("pool", "destroyer", timedelta(seconds=60))
assert store.get_destroy_state("pool") == PoolDestroyState.DESTROYED
with pytest.raises(PoolDestroyedException):
store.begin_destroy("pool", "destroyer-2")
assert store.get_destroy_state("pool") == PoolDestroyState.DESTROYED
def test_redis_store_wraps_client_failures() -> None:
store = RedisPoolStateStore(_BrokenRedis())
with pytest.raises(PoolStateStoreUnavailableException):
store.get_max_idle("pool")
def test_redis_store_rejects_async_client_shape() -> None:
with pytest.raises(TypeError, match="redis.Redis"):
RedisPoolStateStore(_FakeAsyncRedis())
class _BrokenRedis(Redis):
def __init__(self) -> None:
pass
def eval(self, script: str, numkeys: int, *args: str) -> str | int | None:
raise RuntimeError("redis unavailable")
def get(self, key: str) -> str | None:
raise RuntimeError("redis unavailable")
def set(
self,
key: str,
value: str,
*,
nx: bool = False,
px: int | None = None,
) -> bool:
raise RuntimeError("redis unavailable")
def hdel(self, key: str, field: str) -> int:
raise RuntimeError("redis unavailable")
def lrem(self, key: str, count: int, value: str) -> int:
raise RuntimeError("redis unavailable")
def hlen(self, key: str) -> int:
raise RuntimeError("redis unavailable")
def lrange(self, key: str, start: int, stop: int) -> list[str]:
raise RuntimeError("redis unavailable")
def hgetall(self, key: str) -> dict[str, str]:
raise RuntimeError("redis unavailable")
def delete(self, *keys: str) -> int:
raise RuntimeError("redis unavailable")
class _FakeAsyncRedis(AsyncRedis):
def __init__(self) -> None:
pass
async def eval(self, script: str, numkeys: int, *args: str) -> str | int | None:
return None
async def get(self, key: str) -> str | None:
return None
async def set(
self,
key: str,
value: str,
*,
nx: bool = False,
px: int | None = None,
) -> bool:
return True
async def hdel(self, key: str, field: str) -> int:
return 0
async def lrem(self, key: str, count: int, value: str) -> int:
return 0
async def hlen(self, key: str) -> int:
return 0
async def lrange(self, key: str, start: int, stop: int) -> list[str]:
return []
async def hgetall(self, key: str) -> dict[str, str]:
return {}
async def delete(self, *keys: str) -> int:
return 0
class _FakeRedis(Redis):
"""Small Redis double for RedisPoolStateStore unit tests."""
def __init__(self) -> None:
self._strings: dict[str, tuple[str, int | None]] = {}
self._lists: dict[str, list[str]] = {}
self._hashes: dict[str, dict[str, str]] = {}
def eval(self, script: str, numkeys: int, *args: Any) -> Any:
del numkeys
if "LPOP" in script:
min_remaining_ttl_ms = int(args[2]) if len(args) >= 3 else 0
return self._take_idle(args[0], args[1], min_remaining_ttl_ms)
if "RPUSH" in script:
return self._put_idle(args[0], args[1], args[2], args[3], int(args[4]))
if "SET" in script and "NX" in script:
return int(self._acquire_lock(args[0], args[1], args[2], int(args[3])))
if "PEXPIRE" in script:
return int(self._renew_lock(args[0], args[1], args[2], int(args[3])))
if "HGETALL" in script:
min_remaining_ttl_ms = int(args[2]) if len(args) >= 3 else 0
return self._reap_expired(args[0], args[1], min_remaining_ttl_ms)
if "DEL" in script and "GET" in script:
return self._release_lock(args[0], args[1])
if "destroy_state == ARGV[2]" in script:
return self._begin_destroy(args[0], args[1], args[2], args[3], args[4])
if "SET" in script:
return self._set_value(args[0], args[1], args[2])
raise NotImplementedError("unsupported Redis script")
def get(self, key: str) -> str | None:
self._expire_string_if_needed(key)
value = self._strings.get(key)
return value[0] if value is not None else None
def set(
self,
key: str,
value: str,
*,
nx: bool = False,
px: int | None = None,
) -> bool:
self._expire_string_if_needed(key)
if nx and key in self._strings:
return False
expires_at = _now_ms() + px if px is not None else None
self._strings[key] = (value, expires_at)
return True
def hdel(self, key: str, field: str) -> int:
existed = field in self._hashes.get(key, {})
self._hashes.get(key, {}).pop(field, None)
return int(existed)
def lrem(self, key: str, count: int, value: str) -> int:
del count
values = self._lists.get(key, [])
removed = values.count(value)
self._lists[key] = [item for item in values if item != value]
return removed
def hlen(self, key: str) -> int:
return len(self._hashes.get(key, {}))
def lrange(self, key: str, start: int, stop: int) -> list[str]:
values = self._lists.get(key, [])
if stop == -1:
return values[start:]
return values[start : stop + 1]
def hgetall(self, key: str) -> dict[str, str]:
return dict(self._hashes.get(key, {}))
def delete(self, *keys: str) -> int:
removed = 0
for key in keys:
if key in self._strings:
self._strings.pop(key, None)
removed += 1
if key in self._lists:
self._lists.pop(key, None)
removed += 1
if key in self._hashes:
self._hashes.pop(key, None)
removed += 1
return removed
def _take_idle(
self, list_key: str, expires_key: str, min_remaining_ttl_ms: int = 0
) -> Any:
queue = self._lists.setdefault(list_key, [])
expires = self._hashes.setdefault(expires_key, {})
now_ms = _now_ms()
cutoff = now_ms + max(0, min_remaining_ttl_ms)
discarded_alive: list[str] = []
while queue:
sandbox_id = queue.pop(0)
expires_at = expires.pop(sandbox_id, None)
if expires_at is None:
continue
exp = int(expires_at)
if exp > cutoff:
return [sandbox_id, discarded_alive]
if exp > now_ms:
discarded_alive.append(sandbox_id)
if not discarded_alive:
return None
return ["", discarded_alive]
def _put_idle(
self,
list_key: str,
expires_key: str,
destroy_state_key: str,
sandbox_id: str,
ttl_ms: int,
) -> int:
if self.get(destroy_state_key) is not None:
return -1
now = _now_ms()
expires = self._hashes.setdefault(expires_key, {})
current = expires.get(sandbox_id)
if current is not None and int(current) > now:
return 0
if current is None:
self._lists.setdefault(list_key, []).append(sandbox_id)
expires[sandbox_id] = str(now + ttl_ms)
return 1
def _acquire_lock(
self, key: str, destroy_state_key: str, owner_id: str, ttl_ms: int
) -> bool:
if self.get(destroy_state_key) is not None:
return False
if self.set(key, owner_id, nx=True, px=ttl_ms):
return True
return self._renew_lock(key, destroy_state_key, owner_id, ttl_ms)
def _renew_lock(
self, key: str, destroy_state_key: str, owner_id: str, ttl_ms: int
) -> bool:
if self.get(destroy_state_key) is not None:
return False
if self.get(key) != owner_id:
return False
self._strings[key] = (owner_id, _now_ms() + ttl_ms)
return True
def _set_value(self, key: str, destroy_state_key: str, value: str) -> int:
if self.get(destroy_state_key) is not None:
return -1
self.set(key, value)
return 1
def _begin_destroy(
self,
state_key: str,
owner_key: str,
destroying: str,
destroyed: str,
owner_id: str,
) -> int:
if self.get(state_key) == destroyed:
return -1
self.set(state_key, destroying)
self.set(owner_key, owner_id)
return 1
def _release_lock(self, key: str, owner_id: str) -> int:
if self.get(key) != owner_id:
return 0
self._strings.pop(key, None)
return 1
def _reap_expired(
self, list_key: str, expires_key: str, min_remaining_ttl_ms: int = 0
) -> list[str]:
expires = self._hashes.setdefault(expires_key, {})
now_ms = _now_ms()
cutoff = now_ms + max(0, min_remaining_ttl_ms)
evicted: list[tuple[str, int]] = []
for sandbox_id, expiry in list(expires.items()):
exp = int(expiry)
if exp <= cutoff:
evicted.append((sandbox_id, exp))
expires.pop(sandbox_id, None)
if evicted:
evicted_ids = {sandbox_id for sandbox_id, _ in evicted}
self._lists[list_key] = [
sandbox_id
for sandbox_id in self._lists.get(list_key, [])
if sandbox_id not in evicted_ids
]
return [sandbox_id for sandbox_id, exp in evicted if exp > now_ms]
def _expire_string_if_needed(self, key: str) -> None:
value = self._strings.get(key)
if value is not None and value[1] is not None and value[1] <= _now_ms():
self._strings.pop(key, None)
def _now_ms() -> int:
return int(time.time() * 1000)