Skip to content

Commit dd78d32

Browse files
committed
Reseed: zero-converge drained accounting keys
The booked-counter recompute built its Redis reseed solely from a SUM(proc) snapshot, which only returns keys that still have procs. A key whose booked counter drifted stale-high (e.g. a lost decrement) and whose procs then drained to zero vanished from the snapshot entirely, so no resetting HSET was never emitted. The stale value persisted indefinitely for a subscription/folder/job this wedged the key, falsely failing the burst/cap check in the booking Lua with no path to recovery. Solution: Overlay the snapshot on a zero-baseline of every enumerable key. Before folding in the proc sums, seed int_cores=0/int_gpus=0 for every acct:sub / acct:folder / acct:job / acct:point key that exists for a scheduler-managed show, enumerated by reusing the same limit-table queries the limit reseed already runs (subscription, folder_resource, job_resource non-FINISHED, point). A baseline key absent from the snapshot has no procs and emits a resetting 0; a key with procs emits its true sum. This is the same key universe and same order of op count the limit reseed already writes each cycle, and it lets recompute converge a drained key back to truth. Bootstrap reseed benefits too, since it shares reseed_redis_once. acct:layer is intentionally not zero-baselined: layers have no limit table to enumerate from, and the booking Lua never reads the layer counter (HINCRBY-only), so residual layer drift is cosmetic. Orphaned keys for FINISHED jobs are likewise left in place (never read again); reclaiming that memory is tracked as future work.
1 parent cc3135e commit dd78d32

4 files changed

Lines changed: 213 additions & 11 deletions

File tree

docs/_docs/developer-guide/redis-accounting.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,31 @@ The result is dual-written to:
310310
The dual write happens under the same SUM query for consistency: both PG and
311311
Redis end up showing the same snapshot.
312312

313+
#### Zero-convergence for drained keys
314+
315+
`SUM(proc)` only returns keys that **still have procs**. A key whose booked
316+
counter drifted stale-high (e.g. a lost decrement) and whose procs then drained
317+
to zero would vanish from the snapshot entirely - so a snapshot-only reseed
318+
could never reset it, and the stale value would wedge the key forever (for a
319+
subscription/folder/job this means falsely failing the burst/cap check in the
320+
booking Lua with no path to recovery).
321+
322+
To close this, the Redis reseed overlays the snapshot on a **zero-baseline** of
323+
every enumerable key. Before folding in the proc sums, it seeds `int_cores=0`/
324+
`int_gpus=0` for every `acct:sub`/`acct:folder`/`acct:job`/`acct:point` key that
325+
exists for a scheduler-managed show - enumerated by reusing the same limit-table
326+
queries the limit reseed runs (`subscription`, `folder_resource`,
327+
`job_resource` non-`FINISHED`, `point`). A key present in the baseline but
328+
absent from the snapshot has no procs, so it emits a resetting `0`; a key with
329+
procs emits its true sum. This is the same key universe, and same order of
330+
magnitude of ops, the limit reseed already writes each cycle.
331+
332+
`acct:layer` is intentionally **not** zero-baselined: layers have no limit table
333+
to enumerate from, and the booking Lua never reads the layer counter (it is
334+
`HINCRBY`-only), so residual layer drift is cosmetic. Orphaned `acct:job`/
335+
`acct:layer` keys for `FINISHED` jobs are likewise left in place (never read
336+
again); reclaiming that memory is tracked as future work.
337+
313338
### Limit reseed (every 5 min)
314339

