Skip to content

Commit f29d581

Browse files
authored
[scheduler] Avoid starvation (#2463)
* **Bug Fixes** * Enforced per-job core caps during layer dispatching by clamping frame core reservations to the job’s remaining allowance * Added an early job-at-cap pre-check to avoid checkout attempts when a job is already at or over its configured core limit * Updated job pending/capacity calculations to use live “booked” core/GPU usage from active processes * **New Features** * Added a Prometheus counter to track when job-level core-cap pre-checks skip layer matching * **Chores** * Improved formatting and updated/added unit tests for cap behavior ## LLM Usage Disclaimer Claude Code Opus was used to help design and implement the changes on this PR --------- Signed-off-by: Diego Tavares <dtavares@imageworks.com>
1 parent 27385a3 commit f29d581

7 files changed

Lines changed: 360 additions & 50 deletions

File tree

rust/crates/scheduler/src/cluster.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,8 +682,7 @@ impl ClusterFeed {
682682
{
683683
let mut by_type: HashMap<&'static str, i64> = HashMap::new();
684684
{
685-
let clusters =
686-
feed.read().unwrap_or_else(|p| p.into_inner());
685+
let clusters = feed.read().unwrap_or_else(|p| p.into_inner());
687686
for c in clusters.iter() {
688687
*by_type.entry(c.cluster_type()).or_default() += 1;
689688
}

rust/crates/scheduler/src/dao/job_dao.rs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,58 @@ impl DispatchJob {
6565
}
6666

6767
static QUERY_PENDING_BY_SHOW_FACILITY_TAG: &str = r#"
68-
-- bookable_shows: shows that still have room in at least one subscription.
69-
WITH bookable_shows AS (
68+
-- LIVE booked-core CTEs. The job/folder/subscription caps must be gated on CURRENT
69+
-- usage, but the PG columns job_resource.int_cores / folder_resource.int_cores /
70+
-- subscription.int_cores are only materialized by the ~120s recompute loop, so they
71+
-- lag reality. `proc` is the transactionally-accurate source (the scheduler inserts on
72+
-- booking, Cuebot deletes on frame completion, compensation deletes on a failed launch),
73+
-- so we sum it directly. Each CTE is scoped to this show ($1) via i_proc_pkshow and
74+
-- mirrors the recompute aggregation (so the value equals a fresher copy of the PG
75+
-- column), and joins on indexed pk_host / pk_job. Gating on stale PG would otherwise
76+
-- (a) over-fetch jobs for caps that are full in Redis -> wasted rejections, and worse
77+
-- (b) FALSE-EXCLUDE: a frame completes and frees burst live, but the lagged PG column
78+
-- stays high for up to a cycle, dropping the show/folder/job from the fetch and starving
79+
-- its (esp. low-priority) jobs until the next recompute.
80+
WITH job_live AS (
81+
-- per-job booked cores (mirrors RECOMPUTE_JOB_RESOURCE_FROM_PROC)
82+
SELECT p.pk_job, COALESCE(SUM(p.int_cores_reserved), 0)::int AS cores
83+
FROM proc p
84+
WHERE p.pk_show = $1
85+
GROUP BY p.pk_job
86+
),
87+
folder_live AS (
88+
-- per-folder booked cores/gpus (mirrors RECOMPUTE_FOLDER_RESOURCE_FROM_PROC)
89+
SELECT j2.pk_folder,
90+
COALESCE(SUM(p.int_cores_reserved), 0)::int AS cores,
91+
COALESCE(SUM(p.int_gpus_reserved), 0)::int AS gpus
92+
FROM proc p
93+
JOIN job j2 ON j2.pk_job = p.pk_job AND j2.str_state <> 'FINISHED'
94+
WHERE p.pk_show = $1
95+
GROUP BY j2.pk_folder
96+
),
97+
sub_live AS (
98+
-- per-alloc booked cores for this show (mirrors RECOMPUTE_SUBSCRIPTION_FROM_PROC:
99+
-- proc -> host -> alloc, excluding local/desktop bookings)
100+
SELECT h.pk_alloc, COALESCE(SUM(p.int_cores_reserved), 0)::int AS cores
101+
FROM proc p
102+
JOIN host h ON h.pk_host = p.pk_host
103+
WHERE p.pk_show = $1 AND p.b_local = false
104+
GROUP BY h.pk_alloc
105+
),
106+
-- bookable_shows: shows that still have room in at least one subscription (LIVE).
107+
bookable_shows AS (
70108
SELECT DISTINCT w.pk_show, sh.str_name AS show_name
71109
FROM subscription s
110+
-- LEFT JOIN: an alloc with no booked procs has full headroom.
111+
LEFT JOIN sub_live sl ON sl.pk_alloc = s.pk_alloc
72112
INNER JOIN vs_waiting w ON s.pk_show = w.pk_show
73113
INNER JOIN show sh ON sh.pk_show = w.pk_show
74114
WHERE s.pk_show = $1
75115
-- Burst == 0 is used to freeze a subscription.
76116
AND s.int_burst > 0
77-
-- At least one core unit available.
78-
AND s.int_burst - s.int_cores >= $2
79-
AND s.int_cores < s.int_burst
117+
-- At least one core unit available, measured against LIVE usage.
118+
AND s.int_burst - COALESCE(sl.cores, 0) >= $2
119+
AND COALESCE(sl.cores, 0) < s.int_burst
80120
)
81121
SELECT
82122
j.pk_job,
@@ -87,15 +127,19 @@ INNER JOIN bookable_shows bs ON j.pk_show = bs.pk_show
87127
INNER JOIN job_resource jr ON j.pk_job = jr.pk_job
88128
INNER JOIN folder f ON j.pk_folder = f.pk_folder
89129
INNER JOIN folder_resource fr ON f.pk_folder = fr.pk_folder
130+
-- LEFT JOIN the live CTEs: no row => 0 booked => full headroom.
131+
LEFT JOIN job_live jl ON jl.pk_job = j.pk_job
132+
LEFT JOIN folder_live fl ON fl.pk_folder = f.pk_folder
90133
WHERE j.str_state = 'PENDING'
91134
AND j.b_paused = false
92135
AND j.pk_facility = $4
93-
-- Folder must have any room at all; per-layer fit is checked below.
94-
AND (fr.int_max_cores = -1 OR fr.int_cores < fr.int_max_cores)
95-
AND (fr.int_max_gpus = -1 OR fr.int_gpus < fr.int_max_gpus)
136+
-- Folder must have any room at all (LIVE); per-layer fit is checked below.
137+
AND (fr.int_max_cores <= 0 OR COALESCE(fl.cores, 0) < fr.int_max_cores)
138+
AND (fr.int_max_gpus <= 0 OR COALESCE(fl.gpus, 0) < fr.int_max_gpus)
96139
-- The job must have at least one layer that matches the tag set, has waiting
97-
-- frames, and fits within the folder cap. EXISTS short-circuits per job and
98-
-- avoids the cardinality blowup of joining layer + layer_stat at the outer level.
140+
-- frames, and fits within the folder AND job caps (both LIVE). EXISTS short-circuits
141+
-- per job and avoids the cardinality blowup of joining layer + layer_stat at the
142+
-- outer level.
99143
AND EXISTS (
100144
SELECT 1
101145
FROM layer l
@@ -104,8 +148,9 @@ WHERE j.str_state = 'PENDING'
104148
AND ls.int_waiting_count > 0
105149
AND string_to_array(REPLACE($3, ' ', ''), '|')
106150
&& string_to_array(REPLACE(l.str_tags, ' ', ''), '|')
107-
AND (fr.int_max_cores = -1 OR fr.int_cores + l.int_cores_min <= fr.int_max_cores)
108-
AND (fr.int_max_gpus = -1 OR fr.int_gpus + l.int_gpus_min <= fr.int_max_gpus)
151+
AND (fr.int_max_cores <= 0 OR COALESCE(fl.cores, 0) + l.int_cores_min <= fr.int_max_cores)
152+
AND (fr.int_max_gpus <= 0 OR COALESCE(fl.gpus, 0) + l.int_gpus_min <= fr.int_max_gpus)
153+
AND (jr.int_max_cores <= 0 OR COALESCE(jl.cores, 0) + l.int_cores_min <= jr.int_max_cores)
109154
)
110155
ORDER BY jr.int_priority DESC
111156
LIMIT $5

rust/crates/scheduler/src/metrics/mod.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
use axum::{response::IntoResponse, routing::get, Router};
1414
use lazy_static::lazy_static;
1515
use prometheus::{
16-
register_counter, register_counter_vec, register_gauge, register_gauge_vec,
17-
register_histogram, register_histogram_vec, Counter, CounterVec, Encoder, Gauge, GaugeVec,
18-
Histogram, HistogramVec, TextEncoder,
16+
register_counter, register_counter_vec, register_gauge, register_gauge_vec, register_histogram,
17+
register_histogram_vec, Counter, CounterVec, Encoder, Gauge, GaugeVec, Histogram, HistogramVec,
18+
TextEncoder,
1919
};
2020
use std::sync::atomic::{AtomicU64, Ordering};
2121
use std::time::Duration;
@@ -79,6 +79,18 @@ lazy_static! {
7979
)
8080
.expect("Failed to register accounting_limit_exceeded_total counter");
8181

82+
// Counts layers skipped by the matcher's job-level at-cap pre-check
83+
// (`pipeline/matcher.rs`, `placement::job_at_core_cap`). A job already at its
84+
// `job_max_cores` cap would otherwise be re-checked-out and re-rejected up to
85+
// `host_candidate_attempts_per_layer` times every pass; the pre-check returns
86+
// early instead. Kept distinct from `ACCOUNTING_LIMIT_EXCEEDED_TOTAL{table="job"}`
87+
// (Lua-rejection pressure) so this reads cleanly as "wasted attempts avoided".
88+
pub static ref JOB_CAP_PRECHECK_SKIP_TOTAL: Counter = register_counter!(
89+
"scheduler_job_cap_precheck_skip_total",
90+
"Layers skipped pre-checkout because the job is already at its core cap"
91+
)
92+
.expect("Failed to register job_cap_precheck_skip_total counter");
93+
8294
// Job query metrics from dao/job_dao.rs
8395
pub static ref JOB_QUERY_DURATION_SECONDS: Histogram = register_histogram!(
8496
"scheduler_job_query_duration_seconds",
@@ -326,6 +338,12 @@ pub fn increment_accounting_limit_exceeded(table: &str) {
326338
.inc();
327339
}
328340

341+
/// Records a layer skipped by the job-level at-cap pre-check.
342+
#[inline]
343+
pub fn increment_job_cap_precheck_skip() {
344+
JOB_CAP_PRECHECK_SKIP_TOTAL.inc();
345+
}
346+
329347
/// Helper function to observe job query duration
330348
#[inline]
331349
pub fn observe_job_query_duration(duration: Duration) {

0 commit comments

Comments
 (0)