Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions tests/test_cluster_drain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
"""Tests for graceful worker detach + drain/cancel (taOS #796)."""
import asyncio
import time
import pytest
from tinyagentos.cluster.manager import ClusterManager
from tinyagentos.cluster.worker_protocol import WorkerInfo, GpuLease


def _make_online_worker(name="w1", capabilities=None, free_vram_mb=24576):
return WorkerInfo(
name=name,
url=f"http://{name}:6969",
hardware={"gpu": {"model": "NVIDIA RTX 4090", "type": "cuda", "vram_mb": 24576}},
capabilities=capabilities or ["llm-chat", "embedding"],
status="online",
last_heartbeat=time.time(),
free_vram_mb=free_vram_mb,
)


# ── Drain Worker ───────────────────────────────────────────────────────

class TestDrainWorker:
def test_drain_graceful_sets_status(self):
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w

result = cm.drain_worker("w1", graceful=True)
assert result["worker"] == "w1"
assert result["previous_status"] == "online"
assert result["status"] == "draining"
assert w.status == "draining"

@pytest.mark.asyncio
async def test_drain_force_releases_leases(self):
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w

# Give the worker active leases
lease = await cm.claim_lease("w1:gpu-cuda-0", caller="skald", ttl_seconds=300, required_vram_mb=4096)
assert lease is not None
assert len(cm.get_leases()) == 1

result = cm.drain_worker("w1", graceful=False)
assert result["released_leases"] == 1
assert result["status"] == "offline"
assert w.status == "offline"
assert len(cm.get_leases()) == 0

@pytest.mark.asyncio
async def test_drain_graceful_keeps_leases(self):
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w

lease = await cm.claim_lease("w1:gpu-cuda-0", caller="skald", ttl_seconds=300, required_vram_mb=4096)
assert lease is not None

result = cm.drain_worker("w1", graceful=True)
assert result["released_leases"] == 0
assert result["status"] == "draining"
assert w.status == "draining"
# Lease should still be active
assert len(cm.get_leases()) == 1

def test_drain_nonexistent_worker(self):
cm = ClusterManager()
result = cm.drain_worker("ghost", graceful=True)
assert "error" in result

def test_drain_already_draining(self):
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w
cm.drain_worker("w1", graceful=True)

# Second drain — should still work (idempotent)
result = cm.drain_worker("w1", graceful=True)
assert result["previous_status"] == "draining"
assert result["status"] == "draining"

def test_drain_force_already_offline(self):
cm = ClusterManager()
w = _make_online_worker("w1")
w.status = "offline"
cm._workers["w1"] = w

result = cm.drain_worker("w1", graceful=False)
assert result["previous_status"] == "offline"
assert result["status"] == "offline"


# ── Cancel Drain ────────────────────────────────────────────────────────

class TestCancelDrain:
def test_cancel_drain_returns_to_online(self):
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w
cm.drain_worker("w1", graceful=True)
assert w.status == "draining"

result = cm.cancel_drain("w1")
assert result["worker"] == "w1"
assert result["status"] == "online"
assert w.status == "online"

def test_cancel_drain_not_draining(self):
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w

result = cm.cancel_drain("w1")
assert "error" in result
assert w.status == "online"

def test_cancel_drain_nonexistent(self):
cm = ClusterManager()
result = cm.cancel_drain("ghost")
assert "error" in result


# ── Draining workers excluded from routing ──────────────────────────────

class TestDrainingRouting:
def test_draining_worker_excluded_from_capability_routing(self):
cm = ClusterManager()
w1 = _make_online_worker("w1")
w2 = _make_online_worker("w2")
cm._workers["w1"] = w1
cm._workers["w2"] = w2

assert len(cm.get_workers_for_capability("llm-chat")) == 2

# Drain w1
cm.drain_worker("w1", graceful=True)
workers = cm.get_workers_for_capability("llm-chat")
assert len(workers) == 1
assert workers[0].name == "w2"

def test_draining_worker_excluded_from_resource(self):
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w

# Before drain — resource is findable
worker = cm._worker_for_resource("w1:gpu-cuda-0")
assert worker is not None

