Skip to content

Commit e47e327

Browse files
authored
[scheduler] Gate cluster feed on an awake-set scan (#2465)
The cluster feed round-robined the full cluster universe, whose size scales with host/tag count rather than workload: a single show could fan out to ~180 clusters (mostly hostname/manual) of which ~90% were idle, so most passes ended in no_jobs and the round-trip p50 degraded as the lap grew. Add a periodic awake-gate scan that recomputes which (facility, show, tag) tuples have plausibly-dispatchable work (JobDao::scan_active_tags), and have the producer round-robin only that awake subset. Clusters with no work stay out of the rotation until a later scan re-activates them; the producer is woken immediately on an empty -> non-empty transition via active_notify rather than waiting out the idle poll. The scan query is a provable superset of the per-cluster dispatch query: it keeps only a strict subset of that query's conjuncts (PENDING/not-paused/ facility, a waiting layer carrying the tag, show active+scheduler-managed) and drops the folder/job caps, so it can never produce a false negative. Its subscription gate is the cheap static `int_burst > 0` only -- the dynamic live-headroom check is deliberately omitted to avoid an O(farm) full-`proc` aggregation every interval and the over-count/false-exclude foot-gun that a global proc-sum would carry. Burst enforcement stays in the per-cluster path, where an over-burst-but-busy show backs off on cluster_saturated_sleep. The existing per-pass sleep_map backoff is unchanged and still handles the superset residual (clusters that are awake but turn out capped/saturated on a real pass); the awake gate sits on top of it. Adds config knob `active_scan_interval` (default 2s, the idle->dispatch latency lever) and metrics scheduler_clusters_active, scheduler_active_tags, and scheduler_active_scan_duration_seconds. Covered by a new stress-test that asserts the scan is a superset of the per-cluster query against live PG. The remaining diff is rustfmt normalization. ## LLM Usage Disclosure Some of the work of this PR was done using Claude Code Opus. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Scheduler now periodically focuses on clusters with dispatchable work, reducing unnecessary re-checks and improving dispatch responsiveness. * Added new monitoring for the active cluster subset and scan timing. * Configuration now includes a new interval to control how often active clusters are re-scanned. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent f29d581 commit e47e327

16 files changed

Lines changed: 681 additions & 34 deletions

File tree

rust/crates/scheduler/src/accounting/error.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ pub enum AccountingError {
4141
/// per design §2.4), but the bootstrap reseed surfaces it as a startup gate so a
4242
/// scheduler never begins booking against an unseeded Redis. Carries the number of
4343
/// attempts made (`cas_max_retries + 1`) for diagnostics.
44-
#[error("CAS contention exceeded retry budget after {attempts} attempts; reseed cycle skipped")]
44+
#[error(
45+
"CAS contention exceeded retry budget after {attempts} attempts; reseed cycle skipped"
46+
)]
4547
CasContentionExceeded { attempts: u32 },
4648

4749
#[error("accounting redis error: {0}")]

rust/crates/scheduler/src/accounting/managed_shows.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,7 @@ impl ManagedShowsCache {
107107
let added_set: HashSet<Uuid> = added.iter().copied().collect();
108108
let deferred: HashSet<Uuid> =
109109
new_set.difference(&added_set).copied().collect();
110-
let mut lock =
111-
inner.write().unwrap_or_else(|p| p.into_inner());
110+
let mut lock = inner.write().unwrap_or_else(|p| p.into_inner());
112111
*lock = deferred;
113112
return;
114113
}

