2026-02-27 11:59:16 +01:00
|
|
|
"""Scheduler unit tests — Plan 03."""
|
2026-02-27 12:34:32 +01:00
|
|
|
|
|
|
|
|
from nix_builder_autoscaler.config import AppConfig, AwsConfig, CapacityConfig
|
|
|
|
|
from nix_builder_autoscaler.metrics import MetricsRegistry
|
|
|
|
|
from nix_builder_autoscaler.models import ReservationPhase, SlotState
|
|
|
|
|
from nix_builder_autoscaler.providers.clock import FakeClock
|
|
|
|
|
from nix_builder_autoscaler.runtime.fake import FakeRuntime
|
|
|
|
|
from nix_builder_autoscaler.scheduler import scheduling_tick
|
|
|
|
|
from nix_builder_autoscaler.state_db import StateDB
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_env(
|
|
|
|
|
slot_count=3,
|
|
|
|
|
max_slots=3,
|
|
|
|
|
max_leases=1,
|
|
|
|
|
idle_scale_down_seconds=900,
|
|
|
|
|
target_warm=0,
|
|
|
|
|
min_slots=0,
|
|
|
|
|
):
|
|
|
|
|
clock = FakeClock()
|
|
|
|
|
db = StateDB(":memory:", clock=clock)
|
|
|
|
|
db.init_schema()
|
|
|
|
|
db.init_slots("slot", slot_count, "x86_64-linux", "all")
|
|
|
|
|
runtime = FakeRuntime(launch_latency_ticks=2, ip_delay_ticks=1)
|
|
|
|
|
config = AppConfig(
|
|
|
|
|
capacity=CapacityConfig(
|
|
|
|
|
max_slots=max_slots,
|
|
|
|
|
max_leases_per_slot=max_leases,
|
|
|
|
|
idle_scale_down_seconds=idle_scale_down_seconds,
|
|
|
|
|
target_warm_slots=target_warm,
|
|
|
|
|
min_slots=min_slots,
|
|
|
|
|
reservation_ttl_seconds=1200,
|
|
|
|
|
),
|
|
|
|
|
aws=AwsConfig(region="us-east-1"),
|
|
|
|
|
)
|
|
|
|
|
metrics = MetricsRegistry()
|
|
|
|
|
return db, runtime, config, clock, metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_slot_ready(db, slot_id, instance_id="i-test1", ip="100.64.0.1"):
|
|
|
|
|
"""Transition a slot through the full state machine to ready."""
|
|
|
|
|
db.update_slot_state(slot_id, SlotState.LAUNCHING, instance_id=instance_id)
|
|
|
|
|
db.update_slot_state(slot_id, SlotState.BOOTING)
|
|
|
|
|
db.update_slot_state(slot_id, SlotState.BINDING, instance_ip=ip)
|
|
|
|
|
db.update_slot_state(slot_id, SlotState.READY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Test cases ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pending_reservation_assigned_to_ready_slot():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env()
|
|
|
|
|
_make_slot_ready(db, "slot001")
|
|
|
|
|
|
|
|
|
|
resv = db.create_reservation("x86_64-linux", "test", None, 1200)
|
|
|
|
|
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
updated = db.get_reservation(resv["reservation_id"])
|
|
|
|
|
assert updated["phase"] == ReservationPhase.READY.value
|
|
|
|
|
assert updated["slot_id"] == "slot001"
|
|
|
|
|
assert updated["instance_id"] == "i-test1"
|
|
|
|
|
|
|
|
|
|
slot = db.get_slot("slot001")
|
|
|
|
|
assert slot["lease_count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_pending_one_slot_only_one_assigned_per_tick():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env(max_leases=1)
|
|
|
|
|
_make_slot_ready(db, "slot001")
|
|
|
|
|
|
|
|
|
|
r1 = db.create_reservation("x86_64-linux", "test1", None, 1200)
|
|
|
|
|
r2 = db.create_reservation("x86_64-linux", "test2", None, 1200)
|
|
|
|
|
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
u1 = db.get_reservation(r1["reservation_id"])
|
|
|
|
|
u2 = db.get_reservation(r2["reservation_id"])
|
|
|
|
|
|
|
|
|
|
ready_count = sum(1 for r in [u1, u2] if r["phase"] == ReservationPhase.READY.value)
|
|
|
|
|
pending_count = sum(1 for r in [u1, u2] if r["phase"] == ReservationPhase.PENDING.value)
|
|
|
|
|
assert ready_count == 1
|
|
|
|
|
assert pending_count == 1
|
|
|
|
|
|
|
|
|
|
slot = db.get_slot("slot001")
|
|
|
|
|
assert slot["lease_count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reservation_expires_when_ttl_passes():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env()
|
|
|
|
|
config.capacity.reservation_ttl_seconds = 60
|
|
|
|
|
|
|
|
|
|
db.create_reservation("x86_64-linux", "test", None, 60)
|
|
|
|
|
|
|
|
|
|
clock.advance(61)
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
reservations = db.list_reservations(ReservationPhase.EXPIRED)
|
|
|
|
|
assert len(reservations) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scale_down_starts_when_idle_exceeds_threshold():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env(idle_scale_down_seconds=900)
|
|
|
|
|
_make_slot_ready(db, "slot001")
|
|
|
|
|
|
|
|
|
|
clock.advance(901)
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
slot = db.get_slot("slot001")
|
|
|
|
|
assert slot["state"] == SlotState.DRAINING.value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_slot_does_not_drain_while_lease_count_positive():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env(idle_scale_down_seconds=900)
|
|
|
|
|
_make_slot_ready(db, "slot001")
|
|
|
|
|
|
|
|
|
|
resv = db.create_reservation("x86_64-linux", "test", None, 1200)
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
# Confirm assigned
|
|
|
|
|
updated = db.get_reservation(resv["reservation_id"])
|
|
|
|
|
assert updated["phase"] == ReservationPhase.READY.value
|
|
|
|
|
|
|
|
|
|
clock.advance(901)
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
slot = db.get_slot("slot001")
|
|
|
|
|
assert slot["state"] == SlotState.READY.value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_interruption_pending_slot_moves_to_draining():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env()
|
|
|
|
|
_make_slot_ready(db, "slot001")
|
|
|
|
|
|
|
|
|
|
db.update_slot_fields("slot001", interruption_pending=1)
|
|
|
|
|
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
slot = db.get_slot("slot001")
|
|
|
|
|
assert slot["state"] == SlotState.DRAINING.value
|
|
|
|
|
assert slot["interruption_pending"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_launch_triggered_for_unmet_demand():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env()
|
|
|
|
|
|
|
|
|
|
db.create_reservation("x86_64-linux", "test", None, 1200)
|
|
|
|
|
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
launching = db.list_slots(SlotState.LAUNCHING)
|
|
|
|
|
assert len(launching) == 1
|
|
|
|
|
assert launching[0]["instance_id"] is not None
|
|
|
|
|
|
|
|
|
|
# FakeRuntime should have one pending instance
|
|
|
|
|
managed = runtime.list_managed_instances()
|
|
|
|
|
assert len(managed) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_launch_respects_max_slots():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env(max_slots=1)
|
|
|
|
|
_make_slot_ready(db, "slot001")
|
|
|
|
|
|
|
|
|
|
# Slot001 is at capacity (lease_count will be 1 after assignment)
|
|
|
|
|
db.create_reservation("x86_64-linux", "test1", None, 1200)
|
|
|
|
|
db.create_reservation("x86_64-linux", "test2", None, 1200)
|
|
|
|
|
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
# One reservation assigned, one still pending — but no new launch
|
|
|
|
|
# because active_slots (1) == max_slots (1)
|
|
|
|
|
launching = db.list_slots(SlotState.LAUNCHING)
|
|
|
|
|
assert len(launching) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_min_slots_maintained():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env(min_slots=1)
|
|
|
|
|
|
|
|
|
|
# No reservations, all slots empty
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
launching = db.list_slots(SlotState.LAUNCHING)
|
|
|
|
|
assert len(launching) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scale_down_respects_min_slots():
|
|
|
|
|
db, runtime, config, clock, metrics = _make_env(min_slots=1, idle_scale_down_seconds=900)
|
|
|
|
|
_make_slot_ready(db, "slot001")
|
|
|
|
|
|
|
|
|
|
clock.advance(901)
|
|
|
|
|
scheduling_tick(db, runtime, config, clock, metrics)
|
|
|
|
|
|
|
|
|
|
slot = db.get_slot("slot001")
|
|
|
|
|
assert slot["state"] == SlotState.READY.value
|