Phase 1: graph + scheduler tests
Browse files
tests/unit/test_graph_scheduler.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from spi.compiler.production_graph import CycleError, ProductionGraph
|
| 4 |
+
from spi.renderers.base import RendererError, request_hash
|
| 5 |
+
from spi.renderers.mock import MockVideoRenderer
|
| 6 |
+
from spi.scheduler.scheduler import Scheduler
|
| 7 |
+
from spi.schemas.production import GenerationJob
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _job(jid, deps=None, priority=5):
|
| 11 |
+
return GenerationJob(job_id=jid, depends_on=deps or [], priority=priority,
|
| 12 |
+
request={"prompt": jid})
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_topo_respects_deps_and_priority():
|
| 16 |
+
g = ProductionGraph([_job("A"), _job("B"), _job("C", deps=["A"], priority=1),
|
| 17 |
+
_job("D", deps=["A", "C"])])
|
| 18 |
+
order = g.topo_order()
|
| 19 |
+
assert order.index("A") < order.index("C") < order.index("D")
|
| 20 |
+
assert order.index("A") < order.index("B")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_cycle_detected():
|
| 24 |
+
g = ProductionGraph([_job("A"), _job("B"), _job("C")])
|
| 25 |
+
g.add_edge("A", "B")
|
| 26 |
+
g.add_edge("B", "C")
|
| 27 |
+
g.add_edge("C", "A")
|
| 28 |
+
with pytest.raises(CycleError):
|
| 29 |
+
g.topo_order()
|
| 30 |
+
assert g.validate() != []
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_unknown_dependency_rejected():
|
| 34 |
+
g = ProductionGraph([_job("A")])
|
| 35 |
+
g.jobs["A"].depends_on.append("GHOST")
|
| 36 |
+
with pytest.raises(ValueError):
|
| 37 |
+
g.validate()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_ready_jobs_ordering():
|
| 41 |
+
g = ProductionGraph([_job("low", priority=9), _job("high", priority=1)])
|
| 42 |
+
assert g.ready_jobs()[0] == "high"
|
| 43 |
+
g.mark("high", "done")
|
| 44 |
+
assert g.ready_jobs() == ["low"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_request_hash_stable():
|
| 48 |
+
r1 = {"prompt": "x", "seed": 1}
|
| 49 |
+
r2 = {"seed": 1, "prompt": "x"}
|
| 50 |
+
assert request_hash(r1) == request_hash(r2)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_mock_renderer_caches_identical_requests():
|
| 54 |
+
r = MockVideoRenderer()
|
| 55 |
+
a1 = r.generate({"prompt": "same", "num_frames": 48, "seed": 7})
|
| 56 |
+
a2 = r.generate({"prompt": "same", "seed": 1, "num_frames": 48}) # same canonical dict
|
| 57 |
+
assert a1["request_hash"] == a2["request_hash"]
|
| 58 |
+
assert a1["cached"] is False and a2["cached"] is True
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_scheduler_executes_all_and_records():
|
| 62 |
+
g = ProductionGraph([_job("A"), _job("B", deps=["A"])])
|
| 63 |
+
summary = Scheduler(g, MockVideoRenderer()).run()
|
| 64 |
+
assert summary["done"] == 2 and summary["failed"] == 0
|
| 65 |
+
assert all(j.status == "done" for j in g.jobs.values())
|
| 66 |
+
assert all(j.runtime_s is not None for j in g.jobs.values())
|
| 67 |
+
assert g.jobs["A"].artifact_ref.startswith("mock://video/")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_scheduler_retries_then_fails():
|
| 71 |
+
class ExplodingRenderer(MockVideoRenderer):
|
| 72 |
+
def generate(self, request):
|
| 73 |
+
raise RendererError("simulated OOM")
|
| 74 |
+
|
| 75 |
+
g = ProductionGraph([_job("A")])
|
| 76 |
+
summary = Scheduler(g, ExplodingRenderer(), max_retries=2).run()
|
| 77 |
+
assert summary["failed"] == 1
|
| 78 |
+
assert g.jobs["A"].status == "failed"
|
| 79 |
+
assert g.jobs["A"].retries == 3 # 2 retries + initial attempt
|
| 80 |
+
assert "OOM" in g.jobs["A"].error
|