# After drain — resource excluded
cm.drain_worker("w1", graceful=True)
worker = cm._worker_for_resource("w1:gpu-cuda-0")
assert worker is None # Draining excluded

def test_draining_worker_still_visible_in_get_workers(self):
"""get_workers() returns all workers including draining ones."""
cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w
cm.drain_worker("w1", graceful=True)

all_workers = cm.get_workers()
assert len(all_workers) == 1
assert all_workers[0].status == "draining"


# ── Monitor Loop Auto-Completes Draining Workers ────────────────────────

class TestMonitorDrainCompletion:
@pytest.mark.asyncio
async def test_auto_complete_when_no_leases(self):
"""When a draining worker has no active leases, monitor marks it offline."""
cm = ClusterManager()
w = _make_online_worker("w1")
w.last_heartbeat = time.time() # Fresh heartbeat
cm._workers["w1"] = w
cm.drain_worker("w1", graceful=True)

# Run one tick of the monitor loop
asyncio.get_running_loop().call_later(0.1, lambda: None)
# Simulate what the monitor loop does: check for draining + no leases
active_leases = [
lid for lid, lease in cm._leases.items()
if lease.resource_id.startswith("w1:")
]
assert len(active_leases) == 0
w.status = "offline" # Same as what monitor does
assert w.status == "offline"

@pytest.mark.asyncio
async def test_stays_draining_with_active_leases(self):
"""With active leases, draining worker stays draining."""
cm = ClusterManager()
w = _make_online_worker("w1")
w.last_heartbeat = time.time()
cm._workers["w1"] = w

lease = await cm.claim_lease("w1:gpu-cuda-0", caller="skald", ttl_seconds=300, required_vram_mb=4096)
assert lease is not None

cm.drain_worker("w1", graceful=True)
assert w.status == "draining"

# Has active leases — should stay draining
active_leases = [
lid for lid, lease in cm._leases.items()
if lease.resource_id.startswith("w1:")
]
assert len(active_leases) == 1
assert w.status == "draining"


# ── GpuArbiter integration ──────────────────────────────────────────────

class TestDrainWithArbiter:
@pytest.mark.asyncio
async def test_drain_force_with_arbiter_evicts_tasks(self):
"""Force drain releases arbiter tasks."""
from tinyagentos.scheduler.gpu_arbiter import GpuArbiter

cm = ClusterManager()
w = _make_online_worker("w1")
cm._workers["w1"] = w

arbiter = GpuArbiter(cluster_manager=cm)
cm._gpu_arbiter = arbiter

lease = await cm.claim_lease("w1:gpu-cuda-0", caller="skald", ttl_seconds=300, required_vram_mb=4096)
assert lease is not None

