Source code for app.orchestrator.fair_share
"""Fair-share scheduler.
Picks the next ready Task across all tenants, biased toward tenants
with fewer recent admissions. The interleaving rule:
Pick the tenant with the *smallest* count of currently-running tasks;
break ties by the tenant whose last-admitted task is oldest. Within a
tenant, pick by Task.created_at (FIFO).
A simpler invariant we expose for testing:
`max_consecutive_jobs_per_tenant`: never admit more than N consecutive
Tasks from the same tenant when other tenants have ready work.
This is intentionally a *picker* — it does not enqueue. The supervisor
calls `pick_next_task(...)` between subprocess slots; in tests we use
the picker directly to validate fairness.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Task
[docs]
@dataclass
class FairShareState:
max_consecutive_per_tenant: int = 2
recent_tenants: deque[str] = field(default_factory=deque)
[docs]
def admit(self, tenant_id: str) -> None:
self.recent_tenants.append(tenant_id)
# Keep window bounded — only need the last N for the invariant.
while len(self.recent_tenants) > 64:
self.recent_tenants.popleft()
[docs]
def consecutive_for(self, tenant_id: str) -> int:
c = 0
for t in reversed(self.recent_tenants):
if t == tenant_id:
c += 1
else:
break
return c
[docs]
async def pick_next_task(session: AsyncSession, *, state: FairShareState) -> Task | None:
"""Find the highest-priority ready Task that doesn't violate
`max_consecutive_per_tenant`. Ready = pending + deps satisfied.
For Phase 5 v1 we treat 'ready' as `pending` with no remaining deps;
dep satisfaction is a precondition the orchestrator already enforces
when emitting tasks. Tenants are weighted by current running count.
"""
rows = (
(
await session.execute(
select(Task).where(Task.status == "pending").order_by(Task.created_at)
)
)
.scalars()
.all()
)
if not rows:
return None
# Group by tenant; compute current running count per tenant.
running = (
await session.execute(select(Task.tenant_id, Task.task_id).where(Task.status == "running"))
).all()
running_by_tenant: dict[str, int] = {}
for tid, _ in running:
running_by_tenant[tid] = running_by_tenant.get(tid, 0) + 1
distinct_pending_tenants = {r.tenant_id for r in rows}
blocked_by_consecutive: set[str] = set()
if len(distinct_pending_tenants) > 1:
for tid in distinct_pending_tenants:
if state.consecutive_for(tid) >= state.max_consecutive_per_tenant:
blocked_by_consecutive.add(tid)
# Among non-blocked tenants, pick the one with the smallest running count.
best: Task | None = None
best_running = float("inf")
for r in rows:
if r.tenant_id in blocked_by_consecutive:
continue
rc = running_by_tenant.get(r.tenant_id, 0)
if rc < best_running:
best = r
best_running = rc
# If everyone was blocked by the consecutive rule, fall back to any.
if best is None:
best = rows[0]
state.admit(best.tenant_id)
return best