315340
For every scheduler-managed show, the scheduler reads limit fields (`size`,

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ pub struct BookedSnapshotRow {
4141
pub gpus: i64,
4242
}
4343

44+
/// The full universe of enumerable accounting keys for scheduler-managed shows,
45+
/// used by the booked-counter recompute to seed zero baselines. A key present here
46+
/// but absent from the `SUM(proc)` snapshot has no procs, so its booked counter must
47+
/// be reset to 0 - otherwise a counter that drifted stale-high and then drained to
48+
/// zero procs would never converge (the snapshot only returns keys that still have
49+
/// procs). Layer keys are intentionally absent: layers have no limit table to
50+
/// enumerate from, and the booking Lua never reads the layer counter (it is
51+
/// `HINCRBY`-only), so residual layer drift is cosmetic. See `recompute.rs`.
52+
#[derive(Debug, Clone, Default)]
53+
pub struct BaselineKeys {
54+
pub subs: Vec<(Uuid, Uuid)>,
55+
pub folders: Vec<Uuid>,
56+
pub jobs: Vec<Uuid>,
57+
pub points: Vec<(Uuid, Uuid)>,
58+
}
59+
4460
#[derive(Debug, Clone)]
4561
pub struct SubscriptionLimitsRow {
4662
pub show_id: Uuid,
@@ -189,6 +205,27 @@ impl AccountingDao {
189205
.collect())
190206
}
191207

208+
/// Enumerate the full set of sub/folder/job/point keys for scheduler-managed
209+
/// shows, by projecting the key tuples out of the four limit queries. Reusing the
210+
/// limit queries (rather than dedicated key-only SELECTs) guarantees this key
211+
/// universe is grain-consistent with both the limit reseed and the booked snapshot
212+
/// (same `b_scheduler_managed` / `str_state <> 'FINISHED'` filters). Layer keys are
213+
/// not enumerable from a limit table, so they are absent here by design.
214+
pub async fn query_booked_baseline_keys(&self) -> Result<BaselineKeys> {
215+
let (subs, folders, jobs, points) = tokio::try_join!(
216+
self.query_subscription_limits(),
217+
self.query_folder_limits(),
218+
self.query_job_limits(),
219+
self.query_point_limits(),
220+
)?;
221+
Ok(BaselineKeys {
222+
subs: subs.iter().map(|r| (r.show_id, r.alloc_id)).collect(),
223+
folders: folders.iter().map(|r| r.folder_id).collect(),
224+
jobs: jobs.iter().map(|r| r.job_id).collect(),
225+
points: points.iter().map(|r| (r.dept_id, r.show_id)).collect(),
226+
})
227+
}
228+
192229
pub async fn query_subscription_limits(&self) -> Result<Vec<SubscriptionLimitsRow>> {
193230
#[derive(sqlx::FromRow)]
194231
struct Row {

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

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use miette::{IntoDiagnostic, Result, WrapErr};
3434
use tokio::time;
3535
use tracing::{debug, error, info, warn};
3636

37-
use crate::accounting::dao::BookedSnapshotRow;
37+
use crate::accounting::dao::{BaselineKeys, BookedSnapshotRow};
3838
use crate::accounting::redis_client::ReseedOp;
3939
use crate::accounting::AccountingService;
4040
use crate::config::CONFIG;
@@ -96,12 +96,21 @@ async fn run_once(service: &AccountingService, pg_dao: &Arc<ResourceAccountingDa
9696
/// CAS-guarded reseed of the booked-counter fields (`int_cores`/`int_gpus`) on the
9797
/// five `acct:*` hashes from a fresh `SUM(proc)` snapshot. Used by both the recompute
9898
/// loop and the bootstrap. On CAS-budget exhaustion: warn and return Ok (per §2.4).
99+
///
100+
/// The snapshot is overlaid on a zero-baseline of every enumerable sub/folder/job/point
101+
/// key (see `query_booked_baseline_keys`), so a key whose counter drifted stale-high and
102+
/// then drained to zero procs is reset to 0 rather than being left untouched - the
103+
/// `SUM(proc)` snapshot alone only returns keys that still have procs. Both the snapshot
104+
/// and the baseline are re-fetched per CAS attempt, matching `limit_reseed::reseed_once`.
99105
pub async fn reseed_redis_once(service: &AccountingService) -> Result<()> {
100106
let max_retries = CONFIG.accounting.cas_max_retries;
101107
for attempt in 0..=max_retries {
102108
let seq_before = service.redis().get_seq().await.into_diagnostic()?;
103-
let rows = service.dao().query_booked_snapshot().await?;
104-
let ops = booked_ops_from_snapshot(&rows);
109+
let (rows, baseline) = tokio::try_join!(
110+
service.dao().query_booked_snapshot(),
111+
service.dao().query_booked_baseline_keys(),
112+
)?;
113+
let ops = booked_ops_from_snapshot(&rows, &baseline);
105114
debug!(
106115
"Recompute reseed attempt {}/{}: {} rows -> {} ops at seq={}",
107116
attempt + 1,
@@ -147,7 +156,16 @@ pub async fn reseed_redis_once(service: &AccountingService) -> Result<()> {
147156
/// `HSET` (overwrite) rather than `HINCRBY`, emitting one op per row would let later
148157
/// rows clobber earlier ones - the final value would be whichever row sorted last,
149158
/// not the sum across rows. Aggregate first, emit once per unique key.
150-
fn booked_ops_from_snapshot(rows: &[BookedSnapshotRow]) -> Vec<ReseedOp> {
159+
///
160+
/// `baseline` seeds a zero entry for every enumerable sub/folder/job/point key before
161+
/// the proc sums are folded in. A key in the baseline but absent from `rows` has no
162+
/// procs, so it emits `int_cores=0`/`int_gpus=0` - this is what lets recompute converge
163+
/// a counter that drifted stale-high and then drained to zero procs (without it, such a
164+
/// key would simply be missing from the snapshot and never corrected). Layers are not
165+
/// in the baseline (no limit table to enumerate them and the booking Lua never reads the
166+
/// layer counter), so the layer map stays purely proc-driven - residual layer drift is
167+
/// cosmetic by design.
168+
fn booked_ops_from_snapshot(rows: &[BookedSnapshotRow], baseline: &BaselineKeys) -> Vec<ReseedOp> {
151169
use std::collections::HashMap;
152170

153171
let mut sub_totals: HashMap<(uuid::Uuid, uuid::Uuid), (i64, i64)> = HashMap::new();
@@ -156,6 +174,21 @@ fn booked_ops_from_snapshot(rows: &[BookedSnapshotRow]) -> Vec<ReseedOp> {
156174
let mut layer_totals: HashMap<uuid::Uuid, (i64, i64)> = HashMap::new();
157175
let mut point_totals: HashMap<(uuid::Uuid, uuid::Uuid), (i64, i64)> = HashMap::new();
158176

177+
// Zero-baseline first: every enumerable key gets a (0, 0) entry so keys with no
178+
// procs still emit a resetting HSET. The proc fold below adds on top of these.
179+
for &k in &baseline.subs {
180+
sub_totals.entry(k).or_default();
181+
}
182+
for &k in &baseline.folders {
183+
folder_totals.entry(k).or_default();
184+
}
185+
for &k in &baseline.jobs {
186+
job_totals.entry(k).or_default();
187+
}
188+
for &k in &baseline.points {
189+
point_totals.entry(k).or_default();
190+
}
191+
159192
for r in rows {
160193
let s = sub_totals.entry((r.show_id, r.alloc_id)).or_default();
161194
s.0 += r.cores;
@@ -253,9 +286,13 @@ mod tests {
253286
ops.iter().filter(|o| o.key == key).count()
254287
}
255288

289+
fn empty_baseline() -> BaselineKeys {
290+
BaselineKeys::default()
291+
}
292+
256293
#[test]
257294
fn snapshot_single_row_expands_to_ten_ops_in_cores() {
258-
let ops = booked_ops_from_snapshot(&[fixture_row()]);
295+
let ops = booked_ops_from_snapshot(&[fixture_row()], &empty_baseline());
259296
// 5 unique keys × 2 fields (int_cores, int_gpus).
260297
assert_eq!(ops.len(), 10);
261298
let cores_ops: Vec<_> = ops.iter().filter(|o| o.field == "int_cores").collect();
@@ -270,7 +307,7 @@ mod tests {
270307

271308
#[test]
272309
fn snapshot_keys_match_publisher_format() {
273-
let ops = booked_ops_from_snapshot(&[fixture_row()]);
310+
let ops = booked_ops_from_snapshot(&[fixture_row()], &empty_baseline());
274311
let keys: std::collections::HashSet<&str> = ops.iter().map(|o| o.key.as_str()).collect();
275312
assert!(keys.contains(
276313
"acct:sub:00000000-0000-0000-0000-000000000000:00000000-0000-0000-0000-000000000000"
@@ -313,7 +350,7 @@ mod tests {
313350
gpus: 2,
314351
};
315352

316-
let ops = booked_ops_from_snapshot(&[row_a, row_b]);
353+
let ops = booked_ops_from_snapshot(&[row_a, row_b], &empty_baseline());
317354

318355
// Centicores summed (3500), then /100 -> 35 cores.
319356
let sub_key = format!("acct:sub:{}:{}", show, alloc);
@@ -367,7 +404,7 @@ mod tests {
367404
},
368405
];
369406

370-
let ops = booked_ops_from_snapshot(&rows);
407+
let ops = booked_ops_from_snapshot(&rows, &empty_baseline());
371408

372409
let job_key = format!("acct:job:{}", job);
373410
assert_eq!(find_op(&ops, &job_key, "int_cores").value, 17);
@@ -383,4 +420,109 @@ mod tests {
383420
assert_eq!(find_op(&ops, &sub_a, "int_cores").value, 10);
384421
assert_eq!(find_op(&ops, &sub_b, "int_cores").value, 7);
385422
}
423+
424+
/// A baseline key with no matching proc row (its procs drained to zero) must emit a
425+
/// resetting `int_cores=0`/`int_gpus=0` pair so recompute can converge a stale-high
426+
/// counter back to truth. This is the core of the zero-convergence fix.
427+
#[test]
428+
fn baseline_key_absent_from_snapshot_emits_zero_pair() {
429+
let show = Uuid::new_v4();
430+
let alloc = Uuid::new_v4();
431+
let folder = Uuid::new_v4();
432+
let job = Uuid::new_v4();
433+
let dept = Uuid::new_v4();
434+
let baseline = BaselineKeys {
435+
subs: vec![(show, alloc)],
436+
folders: vec![folder],
437+
jobs: vec![job],
438+
points: vec![(dept, show)],
439+
};
440+
441+
// No proc rows at all: every baseline key drained to zero.
442+
let ops = booked_ops_from_snapshot(&[], &baseline);
443+
444+
// 4 enumerable keys × 2 fields; layers have no baseline so none appear.
445+
assert_eq!(ops.len(), 8);
446+
for key in [
447+
format!("acct:sub:{}:{}", show, alloc),
448+
format!("acct:folder:{}", folder),
449+
format!("acct:job:{}", job),
450+
format!("acct:point:{}:{}", dept, show),
451+
] {
452+
assert_eq!(find_op(&ops, &key, "int_cores").value, 0);
453+
assert_eq!(find_op(&ops, &key, "int_gpus").value, 0);
454+
}
455+
}
456+
457+
/// A baseline key that also appears in the snapshot is not double-counted: it emits
458+
/// one pair carrying the proc sum, not the seeded zero plus the sum.
459+
#[test]
460+
fn baseline_key_present_in_snapshot_uses_proc_sum_once() {
461+
let row = fixture_row(); // all-nil keys, 4200 centicores -> 42 cores, 3 gpus.
462+
let baseline = BaselineKeys {
463+
subs: vec![(Uuid::nil(), Uuid::nil())],
464+
folders: vec![Uuid::nil()],
465+
jobs: vec![Uuid::nil()],
466+
points: vec![(Uuid::nil(), Uuid::nil())],
467+
};
468+
469+
let ops = booked_ops_from_snapshot(&[row], &baseline);
470+
471+
// Still one pair per key (no zero/sum duplication): 5 keys × 2 fields.
472+
assert_eq!(ops.len(), 10);
473+
let job_key = "acct:job:00000000-0000-0000-0000-000000000000";
474+
assert_eq!(count_ops_for_key(&ops, job_key), 2);
475+
assert_eq!(find_op(&ops, job_key, "int_cores").value, 42);
476+
assert_eq!(find_op(&ops, job_key, "int_gpus").value, 3);
477+
}
478+
479+
/// Mixed: one baseline job has procs (keep its sum), another drained to zero (reset).
480+
/// The layer that exists only in the snapshot is still emitted from proc data.
481+
#[test]
482+
fn baseline_resets_drained_key_while_keeping_active_one() {
483+
let show = Uuid::new_v4();
484+
let alloc = Uuid::new_v4();
485+
let folder = Uuid::new_v4();
486+
let dept = Uuid::new_v4();
487+
let active_job = Uuid::new_v4();
488+
let drained_job = Uuid::new_v4();
489+
let active_layer = Uuid::new_v4();
490+
491+
let baseline = BaselineKeys {
492+
subs: vec![(show, alloc)],
493+
folders: vec![folder],
494+
jobs: vec![active_job, drained_job],
495+
points: vec![(dept, show)],
496+
};
497+
let rows = [BookedSnapshotRow {
498+
show_id: show,
499+
alloc_id: alloc,
500+
folder_id: folder,
501+
job_id: active_job,
502+
layer_id: active_layer,
503+
dept_id: dept,
504+
cores: 500, // 5 cores
505+
gpus: 1,
506+
}];
507+
508+
let ops = booked_ops_from_snapshot(&rows, &baseline);
509+
510+
let active_key = format!("acct:job:{}", active_job);
511+
assert_eq!(find_op(&ops, &active_key, "int_cores").value, 5);
512+
assert_eq!(find_op(&ops, &active_key, "int_gpus").value, 1);
513+
514+
let drained_key = format!("acct:job:{}", drained_job);
515+
assert_eq!(find_op(&ops, &drained_key, "int_cores").value, 0);
516+
assert_eq!(find_op(&ops, &drained_key, "int_gpus").value, 0);
517+
518+
// Layer is proc-driven only; the active layer is present, no zero-baseline layers.
519+
let layer_key = format!("acct:layer:{}", active_layer);
520+
assert_eq!(find_op(&ops, &layer_key, "int_cores").value, 5);
521+
assert_eq!(
522+
ops.iter()
523+
.filter(|o| o.key.starts_with("acct:layer:"))
524+
.count(),
525+
2
526+
);
527+
}
386528
}

rust/crates/scheduler/src/pipeline/matcher.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,7 @@ impl MatchingService {
266266
tags,
267267
cores: cores_requested,
268268
memory: layer.mem_min,
269-
validation: move |host| {
270-
Self::validate_match(host, os.as_deref(), threadable)
271-
},
269+
validation: move |host| Self::validate_match(host, os.as_deref(), threadable),
272270
})
273271
.await
274272
.expect("Host Cache actor is unresponsive");

0 commit comments

Comments
 (0)