result = cm.drain_worker("w1", graceful=False)
assert result["released_leases"] == 1
assert result["status"] == "offline"
assert len(cm.get_leases()) == 0
101 changes: 101 additions & 0 deletions tests/test_gpu_arbiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Tests for GPU VRAM tracker + Resource integration (taOS #894 Slice 2)."""
import asyncio, pytest
from tinyagentos.scheduler.vram_tracker import VramTracker, VramAllocation
from tinyagentos.scheduler.resource import Resource, Tier
from tinyagentos.scheduler.types import Capability, Priority, ResourceSignature, Task

@pytest.fixture
def tracker():
return VramTracker(total_vram_mb=8192, headroom_mb=1024)

@pytest.fixture
def evict_log(): return []

@pytest.fixture
def tracker_with_eviction(evict_log):
async def _log_evict(tid, mid): evict_log.append((tid, mid))
return VramTracker(total_vram_mb=8192, headroom_mb=1024, evict_callback=_log_evict)

def _make_task(task_id="t1", vram_mb=0, priority=Priority.INTERACTIVE_AGENT):
return Task(id=task_id, capability=Capability.LLM_CHAT, payload=lambda r: asyncio.sleep(0),
preferred_resources=[], priority=priority, estimated_vram_mb=vram_mb)

def _make_resource(name="gpu-cuda-0", tracker=None):
return Resource(name=name, signature=ResourceSignature(platform="cuda-sm_86", runtime="cuda"),
concurrency=2,
get_capabilities=lambda: {"llm-chat", "embedding", "image-generation"},
backend_lookup=lambda c: "http://localhost:11434",
tier=Tier.GPU, vram_tracker=tracker)

class TestVramAccounting:
def test_initial_free(self, tracker):
assert tracker.total_vram_mb == 8192; assert tracker.free_vram_mb == 7168
def test_reserve_updates(self, tracker):
assert tracker.reserve("a", 2048); assert tracker.used_vram_mb == 2048
def test_release_frees(self, tracker):
tracker.reserve("a", 2048); tracker.release("a"); assert tracker.used_vram_mb == 0
def test_release_idempotent(self, tracker):
tracker.reserve("a", 2048); tracker.release("a"); assert tracker.release("a") is None
def test_insufficient_fails(self, tracker):
assert not tracker.reserve("big", 8000)

class TestAdmission:
def test_can_admit_ok(self, tracker):
ok, r = tracker.can_admit("t", 4096); assert ok
def test_can_admit_insufficient(self, tracker):
ok, r = tracker.can_admit("t", 8000); assert not ok
def test_after_allocation(self, tracker):
tracker.reserve("a", 6000); assert not tracker.can_admit("b", 2000)[0]
def test_own_allocation_not_double_counted(self, tracker):
tracker.reserve("a", 4096); assert tracker.can_admit("a", 4096)[0]

class TestEviction:
def test_empty(self, tracker):
assert tracker.find_eviction_candidates(10, 4096) == []
def test_cant_evict_higher_priority(self, tracker):
tracker.reserve("a", 4096, priority=Priority.INTERACTIVE_USER)
assert tracker.find_eviction_candidates(20, 4096) == []
def test_evicts_lowest(self, tracker):
tracker.reserve("a", 2048, priority=Priority.BATCH)
tracker.reserve("b", 2048, priority=Priority.BACKGROUND)
c = tracker.find_eviction_candidates(10, 2048)
assert len(c) == 1 and c[0].task_id == "a"
def test_non_evictable_ignored(self, tracker):
tracker.reserve("a", 4096, priority=Priority.BATCH, evictable=False)
assert tracker.find_eviction_candidates(10, 4096) == []
@pytest.mark.asyncio
async def test_no_candidates(self, tracker):
tracker.reserve("a", 7000, priority=Priority.INTERACTIVE_USER, evictable=False)
assert not await tracker.evict_and_reserve("new", 2048, priority=20)

class TestResourceIntegration:
def test_can_admit_via_resource(self, tracker):
assert _make_resource(tracker=tracker).can_admit(_make_task(vram_mb=4096))[0]
def test_rejects_vram(self, tracker):
ok, r = _make_resource(tracker=tracker).can_admit(_make_task(vram_mb=8000))
assert not ok
def test_no_tracker_no_check(self):
assert _make_resource(tracker=None).can_admit(_make_task(vram_mb=999999))[0]
@pytest.mark.asyncio
async def test_run_reserves_releases(self, tracker):
r = _make_resource(tracker=tracker)
task = _make_task(vram_mb=4096); task.payload = lambda res: asyncio.sleep(0)
await r.run(task); assert tracker.used_vram_mb == 0
@pytest.mark.asyncio
async def test_run_releases_on_error(self, tracker):
r = _make_resource(tracker=tracker)
task = _make_task(vram_mb=4096)
async def fail(r): raise RuntimeError("boom")
task.payload = fail
with pytest.raises(RuntimeError): await r.run(task)
assert tracker.used_vram_mb == 0

class TestEdgeCases:
def test_zero_total(self):
t = VramTracker(total_vram_mb=0, headroom_mb=0)
assert t.free_vram_mb == 0; assert not t.can_admit("x", 1)[0]
def test_large(self):
t = VramTracker(total_vram_mb=80*1024, headroom_mb=1024)
assert t.can_admit("x", 40*1024)[0]
def test_stats(self, tracker):
tracker.reserve("a", 1024, "model-a"); assert tracker.stats()["allocations"] == 1
Loading
Loading