@@ -34,7 +34,7 @@ use miette::{IntoDiagnostic, Result, WrapErr};
3434use tokio:: time;
3535use tracing:: { debug, error, info, warn} ;
3636
37- use crate :: accounting:: dao:: BookedSnapshotRow ;
37+ use crate :: accounting:: dao:: { BaselineKeys , BookedSnapshotRow } ;
3838use crate :: accounting:: redis_client:: ReseedOp ;
3939use crate :: accounting:: AccountingService ;
4040use 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`.
99105pub 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}
0 commit comments