Files
opensandbox-group--opensandbox/tests/python/tests/test_sandbox_pool_e2e_sync.py
T
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

929 lines
36 KiB
Python

#
# Copyright 2025 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.
#
"""E2E coverage for the synchronous Python sandbox pool."""
from __future__ import annotations
import os
import threading
import time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from opensandbox import SandboxManagerSync, SandboxSync
from opensandbox.config import ConnectionConfigSync
from opensandbox.exceptions import (
PoolAcquireFailedException,
PoolDestroyedException,
PoolEmptyException,
PoolNotRunningException,
)
from opensandbox.models.sandboxes import SandboxFilter
from opensandbox.pool import (
AcquirePolicy,
InMemoryPoolStateStore,
PoolCreationSpec,
PoolDestroyOptions,
PoolDestroyState,
PoolState,
PoolStateStore,
SandboxPoolManagerSync,
SandboxPoolSync,
)
from opensandbox.pool_redis import RedisPoolStateStore
from tests.base_e2e_test import (
create_connection_config_sync,
get_e2e_sandbox_resource,
get_sandbox_image,
)
MAX_IDLE = 2
RECONCILE_INTERVAL = timedelta(seconds=1)
PRIMARY_LOCK_TTL = timedelta(seconds=4)
DRAIN_TIMEOUT = timedelta(milliseconds=300)
AWAIT_TIMEOUT = timedelta(minutes=2)
@pytest.mark.e2e
class TestSandboxPoolSingleNodeE2ESync:
"""Single-process in-memory pool E2E scenarios."""
def setup_method(self) -> None:
self.tag = _tag("py-pool")
self.pool_name = f"pool-{self.tag}"
self.store = InMemoryPoolStateStore()
self.manager = SandboxManagerSync.create(create_connection_config_sync())
self.borrowed: list[SandboxSync] = []
self.pool = _create_pool(
pool_name=self.pool_name,
owner_id=f"owner-{self.tag}",
state_store=self.store,
tag=self.tag,
max_idle=MAX_IDLE,
)
self.pool.start()
def teardown_method(self) -> None:
_cleanup_borrowed(self.borrowed)
_cleanup_pool(self.pool)
_cleanup_tagged_sandboxes(self.manager, self.tag)
self.manager.close()
@pytest.mark.timeout(240)
def test_warmup_acquire_fail_fast_and_command(self) -> None:
_eventually(
"pool becomes healthy with warm idle",
lambda: self.pool.snapshot().state == PoolState.HEALTHY
and self.pool.snapshot().idle_count >= 1,
)
sandbox = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
result = sandbox.commands.run("echo py-pool-basic-ok")
assert result.error is None
assert result.logs.stdout[0].text == "py-pool-basic-ok"
@pytest.mark.timeout(240)
def test_resize_release_fail_fast_and_direct_create_fallback(self) -> None:
_eventually("pool has warm idle", lambda: self.pool.snapshot().idle_count >= 1)
self.pool.resize(0)
released = self.pool.release_all_idle()
assert released >= 0
_eventually("idle drains after resize zero", lambda: self.pool.snapshot().idle_count == 0)
with pytest.raises(PoolEmptyException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
direct = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(direct)
assert direct.is_healthy()
@pytest.mark.timeout(240)
def test_destroy_drains_idle_writes_tombstone_and_blocks_acquire(self) -> None:
_eventually("pool has warm idle before destroy", lambda: self.pool.snapshot().idle_count >= 1)
manager = SandboxPoolManagerSync(
state_store=self.store,
connection_config=create_connection_config_sync(),
owner_id=f"destroyer-{self.tag}",
)
result = manager.destroy(
self.pool_name,
PoolDestroyOptions(drain_timeout=timedelta(seconds=30)),
)
assert result.state == PoolDestroyState.DESTROYED
assert result.drained_idle_count >= 1
assert result.persistent_state_cleared
assert self.store.get_destroy_state(self.pool_name) == PoolDestroyState.DESTROYED
with pytest.raises(PoolDestroyedException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
@pytest.mark.timeout(240)
def test_stale_idle_fallback_shutdown_restart_and_snapshot(self) -> None:
self.store.put_idle(self.pool_name, f"missing-{time.monotonic_ns()}")
fallback = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(fallback)
assert fallback.is_healthy()
self.pool.shutdown(graceful=True)
with pytest.raises(PoolNotRunningException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
stopped = self.pool.snapshot()
assert stopped.state == PoolState.STOPPED
assert stopped.lifecycle_state.value == "STOPPED"
self.pool.start()
_eventually(
"pool restarts and warms idle",
lambda: self.pool.snapshot().state == PoolState.HEALTHY
and self.pool.snapshot().idle_count >= 1,
)
entries = self.pool.snapshot_idle_entries()
assert entries
assert all(entry.sandbox_id for entry in entries)
assert all(entry.expires_at > datetime.now(timezone.utc) for entry in entries)
@pytest.mark.timeout(360)
def test_lifecycle_idempotency_resize_rewarm_and_release_remote(self) -> None:
self.pool.start()
_eventually("pool warms before lifecycle checks", lambda: self.pool.snapshot().idle_count >= 1)
self.pool.shutdown(False)
self.pool.shutdown(False)
assert self.pool.snapshot().state == PoolState.STOPPED
with pytest.raises(PoolNotRunningException):
self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.pool.release_all_idle()
assert self.pool.snapshot().idle_count == 0
self.store.put_idle(self.pool_name, f"injected-a-{uuid.uuid4().hex}")
self.store.put_idle(self.pool_name, f"injected-b-{uuid.uuid4().hex}")
assert self.pool.release_all_idle() == 2
assert self.pool.snapshot().idle_count == 0
self.pool.start()
_eventually("pool rewarms after restart", lambda: self.pool.snapshot().idle_count >= 1)
self.pool.resize(0)
assert self.pool.release_all_idle() >= 0
_eventually(
"releaseAllIdle reduces remote tagged sandboxes",
lambda: self.pool.snapshot().idle_count == 0
and _count_tagged_sandboxes(self.manager, self.tag) == 0,
timeout=timedelta(seconds=60),
)
self.pool.resize(1)
_eventually(
"resize from zero to positive rewarms idle",
lambda: self.pool.snapshot().state == PoolState.HEALTHY
and self.pool.snapshot().idle_count >= 1,
)
@pytest.mark.timeout(360)
def test_concurrent_acquire_resize_and_shutdown_do_not_duplicate_or_deadlock(self) -> None:
_eventually("pool reaches target idle", lambda: self.pool.snapshot().idle_count >= MAX_IDLE)
start = threading.Event()
acquired_ids: set[str] = set()
acquired_lock = threading.Lock()
errors: list[BaseException] = []
def worker(index: int) -> None:
try:
start.wait()
sandbox = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
with acquired_lock:
assert sandbox.id not in acquired_ids
acquired_ids.add(sandbox.id)
result = sandbox.commands.run(f"echo py-pool-concurrent-{index}")
assert result.error is None
except BaseException as exc:
errors.append(exc)
raise
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(worker, i) for i in range(4)]
start.set()
for future in as_completed(futures, timeout=180):
future.result()
assert not errors
assert len(acquired_ids) == 4
assert _count_tagged_sandboxes(self.manager, self.tag) <= 8
# Race acquire and graceful shutdown. POOL_NOT_RUNNING is the expected rejected path.
self.pool.resize(1)
self.pool.start()
_eventually("pool rewarmed before shutdown race", lambda: self.pool.snapshot().idle_count >= 1)
race_errors: list[BaseException] = []
start.clear()
def acquire_during_shutdown() -> None:
try:
start.wait()
sandbox = self.pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
except PoolNotRunningException:
return
except BaseException as exc:
race_errors.append(exc)
raise
def shutdown_during_acquire() -> None:
start.wait()
self.pool.shutdown(True)
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(acquire_during_shutdown) for _ in range(4)]
futures.append(executor.submit(shutdown_during_acquire))
start.set()
for future in as_completed(futures, timeout=180):
future.result()
assert not race_errors
@pytest.mark.timeout(360)
def test_concurrent_start_shutdown_stress_single_node(self) -> None:
errors: list[BaseException] = []
start = threading.Event()
def worker(index: int) -> None:
try:
start.wait()
for _ in range(3):
if index % 2 == 0:
self.pool.start()
else:
self.pool.shutdown(index % 3 == 0)
time.sleep(0.05)
except BaseException as exc:
errors.append(exc)
raise
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(worker, i) for i in range(4)]
start.set()
for future in as_completed(futures, timeout=180):
future.result()
assert not errors
self.pool.start()
_eventually("pool remains usable after lifecycle stress", lambda: self.pool.snapshot().idle_count >= 1)
@pytest.mark.timeout(300)
def test_warmup_preparer_and_pool_isolation(self) -> None:
_cleanup_pool(self.pool)
marker_path = f"/tmp/{self.tag}-prepared.txt"
def preparer(sandbox: SandboxSync) -> None:
result = sandbox.commands.run(f"printf prepared > {marker_path}")
assert result.error is None
prepared_pool = _create_pool(
pool_name=f"prepared-{self.pool_name}",
owner_id=f"prepared-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=self.tag,
max_idle=1,
warmup_sandbox_preparer=preparer,
)
other_tag = _tag("py-pool-other")
other_pool = _create_pool(
pool_name=f"pool-{other_tag}",
owner_id=f"owner-{other_tag}",
state_store=InMemoryPoolStateStore(),
tag=other_tag,
max_idle=1,
)
other_manager = SandboxManagerSync.create(create_connection_config_sync())
try:
prepared_pool.start()
_eventually("prepared pool warms", lambda: prepared_pool.snapshot().idle_count >= 1)
sandbox = prepared_pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
result = sandbox.commands.run(f"cat {marker_path}")
assert result.error is None
assert result.logs.stdout[0].text == "prepared"
other_pool.start()
_eventually("other pool warms", lambda: other_pool.snapshot().idle_count >= 1)
assert _count_tagged_sandboxes(self.manager, self.tag) >= 1
assert _count_tagged_sandboxes(other_manager, other_tag) >= 1
prepared_pool.resize(0)
prepared_pool.release_all_idle()
_eventually("prepared pool drains", lambda: prepared_pool.snapshot().idle_count == 0)
assert other_pool.snapshot().idle_count >= 1
finally:
_cleanup_pool(prepared_pool)
_cleanup_pool(other_pool)
_cleanup_tagged_sandboxes(other_manager, other_tag)
other_manager.close()
@pytest.mark.timeout(300)
def test_warmup_concurrency_above_one_reaches_target_and_stays_bounded(self) -> None:
_cleanup_pool(self.pool)
concurrent_tag = _tag("py-pool-warmup-concurrency")
concurrent_pool = _create_pool(
pool_name=f"concurrent-{self.pool_name}",
owner_id=f"concurrent-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=concurrent_tag,
max_idle=3,
warmup_concurrency=2,
)
try:
concurrent_pool.start()
_eventually(
"concurrent warmup fills configured idle target",
lambda: concurrent_pool.snapshot().idle_count >= 3
and _count_tagged_sandboxes(self.manager, concurrent_tag) <= 3,
timeout=timedelta(seconds=90),
)
finally:
_cleanup_pool(concurrent_pool)
_cleanup_tagged_sandboxes(self.manager, concurrent_tag)
@pytest.mark.timeout(240)
def test_broken_connection_degrades_and_healthy_pool_still_works(self) -> None:
_cleanup_pool(self.pool)
bad_tag = _tag("py-pool-bad")
bad_pool = _create_pool(
pool_name=f"bad-{self.pool_name}",
owner_id=f"bad-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=bad_tag,
max_idle=1,
connection_config=_broken_connection_config(),
degraded_threshold=1,
warmup_ready_timeout=timedelta(seconds=1),
acquire_ready_timeout=timedelta(seconds=1),
)
try:
bad_pool.start()
_eventually(
"bad pool enters degraded state",
lambda: bad_pool.snapshot().state == PoolState.DEGRADED,
timeout=timedelta(seconds=60),
interval=timedelta(seconds=1),
)
snapshot = bad_pool.snapshot()
assert snapshot.last_error
assert snapshot.idle_count == 0
with pytest.raises(PoolEmptyException):
bad_pool.acquire(timedelta(minutes=1), AcquirePolicy.FAIL_FAST)
with pytest.raises(Exception):
bad_pool.acquire(timedelta(minutes=1), AcquirePolicy.DIRECT_CREATE)
finally:
_cleanup_pool(bad_pool)
_cleanup_tagged_sandboxes(self.manager, bad_tag)
healthy_tag = _tag("py-pool-good")
healthy_pool = _create_pool(
pool_name=f"healthy-{self.pool_name}",
owner_id=f"healthy-owner-{self.tag}",
state_store=InMemoryPoolStateStore(),
tag=healthy_tag,
max_idle=1,
)
try:
healthy_pool.start()
_eventually(
"healthy pool still warms after broken pool path",
lambda: healthy_pool.snapshot().idle_count >= 1,
)
sandbox = healthy_pool.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
finally:
_cleanup_pool(healthy_pool)
_cleanup_tagged_sandboxes(self.manager, healthy_tag)
@pytest.mark.e2e
class TestSandboxPoolRedisDistributedE2ESync:
"""Redis-backed multi-instance pool E2E scenarios."""
def setup_method(self) -> None:
redis_url = os.getenv("OPENSANDBOX_TEST_REDIS_URL")
if not redis_url:
pytest.skip("Set OPENSANDBOX_TEST_REDIS_URL to run Redis-backed pool E2E tests")
redis_module = pytest.importorskip("redis")
self.redis = redis_module.Redis.from_url(redis_url, decode_responses=True)
self.key_prefix = f"opensandbox:e2e:{uuid.uuid4()}"
self.manager = SandboxManagerSync.create(create_connection_config_sync())
self.borrowed: list[SandboxSync] = []
self.pools: list[SandboxPoolSync] = []
self.tag = _tag("py-pool-redis")
def teardown_method(self) -> None:
_cleanup_borrowed(self.borrowed)
for pool in self.pools:
_cleanup_pool(pool)
_cleanup_tagged_sandboxes(self.manager, self.tag)
self.manager.close()
for key in self.redis.scan_iter(f"{self.key_prefix}:*"):
self.redis.delete(key)
self.redis.close()
@pytest.mark.timeout(360)
def test_redis_cross_node_acquire_shared_resize_and_direct_create(self) -> None:
pool_name = f"redis-pool-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 2)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis pool warms", lambda: pool_a.snapshot().idle_count >= 1)
sandbox = pool_b.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
result = sandbox.commands.run("echo py-redis-dist-ok")
assert result.error is None
pool_b.resize(0)
_eventually("Redis idle drains after shared resize", lambda: pool_a.snapshot().idle_count == 0)
time.sleep(RECONCILE_INTERVAL.total_seconds() * 2)
assert pool_a.snapshot().idle_count == 0
with pytest.raises(PoolEmptyException):
pool_a.acquire(timedelta(minutes=2), AcquirePolicy.FAIL_FAST)
direct = pool_a.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(direct)
assert direct.is_healthy()
direct_result = direct.commands.run("echo py-redis-direct-create-ok")
assert direct_result.error is None
assert pool_a.snapshot().idle_count == 0
@pytest.mark.timeout(420)
def test_redis_primary_failover_restart_and_resize_jitter_stay_bounded(self) -> None:
pool_name = f"redis-failover-{self.tag}"
owner_a = f"owner-a-{self.tag}"
owner_b = f"owner-b-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, owner_a, store_a, self.tag, 1)
pool_b = _create_pool(pool_name, owner_b, store_b, self.tag, 1)
self.pools.extend([pool_a, pool_b])
lock_key = store_a._primary_lock_key(pool_name)
pool_a.start()
_eventually(
"first Redis node owns primary lock and warms",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count >= 1,
)
pool_b.start()
pool_a.shutdown(False)
pool_b.resize(1)
_eventually(
"primary lock fails over to remaining Redis node",
lambda: self.redis.get(lock_key) == owner_b and pool_b.snapshot().idle_count >= 1,
timeout=timedelta(seconds=60),
)
pool_a.start()
for index in range(6):
(pool_a if index % 2 == 0 else pool_b).resize(index % 3)
time.sleep(0.2)
pool_b.resize(1)
_eventually(
"Redis restart and resize jitter converge to configured maxIdle",
lambda: pool_a.snapshot().idle_count <= 1
and _count_tagged_sandboxes(self.manager, self.tag) <= 2,
timeout=timedelta(seconds=60),
)
@pytest.mark.timeout(420)
def test_redis_start_overwrites_stale_shared_max_idle_after_restart(self) -> None:
pool_name = f"redis-restart-config-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 1)
self.pools.append(pool_a)
pool_a.start()
_eventually("initial Redis pool warms", lambda: pool_a.snapshot().idle_count >= 1)
pool_a.resize(0)
_eventually("initial Redis pool drains to zero", lambda: pool_a.snapshot().idle_count == 0)
pool_a.shutdown(False)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.append(pool_b)
pool_b.start()
_eventually(
"restart with same Redis namespace uses new configured max_idle",
lambda: pool_b.snapshot().max_idle == 2 and pool_b.snapshot().idle_count >= 2,
)
@pytest.mark.timeout(420)
def test_redis_secondary_resize_is_applied_by_primary_periodic_reconcile(self) -> None:
pool_name = f"redis-secondary-resize-{self.tag}"
owner_a = f"owner-a-{self.tag}"
owner_b = f"owner-b-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, owner_a, store_a, self.tag, 2)
pool_b = _create_pool(pool_name, owner_b, store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
lock_key = store_a._primary_lock_key(pool_name)
pool_a.start()
_eventually(
"primary Redis node owns lock and warms",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count >= 2,
)
pool_b.start()
pool_b.resize(0)
_eventually(
"secondary resize to zero is applied by primary",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count == 0,
)
pool_b.resize(2)
_eventually(
"secondary resize up is applied by primary",
lambda: self.redis.get(lock_key) == owner_a and pool_a.snapshot().idle_count >= 2,
)
@pytest.mark.timeout(360)
def test_redis_concurrent_cross_node_acquire_and_atomic_take(self) -> None:
pool_name = f"redis-concurrent-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 2)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis pool warms two idle", lambda: pool_a.snapshot().idle_count >= 2)
start = threading.Event()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(lambda: (start.wait(), pool_a.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST))[1]),
executor.submit(lambda: (start.wait(), pool_b.acquire(timedelta(minutes=5), AcquirePolicy.FAIL_FAST))[1]),
]
start.set()
sandboxes = [future.result(timeout=90) for future in futures]
self.borrowed.extend(sandboxes)
ids = {sandbox.id for sandbox in sandboxes}
assert len(ids) == 2
assert all(sandbox.is_healthy() for sandbox in sandboxes)
store = RedisPoolStateStore(self.redis, self.key_prefix)
contention_pool = f"redis-store-contention-{uuid.uuid4()}"
for i in range(50):
store.put_idle(contention_pool, f"id-{i}")
taken: set[str] = set()
taken_lock = threading.Lock()
def take_until_empty() -> None:
while True:
sandbox_id = store.try_take_idle(contention_pool)
if sandbox_id is None:
return
with taken_lock:
assert sandbox_id not in taken
taken.add(sandbox_id)
with ThreadPoolExecutor(max_workers=16) as executor:
futures = [executor.submit(take_until_empty) for _ in range(16)]
for future in as_completed(futures, timeout=30):
future.result()
assert len(taken) == 50
assert store.snapshot_counters(contention_pool).idle_count == 0
@pytest.mark.timeout(60)
def test_redis_expired_idle_is_not_removed_by_snapshot_but_take_reaps_it(self) -> None:
store = RedisPoolStateStore(self.redis, self.key_prefix)
pool_name = f"redis-expired-idle-{self.tag}"
store.set_idle_entry_ttl(pool_name, timedelta(milliseconds=50))
store.put_idle(pool_name, f"expired-{uuid.uuid4().hex}")
time.sleep(0.1)
assert store.snapshot_counters(pool_name).idle_count == 1
assert store.try_take_idle(pool_name) is None
assert store.snapshot_counters(pool_name).idle_count == 0
@pytest.mark.timeout(420)
def test_redis_concurrent_acquire_and_resize_jitter_remain_bounded(self) -> None:
pool_name = f"redis-acquire-resize-jitter-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 2)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 2)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis jitter pool warms two idle", lambda: pool_a.snapshot().idle_count >= 2)
acquired_ids: set[str] = set()
acquired_lock = threading.Lock()
def acquire_once(index: int) -> None:
pool = pool_a if index % 2 == 0 else pool_b
sandbox = pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
with acquired_lock:
assert sandbox.id not in acquired_ids
acquired_ids.add(sandbox.id)
result = sandbox.commands.run(f"echo py-redis-jitter-{index}")
assert result.error is None
def resize_jitter() -> None:
for index in range(8):
(pool_a if index % 2 == 0 else pool_b).resize(index % 3)
time.sleep(0.2)
pool_b.resize(2)
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(acquire_once, i) for i in range(4)]
futures.append(executor.submit(resize_jitter))
for future in as_completed(futures, timeout=180):
future.result()
assert len(acquired_ids) == 4
_eventually(
"Redis acquire plus resize jitter converges and stays bounded",
lambda: pool_a.snapshot().idle_count <= 2
and _count_tagged_sandboxes(self.manager, self.tag) <= 8,
timeout=timedelta(seconds=90),
)
@pytest.mark.timeout(360)
def test_redis_stale_idle_is_removed_and_direct_create_fallback_works(self) -> None:
pool_name = f"redis-stale-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 0)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 0)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
store_a.put_idle(pool_name, f"missing-{uuid.uuid4().hex}")
with pytest.raises(PoolAcquireFailedException):
pool_b.acquire(timedelta(seconds=2), AcquirePolicy.FAIL_FAST)
assert store_a.snapshot_counters(pool_name).idle_count == 0
sandbox = pool_b.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
self.borrowed.append(sandbox)
assert sandbox.is_healthy()
@pytest.mark.timeout(420)
def test_redis_lost_lock_window_discards_orphan_and_recovers(self) -> None:
pool_name = f"redis-renew-window-{self.tag}"
owner = f"owner-a-{self.tag}"
store = RedisPoolStateStore(self.redis, self.key_prefix)
lock_key = store._primary_lock_key(pool_name)
dropped_once = threading.Event()
def drop_lock_once(_: SandboxSync) -> None:
if not dropped_once.is_set():
dropped_once.set()
self.redis.delete(lock_key)
pool = _create_pool(
pool_name,
owner,
store,
self.tag,
1,
warmup_sandbox_preparer=drop_lock_once,
)
self.pools.append(pool)
pool.start()
_eventually(
"Redis pool recovers after losing primary lock during warmup",
lambda: pool.snapshot().idle_count == 1,
timeout=timedelta(seconds=90),
interval=timedelta(milliseconds=500),
)
_eventually(
"lost-lock orphan cleanup keeps remote count bounded",
lambda: _count_tagged_sandboxes(self.manager, self.tag) == 1,
timeout=timedelta(seconds=60),
)
@pytest.mark.timeout(420)
def test_redis_destroy_tombstone_blocks_all_nodes_and_direct_create(self) -> None:
pool_name = f"redis-destroy-{self.tag}"
store_a = RedisPoolStateStore(self.redis, self.key_prefix)
store_b = RedisPoolStateStore(self.redis, self.key_prefix)
pool_a = _create_pool(pool_name, f"owner-a-{self.tag}", store_a, self.tag, 1)
pool_b = _create_pool(pool_name, f"owner-b-{self.tag}", store_b, self.tag, 1)
self.pools.extend([pool_a, pool_b])
pool_a.start()
pool_b.start()
_eventually("Redis destroy pool warms", lambda: pool_a.snapshot().idle_count >= 1)
pool_manager = SandboxPoolManagerSync(
state_store=store_b,
connection_config=create_connection_config_sync(),
owner_id=f"destroyer-{self.tag}",
)
result = pool_manager.destroy(
pool_name,
PoolDestroyOptions(drain_timeout=timedelta(seconds=60)),
)
assert result.state == PoolDestroyState.DESTROYED
assert store_a.get_destroy_state(pool_name) == PoolDestroyState.DESTROYED
with pytest.raises(PoolDestroyedException):
pool_a.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
with pytest.raises(PoolDestroyedException):
pool_b.resize(1)
@pytest.mark.timeout(60)
def test_redis_begin_destroy_fence_blocks_start_and_direct_create(self) -> None:
pool_name = f"redis-destroying-fence-{self.tag}"
store = RedisPoolStateStore(self.redis, self.key_prefix)
store.begin_destroy(pool_name, f"destroyer-{self.tag}")
assert store.get_destroy_state(pool_name) == PoolDestroyState.DESTROYING
pool = _create_pool(pool_name, f"owner-{self.tag}", store, self.tag, 0)
self.pools.append(pool)
with pytest.raises(PoolDestroyedException):
pool.start()
running_pool_name = f"redis-destroying-running-{self.tag}"
running_store = RedisPoolStateStore(self.redis, self.key_prefix)
running_pool = _create_pool(
running_pool_name,
f"owner-running-{self.tag}",
running_store,
self.tag,
0,
)
self.pools.append(running_pool)
running_pool.start()
running_store.begin_destroy(running_pool_name, f"destroyer-{self.tag}")
with pytest.raises(PoolDestroyedException):
running_pool.acquire(timedelta(minutes=5), AcquirePolicy.DIRECT_CREATE)
def _create_pool(
pool_name: str,
owner_id: str,
state_store: PoolStateStore,
tag: str,
max_idle: int,
warmup_sandbox_preparer: Callable[[SandboxSync], None] | None = None,
connection_config: ConnectionConfigSync | None = None,
degraded_threshold: int = 3,
warmup_ready_timeout: timedelta = timedelta(seconds=30),
acquire_ready_timeout: timedelta = timedelta(seconds=30),
primary_lock_ttl: timedelta = PRIMARY_LOCK_TTL,
reconcile_interval: timedelta = RECONCILE_INTERVAL,
warmup_concurrency: int = 1,
) -> SandboxPoolSync:
return SandboxPoolSync(
pool_name=pool_name,
owner_id=owner_id,
max_idle=max_idle,
warmup_concurrency=warmup_concurrency,
state_store=state_store,
connection_config=connection_config or create_connection_config_sync(),
creation_spec=PoolCreationSpec(
image=get_sandbox_image(),
entrypoint=["tail", "-f", "/dev/null"],
metadata={"tag": tag, "suite": "sandbox-pool-python-e2e"},
env={
"E2E_TEST": "true",
"EXECD_API_GRACE_SHUTDOWN": "3s",
"EXECD_JUPYTER_IDLE_POLL_INTERVAL": "1s",
},
resource=get_e2e_sandbox_resource(),
),
reconcile_interval=reconcile_interval,
primary_lock_ttl=primary_lock_ttl,
drain_timeout=DRAIN_TIMEOUT,
warmup_sandbox_preparer=warmup_sandbox_preparer,
degraded_threshold=degraded_threshold,
warmup_ready_timeout=warmup_ready_timeout,
acquire_ready_timeout=acquire_ready_timeout,
)
def _broken_connection_config() -> ConnectionConfigSync:
return ConnectionConfigSync(
domain="127.0.0.1:9",
api_key="broken-e2e-test",
request_timeout=timedelta(seconds=1),
transport=httpx.HTTPTransport(
limits=httpx.Limits(max_connections=2, max_keepalive_connections=0)
),
)
def _eventually(
description: str,
condition: Callable[[], bool],
timeout: timedelta = AWAIT_TIMEOUT,
interval: timedelta = timedelta(seconds=1),
) -> None:
deadline = time.monotonic() + timeout.total_seconds()
last_error: BaseException | None = None
while time.monotonic() < deadline:
try:
if condition():
return
except BaseException as exc:
last_error = exc
time.sleep(interval.total_seconds())
if last_error is not None:
raise AssertionError(f"Timed out waiting for {description}") from last_error
raise AssertionError(f"Timed out waiting for {description}")
def _cleanup_pool(pool: SandboxPoolSync) -> None:
try:
pool.resize(0)
except Exception:
pass
try:
pool.release_all_idle()
except Exception:
pass
try:
pool.shutdown(False)
except Exception:
pass
def _cleanup_borrowed(sandboxes: list[SandboxSync]) -> None:
for sandbox in sandboxes:
try:
sandbox.kill()
except Exception:
pass
try:
sandbox.close()
except Exception:
pass
sandboxes.clear()
def _cleanup_tagged_sandboxes(manager: SandboxManagerSync, tag: str) -> None:
for _ in range(5):
try:
infos = manager.list_sandbox_infos(
SandboxFilter(metadata={"tag": tag}, page_size=50)
)
if not infos.sandbox_infos:
return
for info in infos.sandbox_infos:
try:
manager.kill_sandbox(info.id)
except Exception:
pass
except Exception:
return
def _count_tagged_sandboxes(manager: SandboxManagerSync, tag: str) -> int:
infos = manager.list_sandbox_infos(SandboxFilter(metadata={"tag": tag}, page_size=50))
return len(infos.sandbox_infos)
def _tag(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"