1use sha2::{Digest, Sha256};
13
14use super::LeaseOwnerIdentity;
15use super::StoreError;
16use crate::runtime::QueuedWorkClass;
17use crate::{
18 DeliveryPolicy, MergeKey, QueuedWorkClaim, QueuedWorkClaimBoundary, QueuedWorkCompletion,
19 SlotPolicy,
20};
21
22#[derive(Clone, Debug)]
28pub struct ClaimCandidate {
29 pub enqueue_seq: u64,
30 pub claim_fencing_token: u64,
31 pub work_class: QueuedWorkClass,
32 pub delivery_policy: DeliveryPolicy,
33 pub slot_policy: SlotPolicy,
34 pub merge_key: MergeKey,
35}
36
37pub fn claim_scan_limit(max_batches: usize) -> i64 {
41 (max_batches as i64).saturating_add(32)
42}
43
44pub fn select_leading_session_command(candidates: &[ClaimCandidate]) -> usize {
51 candidates
52 .first()
53 .is_some_and(|candidate| candidate.work_class == QueuedWorkClass::SessionCommand)
54 .then_some(1)
55 .unwrap_or(0)
56}
57
58pub fn select_turn_work_claim_prefix(
72 candidates: &[ClaimCandidate],
73 boundary: QueuedWorkClaimBoundary,
74 max_batches: usize,
75) -> usize {
76 if max_batches == 0 {
77 return 0;
78 }
79 let Some(first) = candidates.first() else {
80 return 0;
81 };
82 if first.work_class != QueuedWorkClass::TurnWork {
83 return 0;
84 }
85 if boundary == QueuedWorkClaimBoundary::ActiveTurnCheckpoint
86 && first.delivery_policy != DeliveryPolicy::EarliestSafeBoundary
87 {
88 return 0;
89 }
90 if first.slot_policy != SlotPolicy::Join {
91 return 1;
92 }
93 let mut selected = 1;
94 for candidate in &candidates[1..] {
95 if selected >= max_batches
96 || candidate.work_class != QueuedWorkClass::TurnWork
97 || candidate.slot_policy != SlotPolicy::Join
98 || candidate.delivery_policy != first.delivery_policy
99 || candidate.merge_key != first.merge_key
100 {
101 break;
102 }
103 selected += 1;
104 }
105 selected
106}
107
108#[derive(Clone, Debug)]
115pub struct QueuedWorkClaimLease {
116 pub claim_id: String,
117 pub lease_token: String,
118 pub fencing_token: u64,
119 pub claimed_at_epoch_ms: u64,
120 pub expires_at_epoch_ms: u64,
121}
122
123impl QueuedWorkClaimLease {
124 pub fn derive(
125 head: &ClaimCandidate,
126 session_id: &str,
127 owner: &LeaseOwnerIdentity,
128 now_epoch_ms: u64,
129 lease_ttl_ms: u64,
130 ) -> Self {
131 let fencing_token = head.claim_fencing_token.saturating_add(1);
132 let claim_id = format!("qwc:{}:{fencing_token}", head.enqueue_seq);
133 let lease_token = format!(
134 "{:x}",
135 Sha256::digest(
136 format!(
137 "{}:{}:{}:{}:{}",
138 session_id, owner.owner_id, owner.incarnation_id, claim_id, now_epoch_ms
139 )
140 .as_bytes(),
141 )
142 );
143 Self {
144 claim_id,
145 lease_token,
146 fencing_token,
147 claimed_at_epoch_ms: now_epoch_ms,
148 expires_at_epoch_ms: now_epoch_ms.saturating_add(lease_ttl_ms),
149 }
150 }
151}
152
153pub fn derive_batch_id(
158 session_id: &str,
159 source_key: Option<&str>,
160 now_epoch_ms: u64,
161 nonce: Option<u64>,
162) -> String {
163 let mut seed = format!("{session_id}:{source_key:?}:{now_epoch_ms}");
164 if let Some(nonce) = nonce {
165 seed.push_str(&format!(":{nonce}"));
166 }
167 format!("qwb:{:x}", Sha256::digest(seed.as_bytes()))
168}
169
170pub fn renewed_claim(
173 claim: &QueuedWorkClaim,
174 renewed_rows: usize,
175 expires_at_epoch_ms: u64,
176) -> Result<QueuedWorkClaim, StoreError> {
177 if renewed_rows != claim.batches.len() {
178 return Err(StoreError::QueuedWorkClaimExpired {
179 session_id: claim.session_id.clone(),
180 claim_id: claim.claim_id.clone(),
181 });
182 }
183 Ok(QueuedWorkClaim {
184 expires_at_epoch_ms,
185 ..claim.clone()
186 })
187}
188
189pub fn ensure_completion_owns_all_batches(
193 completed: &QueuedWorkCompletion,
194 owned_rows: usize,
195) -> Result<(), StoreError> {
196 if owned_rows != completed.batch_ids.len() {
197 return Err(StoreError::QueuedWorkClaimExpired {
198 session_id: completed.session_id.clone(),
199 claim_id: completed.claim_id.clone(),
200 });
201 }
202 Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn candidate(
210 enqueue_seq: u64,
211 work_class: QueuedWorkClass,
212 delivery_policy: DeliveryPolicy,
213 slot_policy: SlotPolicy,
214 merge_key: MergeKey,
215 ) -> ClaimCandidate {
216 ClaimCandidate {
217 enqueue_seq,
218 claim_fencing_token: 0,
219 work_class,
220 delivery_policy,
221 slot_policy,
222 merge_key,
223 }
224 }
225
226 #[test]
227 fn exclusive_head_claims_exactly_one() {
228 let candidates = vec![
229 candidate(
230 1,
231 QueuedWorkClass::TurnWork,
232 DeliveryPolicy::EarliestSafeBoundary,
233 SlotPolicy::Exclusive,
234 MergeKey::Never,
235 ),
236 candidate(
237 2,
238 QueuedWorkClass::TurnWork,
239 DeliveryPolicy::EarliestSafeBoundary,
240 SlotPolicy::Exclusive,
241 MergeKey::Never,
242 ),
243 ];
244 assert_eq!(
245 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
246 1
247 );
248 }
249
250 #[test]
251 fn join_head_groups_matching_prefix_up_to_max() {
252 let join = |seq| {
253 candidate(
254 seq,
255 QueuedWorkClass::TurnWork,
256 DeliveryPolicy::EarliestSafeBoundary,
257 SlotPolicy::Join,
258 MergeKey::PayloadDefault,
259 )
260 };
261 let candidates = vec![join(1), join(2), join(3), join(4)];
262 assert_eq!(
263 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 3),
264 3
265 );
266 }
267
268 #[test]
269 fn join_group_breaks_on_policy_or_merge_key_mismatch() {
270 let candidates = vec![
271 candidate(
272 1,
273 QueuedWorkClass::TurnWork,
274 DeliveryPolicy::EarliestSafeBoundary,
275 SlotPolicy::Join,
276 MergeKey::Group("a".to_string()),
277 ),
278 candidate(
279 2,
280 QueuedWorkClass::TurnWork,
281 DeliveryPolicy::EarliestSafeBoundary,
282 SlotPolicy::Join,
283 MergeKey::Group("b".to_string()),
284 ),
285 ];
286 assert_eq!(
287 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
288 1
289 );
290 }
291
292 #[test]
293 fn active_turn_checkpoint_boundary_gates_on_delivery_policy() {
294 let candidates = vec![candidate(
295 1,
296 QueuedWorkClass::TurnWork,
297 DeliveryPolicy::AfterCurrentTurnCommit,
298 SlotPolicy::Exclusive,
299 MergeKey::Never,
300 )];
301 assert_eq!(
302 select_turn_work_claim_prefix(
303 &candidates,
304 QueuedWorkClaimBoundary::ActiveTurnCheckpoint,
305 8
306 ),
307 0
308 );
309 }
310
311 #[test]
312 fn leading_session_command_blocks_turn_work_claim() {
313 let candidates = vec![
314 candidate(
315 1,
316 QueuedWorkClass::SessionCommand,
317 DeliveryPolicy::EarliestSafeBoundary,
318 SlotPolicy::Exclusive,
319 MergeKey::Never,
320 ),
321 candidate(
322 2,
323 QueuedWorkClass::TurnWork,
324 DeliveryPolicy::EarliestSafeBoundary,
325 SlotPolicy::Exclusive,
326 MergeKey::Never,
327 ),
328 ];
329 assert_eq!(select_leading_session_command(&candidates), 1);
330 assert_eq!(
331 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
332 0
333 );
334 }
335
336 #[test]
337 fn later_session_command_does_not_join_turn_work_claim() {
338 let candidates = vec![
339 candidate(
340 1,
341 QueuedWorkClass::TurnWork,
342 DeliveryPolicy::EarliestSafeBoundary,
343 SlotPolicy::Join,
344 MergeKey::PayloadDefault,
345 ),
346 candidate(
347 2,
348 QueuedWorkClass::SessionCommand,
349 DeliveryPolicy::EarliestSafeBoundary,
350 SlotPolicy::Join,
351 MergeKey::PayloadDefault,
352 ),
353 ];
354 assert_eq!(select_leading_session_command(&candidates), 0);
355 assert_eq!(
356 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
357 1
358 );
359 }
360
361 #[test]
362 fn lease_derivation_is_deterministic_and_advances_fencing() {
363 let head = ClaimCandidate {
364 enqueue_seq: 7,
365 claim_fencing_token: 2,
366 work_class: QueuedWorkClass::TurnWork,
367 delivery_policy: DeliveryPolicy::EarliestSafeBoundary,
368 slot_policy: SlotPolicy::Exclusive,
369 merge_key: MergeKey::Never,
370 };
371 let owner = LeaseOwnerIdentity::opaque("owner", "owner:incarnation");
372 let lease = QueuedWorkClaimLease::derive(&head, "session", &owner, 1_000, 250);
373 assert_eq!(lease.fencing_token, 3);
374 assert_eq!(lease.claim_id, "qwc:7:3");
375 assert_eq!(lease.claimed_at_epoch_ms, 1_000);
376 assert_eq!(lease.expires_at_epoch_ms, 1_250);
377 let again = QueuedWorkClaimLease::derive(&head, "session", &owner, 1_000, 250);
378 assert_eq!(lease.lease_token, again.lease_token);
379 }
380
381 #[test]
382 fn batch_id_includes_optional_nonce() {
383 let plain = derive_batch_id("session", Some("key"), 1_000, None);
384 let nonced = derive_batch_id("session", Some("key"), 1_000, Some(1));
385 assert_ne!(plain, nonced);
386 assert!(plain.starts_with("qwb:"));
387 }
388}