Skip to content

Commit 9cb57c5

Browse files
Zhe Jinclaude
andcommitted
scheduler: fix executor allocation for non-gang policies and gang batch semantics
Three interrelated bugs prevented correct executor allocation depending on which scheduler plugins were configured. Bug 1 — executors never allocated without gang (DRF-only, priority-only) PluginManager aggregated is_ready/is_fulfilled with unwrap_or(true), meaning when no plugin had a batch opinion the result was true. AllocateAction's pre-check (`if fulfilled || ready { skip }`) and DispatchAction's commit condition (`if fulfilled { commit }`) therefore skipped or no-op'd every session, leaving tasks permanently pending. Fix: replace bool aggregation with Option<bool> three-value logic — Some(false) = veto, Some(true) = approved, None = no opinion. Expose bool at the public API level by tracking per-session op counts (no_gang_alloc_ops / no_gang_bind_ops) inside PluginManager when no gang plugin is loaded. is_ready/is_fulfilled return false before the first op and true after, so callers use uniform `if is_ready() { break; }` without needing to know whether gang is configured. Bug 2 — gang only ever created 1 executor regardless of task count The old readiness formula `total % batch_size == 0` was true at every multiple, so after one allocation (total=1, batch_size=1) the loop broke and no further executors were created for the remaining pending tasks. Fix: introduce `incomplete_tasks` in GangState (sampled once per cycle as Pending + Running task count) and compute: needed = (incomplete_tasks / batch_size) * batch_size ready = needed > 0 && total == needed This creates exactly enough executors to cover all incomplete tasks in full batches per scheduling cycle. Bug 3 — duplicate executor creation when Dispatch and Allocate ran in the same cycle for a gang session DispatchAction lacked a pre-session guard: a session whose executor was still in Binding state (counted in Gang's `allocated`) received additional bindings every cycle. Fix: check is_fulfilled before entering the bind loop; skip the session if it is already satisfied. Additional changes: - Remove "drf" from DEFAULT_POLICIES; DRF remains opt-in via cluster.policies. Default stack is now priority + gang + shim. - Allow listing "shim" in cluster.policies without error (log warning, ignore); shim is always-on. - Regression tests added for all affected configurations: · drf-only and priority-only: executor is allocated · gang batch_size=1: one executor per incomplete task per cycle · gang batch_size=2: exactly two executors per cycle · no-gang: at most one executor per cycle · dispatch without gang: idle executor is bound · plugin aggregation: None/Some(false)/Some(true) edge cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4ea76a5 commit 9cb57c5

7 files changed

Lines changed: 959 additions & 88 deletions

File tree

common/src/ctx.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,18 @@ const DEFAULT_FLAME_CONF: &str = "flame-cluster.yaml";
2727
const DEFAULT_CONTEXT_NAME: &str = "flame";
2828
const DEFAULT_FLAME_ENDPOINT: &str = "http://127.0.0.1:8080";
2929
/// Default policies to enable when none specified in config.
30-
/// Available configurable policies: "priority", "drf", "gang"
31-
/// Note: "shim" plugin is always enabled (required for executor matching)
32-
pub const DEFAULT_POLICIES: &[&str] = &["priority", "drf", "gang"];
30+
///
31+
/// Effective default scheduler stack: `priority + gang + shim`
32+
/// - "gang" is listed here so batch-size semantics and allocation guards are active
33+
/// by default. Users who do not need gang scheduling may omit it; scheduling
34+
/// will still work but allocates one executor per cycle with no batch constraint.
35+
/// - "shim" is always-on (non-configurable) and runs regardless of this list.
36+
///
37+
/// Optional configurable policies (add to cluster.policies in your config to enable):
38+
/// - "drf" — Dominant Resource Fairness; enables fair multi-resource sharing across sessions.
39+
///
40+
/// Note: listing "shim" in the config is harmless — a warning is logged and it is ignored.
41+
pub const DEFAULT_POLICIES: &[&str] = &["priority", "gang"];
3342
const DEFAULT_STORAGE: &str = "sqlite://flame.db";
3443
const DEFAULT_MAX_EXECUTORS_PER_NODE: u32 = 128;
3544
pub const DEFAULT_SESSION_RETRY_LIMITS: u32 = 5;

