@@ -1073,3 +1073,106 @@ impl JobStoreShard {
10731073 Ok ( ( tasks, keys) )
10741074 }
10751075}
1076+
1077+ #[ cfg( test) ]
1078+ mod claimed_inflight_guard_tests {
1079+ //! Unit tests for [`ClaimedInflightGuard`].
1080+ //!
1081+ //! These exercise the RAII contract directly: Drop must release in-flight
1082+ //! reservations on the broker registry iff the guard was not disarmed.
1083+ //! Integration coverage (a real `dequeue` future being cancelled) is more
1084+ //! expensive to stage deterministically; these unit tests pin the same
1085+ //! invariant at the type level so any future refactor that breaks Drop or
1086+ //! disarm fails CI before the integration cost catches up to it.
1087+ use super :: * ;
1088+ use crate :: codec:: { decode_task_validated, encode_task} ;
1089+ use crate :: instrumented_db:: InstrumentedDb ;
1090+ use crate :: shard_range:: ShardRange ;
1091+ use std:: sync:: Arc ;
1092+
1093+ fn sample_broker_task ( task_group : & str , job_id : & str ) -> BrokerTask {
1094+ let task = Task :: RunAttempt {
1095+ id : format ! ( "task-{job_id}" ) ,
1096+ tenant : "-" . to_string ( ) ,
1097+ job_id : job_id. to_string ( ) ,
1098+ attempt_number : 1 ,
1099+ relative_attempt_number : 1 ,
1100+ held_queues : vec ! [ ] ,
1101+ task_group : task_group. to_string ( ) ,
1102+ } ;
1103+ let key = crate :: keys:: task_key ( task_group, 1 , 0 , job_id, 1 ) ;
1104+ let decoded = decode_task_validated ( encode_task ( & task) ) . expect ( "decode task" ) ;
1105+ BrokerTask { key, decoded }
1106+ }
1107+
1108+ async fn build_registry ( ) -> Arc < TaskBrokerRegistry > {
1109+ let store = Arc :: new ( slatedb:: object_store:: memory:: InMemory :: new ( ) ) ;
1110+ let db = slatedb:: DbBuilder :: new ( "test" , store)
1111+ . build ( )
1112+ . await
1113+ . expect ( "open in-memory db" ) ;
1114+ let db = InstrumentedDb :: new ( Arc :: new ( db) , tracing:: Span :: none ( ) ) ;
1115+ TaskBrokerRegistry :: new ( db, "shard" . to_string ( ) , None , ShardRange :: full ( ) )
1116+ }
1117+
1118+ /// Drop without disarm releases in-flight on the broker registry.
1119+ ///
1120+ /// This is the leak path commit 1c7f047 closed: if `dequeue` is cancelled
1121+ /// or panics after `claim_ready`, neither `ack_durable` nor `requeue` runs
1122+ /// and the claimed keys sit in `inflight` forever (no TTL).
1123+ #[ tokio:: test]
1124+ async fn drop_without_disarm_releases_inflight ( ) {
1125+ let brokers = build_registry ( ) . await ;
1126+ let bt = sample_broker_task ( "tg-drop" , "j1" ) ;
1127+ brokers. seed_inflight_for_test ( "tg-drop" , vec ! [ bt. key. clone( ) ] ) ;
1128+ assert_eq ! ( brokers. inflight_len( ) , 1 ) ;
1129+
1130+ {
1131+ let _guard = ClaimedInflightGuard :: new ( & brokers, vec ! [ bt] ) ;
1132+ // No disarm — drop fires at end of scope.
1133+ }
1134+
1135+ assert_eq ! (
1136+ brokers. inflight_len( ) ,
1137+ 0 ,
1138+ "Drop must release inflight when guard was not disarmed" ,
1139+ ) ;
1140+ }
1141+
1142+ /// `disarm()` neutralizes the guard: Drop becomes a no-op so the normal
1143+ /// ack/requeue path retains control of the in-flight reservation.
1144+ ///
1145+ /// Without this contract every normal exit would double-release, racing
1146+ /// the `ack_durable` / `requeue` calls that follow disarm.
1147+ #[ tokio:: test]
1148+ async fn disarm_neutralizes_guard_drop ( ) {
1149+ let brokers = build_registry ( ) . await ;
1150+ let bt = sample_broker_task ( "tg-disarm" , "j2" ) ;
1151+ brokers. seed_inflight_for_test ( "tg-disarm" , vec ! [ bt. key. clone( ) ] ) ;
1152+ assert_eq ! ( brokers. inflight_len( ) , 1 ) ;
1153+
1154+ {
1155+ let mut guard = ClaimedInflightGuard :: new ( & brokers, vec ! [ bt] ) ;
1156+ let returned = guard. disarm ( ) ;
1157+ assert_eq ! ( returned. len( ) , 1 , "disarm returns the claimed batch" ) ;
1158+ // Drop now fires on the empty guard — no-op.
1159+ }
1160+
1161+ assert_eq ! (
1162+ brokers. inflight_len( ) ,
1163+ 1 ,
1164+ "Disarmed guard's Drop must NOT release inflight" ,
1165+ ) ;
1166+ }
1167+
1168+ /// Drop on an empty batch is a no-op (defensive against future callers
1169+ /// that might construct the guard before claim_ready returns anything).
1170+ #[ tokio:: test]
1171+ async fn drop_on_empty_batch_is_noop ( ) {
1172+ let brokers = build_registry ( ) . await ;
1173+ {
1174+ let _guard = ClaimedInflightGuard :: new ( & brokers, vec ! [ ] ) ;
1175+ }
1176+ assert_eq ! ( brokers. inflight_len( ) , 0 ) ;
1177+ }
1178+ }
0 commit comments