rust/crates/scheduler/src/accounting/recompute.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,7 @@ pub fn spawn_loop(service: Arc<AccountingService>) {
7070
last_dispatched = current_dispatched;
7171

7272
let current_limit_exceeded = metrics::resource_limit_exceeded_session();
73-
let limit_exceeded_delta =
74-
current_limit_exceeded.saturating_sub(last_limit_exceeded);
73+
let limit_exceeded_delta = current_limit_exceeded.saturating_sub(last_limit_exceeded);
7574
last_limit_exceeded = current_limit_exceeded;
7675

7776
info!(

rust/crates/scheduler/src/cluster.rs

Lines changed: 401 additions & 22 deletions
Large diffs are not rendered by default.

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ pub struct QueueConfig {
146146
/// The reload only swaps the live set when it actually changed.
147147
#[serde(with = "humantime_serde")]
148148
pub cluster_reload_interval: Duration,
149+
/// Interval between awake-gate scans. Each scan runs a single query that
150+
/// computes which clusters have plausibly-dispatchable work; clusters not in
151+
/// that set are skipped by the producer until a later scan re-activates them.
152+
/// This bounds idle->dispatch latency: a newly-launched job starts being
153+
/// dispatched within at most one scan interval. Smaller = lower wake latency,
154+
/// larger = fewer scan queries.
155+
#[serde(with = "humantime_serde")]
156+
pub active_scan_interval: Duration,
149157
/// Duration a cluster sleeps after a pass found jobs but dispatched zero
150158
/// frames (saturated farm: no host candidate fits any pending layer).
151159
/// Keeps the loop from re-querying jobs and layers continuously while
@@ -183,6 +191,7 @@ impl Default for QueueConfig {
183191
job_back_off_duration: Duration::from_secs(300),
184192
cluster_empty_sleep: Duration::from_secs(30),
185193
cluster_reload_interval: Duration::from_secs(120),
194+
active_scan_interval: Duration::from_secs(2),
186195
cluster_saturated_sleep: Duration::from_secs(5),
187196
stream: StreamConfig::default(),
188197
max_jobs_per_cluster_pass: 20,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212

1313
use std::sync::Arc;
1414

15+
use crate::pgpool::connection_pool;
1516
use futures::Stream;
1617
use miette::{IntoDiagnostic, Result};
1718
use serde::{Deserialize, Serialize};
1819
use sqlx::{Pool, Postgres};
19-
use crate::pgpool::connection_pool;
2020

2121
/// Data Access Object for host operations in the job dispatch system.
2222
///

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ pub struct JobModel {
4444
pub show_name: String,
4545
}
4646

47+
/// One active (facility, show, tag) tuple from [`JobDao::scan_active_tags`].
48+
///
49+
/// A tag is "active" for a (facility, show) when at least one PENDING job in
50+
/// that facility/show has a waiting layer carrying the tag, and the show still
51+
/// has subscription headroom. Used to gate the cluster feed's awake set: a
52+
/// cluster is eligible iff one of its tags appears here under its
53+
/// (facility, show) key. See `cluster::ClusterFeed` and the superset argument
54+
/// on [`QUERY_ACTIVE_TAGS`].
55+
#[derive(sqlx::FromRow, Serialize, Deserialize)]
56+
pub struct ActiveTagModel {
57+
pub pk_show: String,
58+
pub pk_facility: String,
59+
pub tag: String,
60+
}
61+
4762
impl DispatchJob {
4863
/// Creates a new DispatchJob from a database model and cluster assignment.
4964
///
@@ -156,6 +171,39 @@ ORDER BY jr.int_priority DESC
156171
LIMIT $5
157172
"#;
158173

174+
// Awake-gate scan. Returns the set of (facility, show, tag) tuples that *could*
175+
// yield a dispatchable job, so the cluster feed can keep clusters with no work
176+
// asleep instead of re-querying them every backoff window.
177+
//
178+
// SUPERSET INVARIANT (must never produce a false negative): every (job, layer)
179+
// the per-cluster query QUERY_PENDING_BY_SHOW_FACILITY_TAG would return must be
180+
// captured here, otherwise the owning cluster is gated to permanent sleep and
181+
// its jobs starve. Two rules keep this true:
182+
//
183+
// `unnest` splits each layer's pipe-joined str_tags into individual tags; the
184+
// feed re-applies tag-set overlap on the cluster side, matching the per-cluster
185+
// `&&` array-overlap semantics.
186+
static QUERY_ACTIVE_TAGS: &str = r#"
187+
SELECT DISTINCT
188+
j.pk_show,
189+
j.pk_facility,
190+
unnest(string_to_array(REPLACE(l.str_tags, ' ', ''), '|')) AS tag
191+
FROM job j
192+
INNER JOIN show sh ON sh.pk_show = j.pk_show
193+
INNER JOIN layer l ON l.pk_job = j.pk_job
194+
INNER JOIN layer_stat ls ON ls.pk_layer = l.pk_layer
195+
WHERE j.str_state = 'PENDING'
196+
AND j.b_paused = false
197+
AND sh.b_active = true
198+
AND sh.b_scheduler_managed = true
199+
AND ls.int_waiting_count > 0
200+
AND EXISTS (
201+
SELECT 1 FROM subscription s
202+
WHERE s.pk_show = j.pk_show AND s.int_burst > 0
203+
)
204+
AND ($1::text IS NULL OR j.pk_facility = $1)
205+
"#;
206+
159207
impl JobDao {
160208
/// Creates a new JobDao from database configuration.
161209
///
@@ -230,4 +278,33 @@ impl JobDao {
230278
observe_job_query_duration(start.elapsed());
231279
result
232280
}
281+
282+
/// Scans for the set of (facility, show, tag) tuples that currently have
283+
/// plausibly-dispatchable work, used to gate the cluster feed's awake set.
284+
///
285+
/// This is a strict superset of what `query_pending_jobs_by_show_facility_and_tags`
286+
/// would return (see [`QUERY_ACTIVE_TAGS`]): a cluster whose tag is absent
287+
/// here is guaranteed to have no dispatchable job and can stay asleep. The
288+
/// reverse does not hold — a returned tag may still yield a no_jobs or
289+
/// saturated pass once the folder/job caps and live subscription headroom are
290+
/// evaluated per-cluster, which is intentionally cheap to absorb.
291+
///
292+
/// # Arguments
293+
/// * `facility_id` - Optional facility filter; `None` scans every facility.
294+
///
295+
/// # Returns
296+
/// All active tuples (unordered). One database round-trip regardless of the
297+
/// number of clusters.
298+
pub async fn scan_active_tags(
299+
&self,
300+
facility_id: Option<&str>,
301+
) -> Result<Vec<ActiveTagModel>, sqlx::Error> {
302+
let start = std::time::Instant::now();
303+
let result = sqlx::query_as::<_, ActiveTagModel>(QUERY_ACTIVE_TAGS)
304+
.bind(facility_id)
305+
.fetch_all(&*self.connection_pool)
306+
.await;
307+
crate::metrics::observe_active_scan_duration(start.elapsed());
308+
result
309+
}
233310
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ mod resource_accounting_dao;
2222
pub use cluster_dao::ClusterDao;
2323
pub use frame_dao::FrameDao;
2424
pub use host_dao::HostDao;
25-
pub use job_dao::JobDao;
25+
pub use job_dao::{ActiveTagModel, JobDao};
2626
pub use layer_dao::LayerDao;
2727
pub use proc_dao::{ProcDao, ProcDaoError};
2828

rust/crates/scheduler/src/host_cache/cache.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -964,7 +964,10 @@ mod tests {
964964
.values()
965965
.flat_map(|by_memory| by_memory.values())
966966
.any(|hosts| hosts.contains(&dropped_id));
967-
assert!(!still_referenced, "pruned host must not linger in any bucket");
967+
assert!(
968+
!still_referenced,
969+
"pruned host must not linger in any bucket"
970+
);
968971
drop(hosts_index);
969972

970973
// A second prune with the same live set is a no-op.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
// or implied. See the License for the specific language governing permissions and limitations under
1111
// the License.
1212

13-
pub(crate) mod cache;
1413
mod actor;
14+
pub(crate) mod cache;
1515
pub mod messages;
1616
mod store;
1717

0 commit comments

Comments
 (0)