session_manager/src/scheduler/actions/allocate.rs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,12 @@ impl Action for AllocateAction {
118118
// (see `Context`): Gang's counters include binds Dispatch committed via
119119
// `Statement` in this same `Context`. If those already satisfy gang scheduling, do not
120120
// create more executors here.
121-
let fulfilled = ctx.is_fulfilled(&ssn)?;
122-
let ready = ctx.is_ready(&ssn)?;
121+
//
122+
// `is_ready` / `is_fulfilled` return true once the batch is satisfied (gang) or
123+
// after the first op this cycle (no gang). Skip only when one of them is already
124+
// true before we begin.
125+
let fulfilled = ctx.is_fulfilled(&ssn);
126+
let ready = ctx.is_ready(&ssn);
123127
if fulfilled || ready {
124128
tracing::debug!(
125129
"Skip allocate resources for session <{}>: is_fulfilled={}, is_ready={}",
@@ -132,28 +136,34 @@ impl Action for AllocateAction {
132136

133137
let mut stmt = Statement::new(ss.clone(), ctx.plugins.clone(), ctx.controller.clone());
134138

135-
let pipelineable = void_executors
139+
let pipelineable: Vec<_> = void_executors
136140
.values()
137141
.chain(unbinding_executors.values())
138-
.filter(|e| ctx.is_available(e, &ssn).unwrap_or(false));
142+
.filter(|e| ctx.is_available(e, &ssn).unwrap_or(false))
143+
.cloned()
144+
.collect();
139145

140-
for exec in pipelineable {
146+
for exec in &pipelineable {
141147
stmt.pipeline(exec, &ssn)?;
142-
if ctx.is_ready(&ssn)? {
148+
if ctx.is_ready(&ssn) {
143149
break;
144150
}
145151
}
146152

147-
for node in nodes.iter() {
148-
if ctx.is_ready(&ssn)? {
153+
'node_loop: for node in nodes.iter() {
154+
if ctx.is_ready(&ssn) {
149155
break;
150156
}
151-
while ctx.is_allocatable(node, &ssn)? && !ctx.is_ready(&ssn)? {
157+
while ctx.is_allocatable(node, &ssn)? {
152158
stmt.allocate(node, &ssn)?;
159+
if ctx.is_ready(&ssn) {
160+
break 'node_loop;
161+
}
153162
}
154163
}
155164

156-
if ctx.is_ready(&ssn)? {
165+
let ready_after = ctx.is_ready(&ssn);
166+
if ready_after && !stmt.is_empty() {
157167
let op_count = stmt.len();
158168
let pipelined_ids = stmt.commit().await?;
159169
for id in &pipelined_ids {
@@ -162,7 +172,13 @@ impl Action for AllocateAction {
162172
}
163173
tracing::debug!("Committed {} op(s) for session <{}>", op_count, ssn.id);
164174
nodes.sort_by(|a, b| node_order_fn.cmp(a, b));
165-
open_ssns.push(ssn.clone());
175+
// Re-queue when the batch is complete so remaining pipeline slots can be
176+
// filled in the same cycle (gang with multiple tasks). With no gang,
177+
// `ready_after` is also true after 1 op, but the pre-check above will
178+
// immediately skip the session on re-entry, bounding allocation to 1 op.
179+
if ready_after {
180+
open_ssns.push(ssn.clone());
181+
}
166182
} else if !stmt.is_empty() {
167183
tracing::debug!(
168184
"Discarding incomplete batch for session <{}>: not enough resources",

session_manager/src/scheduler/actions/dispatch.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ impl Action for DispatchAction {
6565
continue;
6666
}
6767

68+
// Skip sessions that already have enough executors committed (or in-flight from a
69+
// previous cycle). Without this guard a session whose executor is still running
70+
// `on_session_enter` (Binding state, counted in Gang's `allocated`) would receive
71+
// additional bindings every scheduling cycle.
72+
//
73+
// `is_fulfilled` returns true once the batch is satisfied (gang) or after the
74+
// first bind this cycle (no gang), so this guard works uniformly for both cases.
75+
if ctx.is_fulfilled(&ssn) {
76+
tracing::debug!(
77+
"Session <{}> is already fulfilled (existing executors satisfy batch_size), skip dispatch.",
78+
ssn.id
79+
);
80+
continue;
81+
}
82+
6883
tracing::debug!(
6984
"Session <{}> is underused, start to allocate resources.",
7085
&ssn.id
@@ -75,13 +90,13 @@ impl Action for DispatchAction {
7590
for (_, exec) in idle_executors.iter() {
7691
if ctx.is_available(exec, &ssn)? {
7792
stmt.bind(exec, &ssn)?;
78-
if ctx.is_fulfilled(&ssn)? {
93+
if ctx.is_fulfilled(&ssn) {
7994
break;
8095
}
8196
}
8297
}
8398

84-
if ctx.is_fulfilled(&ssn)? {
99+
if ctx.is_fulfilled(&ssn) && !stmt.is_empty() {
85100
tracing::debug!("Bind executor for session <{}>.", ssn.id);
86101
let bound_ids = stmt.commit().await?;
87102
for id in &bound_ids {
@@ -97,7 +112,7 @@ impl Action for DispatchAction {
97112
continue;
98113
} else if !stmt.is_empty() {
99114
tracing::debug!(
100-
"Discarding unfulfilled binding for session <{}>: no available idle executors",
115+
"Discarding unfulfilled binding for session <{}>: gang batch incomplete",
101116
ssn.id
102117
);
103118
stmt.discard()?;

session_manager/src/scheduler/ctx.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,21 @@ impl Context {
7777
/// Allocation-side batch readiness (e.g. Gang: pipelined + allocated executors form full
7878
/// batches). Reflects in-memory plugin state, including `Statement` ops earlier in this
7979
/// same cycle (typically pipeline/allocate before commit).
80-
pub fn is_ready(&self, ssn: &SessionInfoPtr) -> Result<bool, FlameError> {
80+
///
81+
/// Returns `true` when the batch is complete (gang) or after the first pipeline/allocate
82+
/// op in a no-gang cycle. Returns `false` before any op has been attempted, or when gang
83+
/// reports the batch is still incomplete.
84+
pub fn is_ready(&self, ssn: &SessionInfoPtr) -> bool {
8185
self.plugins.is_ready(ssn)
8286
}
8387

8488
/// Binding-side batch readiness (e.g. Gang: bound + on-session executors form full batches).
85-
/// After Dispatch commits binds, this can be true so Allocate skips provisioning.
86-
pub fn is_fulfilled(&self, ssn: &SessionInfoPtr) -> Result<bool, FlameError> {
89+
/// After Dispatch commits binds, this can be `true` so Allocate skips provisioning.
90+
///
91+
/// Returns `true` when the batch is fulfilled (gang) or after the first bind op in a
92+
/// no-gang cycle. Returns `false` before any bind has been attempted, or when gang
93+
/// reports the batch is still incomplete.
94+
pub fn is_fulfilled(&self, ssn: &SessionInfoPtr) -> bool {
8795
self.plugins.is_fulfilled(ssn)
8896
}
8997

0 commit comments

Comments
 (0)