# SLA-Weighted Fair Scheduling

**Date:** 2026-03-26
**Status:** In Progress
**Last revised:** 2026-03-26

## Overview

The current claim query orders batches by `(user_active_count ASC, expires_at ASC)` — giving all users equal share regardless of SLA tier. A user with a 1-hour SLA batch (higher price) competes equally with a 24-hour SLA user. The business requirement is that shorter-SLA users should get proportionally more model capacity, controlled by a configurable weight.

Three approaches were considered: time bins, weighted urgency, and async/urgent regions. After analysis, a **continuous weighted score** is the safest approach — it requires minimal SQL changes, no new CTEs, no new round trips, and degrades gracefully to current behavior when the weight is 0.

## Approach: Composite Score in ORDER BY

Replace the current two-column sort with a single blended score:

```
score = (1 - urgency_weight) * normalized_active_count + urgency_weight * urgency
```

Where:
- `normalized_active_count` = `active_count / max(all_active_counts)` → range [0, 1]
- `urgency` = `seconds_remaining / 86400` capped at 1.0 → range [0, 1] (more time = higher score = lower priority)
- `urgency_weight` = new config param, range [0, 1], default 0.0

**Behavior at extremes:**
- `urgency_weight = 0.0` → pure user-fairness (current behavior, score is monotonic with active_count)
- `urgency_weight = 1.0` → pure deadline urgency
- `urgency_weight = 0.3` → a 1hr SLA batch from a busy user can jump ahead of a 24hr batch from an idle user

**Why not the other approaches:**
- **Time bins**: Requires discretization boundaries, more complex CTE, hard edges at bin boundaries
- **Async/urgent regions**: Too binary — a batch at 3h01m vs 2h59m gets dramatically different treatment
- **The continuous formula** provides smooth urgency escalation with a single tuning knob

## Implementation

### 1. `src/daemon/mod.rs` — Add `urgency_weight` to DaemonConfig

New field on `DaemonConfig`:

```rust
/// Weight controlling how much SLA urgency influences claim scheduling (0.0–1.0).
///
/// Blends per-user fairness with batch deadline urgency when ordering claims:
/// - `0.0`: Pure user-fairness (users with fewer in-flight requests go first)
/// - `1.0`: Pure deadline urgency (batches closest to expiry go first)
/// - `0.3`: Recommended starting point
///
/// Default: `0.0` (backward-compatible, pure user-fairness).
#[serde(default)]
pub urgency_weight: f64,
```

Default: `urgency_weight: 0.0`.

### 2. `src/manager/postgres.rs` — Modify claim SQL ORDER BY

Current ORDER BY:
```sql
ORDER BY COALESCE(up.active_count, 0) ASC, ab.expires_at ASC, ab.id ASC
```

New ORDER BY:
```sql
ORDER BY
    (1.0 - $8::DOUBLE PRECISION)
        * COALESCE(up.active_count, 0)::DOUBLE PRECISION
        / GREATEST(NULLIF((SELECT MAX(v) FROM unnest($7::BIGINT[]) v), 0), 1)::DOUBLE PRECISION
    + $8::DOUBLE PRECISION
        * LEAST(GREATEST(EXTRACT(EPOCH FROM ab.expires_at - $3), 0.0) / 86400.0, 1.0)
    ASC,
    ab.expires_at ASC,
    ab.id ASC
```

Notes on the formula:
- `NULLIF(MAX(v), 0)` handles the case where max active count is 0 (all users idle) — falls through to `GREATEST(NULL, 1)` = 1, making the fairness term 0 for everyone
- `EXTRACT(EPOCH FROM expires_at - $3)` gives seconds remaining; divided by 86400 normalises to fraction of a day
- `GREATEST(..., 0.0)` clamps expired batches to 0 (maximum urgency)
- `LEAST(..., 1.0)` caps at 1.0 (batches > 24hr from expiry all score equally low urgency)
- When `$8 = 0`, the urgency term vanishes and the score is `active_count / max_active` — monotonically equivalent to current behavior

New `$8` parameter binding: `self.config.urgency_weight`.

### 3. `src/manager/mod.rs` — Update trait doc comment

Update the `claim_requests` doc to mention urgency weighting is implementation-defined and controlled by `DaemonConfig::urgency_weight`.

### 4. Tests in `src/manager/postgres.rs`

Add `test_urgency_weighted_scheduling`:
- Create two users with equal active counts
- User A has a 1-hour SLA batch, User B has a 24-hour SLA batch
- Set `urgency_weight = 0.5`
- Assert User A's requests are claimed first (urgency breaks the fairness tie)
- Re-test with `urgency_weight = 0.0` to verify User A no longer has priority (deadline only as tiebreaker)

### 5. Regenerate sqlx cache

```bash
cargo sqlx prepare
```

## Files to modify

| File | Change |
|------|--------|
| `src/daemon/mod.rs` | Add `urgency_weight: f64` to `DaemonConfig` + `Default` impl |
| `src/manager/postgres.rs` | Modify ORDER BY in claim SQL, add `$8` binding, add test |
| `src/manager/mod.rs` | Update doc comment on `claim_requests` |
| `.sqlx/query-*.json` | Regenerated by `cargo sqlx prepare` |

## Performance

The modified ORDER BY adds negligible cost:
- One `MAX` subquery over the `$7` array (already in memory, evaluated once as an InitPlan)
- One `EXTRACT(EPOCH FROM ...)` per batch row — trivial arithmetic
- One division and one addition per row in the sort key

The claim cycle runs every ~1 second and typically processes dozens to low hundreds of rows.

## Rollout Strategy

1. Deploy with `urgency_weight: 0.0` (default). Zero behavioral change.
2. Gradually increase to `0.1`, `0.2`, `0.3` while monitoring claim distribution metrics.
3. Observe whether high-SLA batches complete faster without starving low-SLA users.
4. Settle on a production value (likely 0.2–0.4 based on workload).

## Verification

1. `just db-start && just db-setup`
2. `just test` — all existing tests must pass (urgency_weight defaults to 0.0)
3. `just test test_urgency_weighted_scheduling` — new test passes
4. `just lint` — no warnings
5. `cargo sqlx prepare --check` — offline cache valid
