Skip to content

Commit 2a695c2

Browse files
committed
Additional test cases
1 parent 1c7f047 commit 2a695c2

7 files changed

Lines changed: 1661 additions & 1 deletion

File tree

src/job_store_shard/dequeue.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

src/job_store_shard/mod.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,55 @@ impl JobStoreShard {
943943
self.concurrency.rollback_grant(tenant, queue, task_id);
944944
}
945945

946+
/// Test-only: inject the broker buffer entries for the given task keys,
947+
/// reading the persisted task bytes from the DB. Lets tests deterministically
948+
/// drive `dequeue` against future-scheduled or pre-cancel-staged tasks
949+
/// without waiting for the background broker scanner.
950+
pub async fn force_buffer_tasks_for_test(
951+
&self,
952+
keys: Vec<Vec<u8>>,
953+
) -> Result<usize, JobStoreShardError> {
954+
use crate::codec::decode_task_validated;
955+
use crate::task_broker::BrokerTask;
956+
let mut tasks = Vec::with_capacity(keys.len());
957+
for key in keys {
958+
let Some(raw) = self.db.get(&key).await? else {
959+
continue;
960+
};
961+
let decoded = decode_task_validated(raw).map_err(|e| {
962+
JobStoreShardError::Codec(format!("force_buffer decode: {e}"))
963+
})?;
964+
tasks.push(BrokerTask { key, decoded });
965+
}
966+
let count = tasks.len();
967+
self.brokers.requeue(tasks);
968+
Ok(count)
969+
}
970+
971+
/// Test-only: reserve an in-memory holder for (tenant, queue, task_id).
972+
/// Bypasses DB hydration and capacity checks so tests can fabricate the
973+
/// "in-memory and on-disk holder exist for a fake chain" state needed to
974+
/// drive cancel/reimport paths through their defensive arms.
975+
pub async fn force_reserve_concurrency_for_test(
976+
&self,
977+
tenant: &str,
978+
queue: &str,
979+
task_id: &str,
980+
limit: usize,
981+
) -> bool {
982+
self.concurrency.counts().try_reserve(
983+
&self.db,
984+
&self.get_range(),
985+
tenant,
986+
queue,
987+
task_id,
988+
limit,
989+
"force-reserve-test",
990+
)
991+
.await
992+
.unwrap_or(false)
993+
}
994+
946995
/// Fetch a job by id as a zero-copy archived view.
947996
pub async fn get_job(
948997
&self,

src/task_broker.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,19 @@ impl TaskBrokerRegistry {
677677
entry.value().stop();
678678
}
679679
}
680+
681+
/// Test-only: ensure a broker exists for `task_group` and directly inject
682+
/// `keys` into its in-flight set. Used to exercise drop-guard / release
683+
/// paths without standing up a scanner + DB write round-trip just to drive
684+
/// the same state.
685+
#[cfg(test)]
686+
pub(crate) fn seed_inflight_for_test(&self, task_group: &str, keys: Vec<Vec<u8>>) {
687+
let broker = self.get_or_create(task_group);
688+
let mut inflight = broker.inflight.lock().unwrap();
689+
for k in keys {
690+
inflight.insert(k);
691+
}
692+
}
680693
}
681694

682695
#[cfg(test)]

0 commit comments

Comments
 (0)