1use sha2::{Digest, Sha256};
13
14use super::LeaseOwnerIdentity;
15use super::StoreError;
16use crate::runtime::QueuedWorkClass;
17use crate::{DeliveryPolicy, MergeKey, QueuedWorkClaimBoundary, QueuedWorkCompletion, SlotPolicy};
18
19#[derive(Clone, Debug)]
25pub struct ClaimCandidate {
26 pub enqueue_seq: u64,
27 pub claim_fencing_token: u64,
28 pub work_class: QueuedWorkClass,
29 pub delivery_policy: DeliveryPolicy,
30 pub slot_policy: SlotPolicy,
31 pub merge_key: MergeKey,
32}
33
34pub fn claim_scan_limit(max_batches: usize) -> i64 {
38 (max_batches as i64).saturating_add(32)
39}
40
41pub fn select_leading_session_command(candidates: &[ClaimCandidate]) -> usize {
48 if candidates
49 .first()
50 .is_some_and(|candidate| candidate.work_class == QueuedWorkClass::SessionCommand)
51 {
52 1
53 } else {
54 0
55 }
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)]
117pub struct QueuedWorkClaimLease {
118 pub claim_id: String,
119 pub lease_token: String,
120 pub fencing_token: u64,
121 pub session_lease_generation: u64,
122}
123
124impl QueuedWorkClaimLease {
125 pub fn derive(
126 head: &ClaimCandidate,
127 session_id: &str,
128 owner: &LeaseOwnerIdentity,
129 now_epoch_ms: u64,
130 session_lease_generation: u64,
131 ) -> Self {
132 let fencing_token = head.claim_fencing_token.saturating_add(1);
133 let claim_id = format!("qwc:{}:{fencing_token}", head.enqueue_seq);
134 let lease_token = format!(
135 "{:x}",
136 Sha256::digest(
137 format!(
138 "{}:{}:{}:{}:{}",
139 session_id, owner.owner_id, owner.incarnation_id, claim_id, now_epoch_ms
140 )
141 .as_bytes(),
142 )
143 );
144 Self {
145 claim_id,
146 lease_token,
147 fencing_token,
148 session_lease_generation,
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 ensure_completion_owns_all_batches(
174 completed: &QueuedWorkCompletion,
175 owned_rows: usize,
176) -> Result<(), StoreError> {
177 if owned_rows != completed.batch_ids.len() {
178 return Err(StoreError::QueuedWorkClaimSuperseded {
179 session_id: completed.session_id.clone(),
180 claim_id: completed.claim_id.clone(),
181 });
182 }
183 Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn candidate(
191 enqueue_seq: u64,
192 work_class: QueuedWorkClass,
193 delivery_policy: DeliveryPolicy,
194 slot_policy: SlotPolicy,
195 merge_key: MergeKey,
196 ) -> ClaimCandidate {
197 ClaimCandidate {
198 enqueue_seq,
199 claim_fencing_token: 0,
200 work_class,
201 delivery_policy,
202 slot_policy,
203 merge_key,
204 }
205 }
206
207 #[test]
208 fn exclusive_head_claims_exactly_one() {
209 let candidates = vec![
210 candidate(
211 1,
212 QueuedWorkClass::TurnWork,
213 DeliveryPolicy::EarliestSafeBoundary,
214 SlotPolicy::Exclusive,
215 MergeKey::Never,
216 ),
217 candidate(
218 2,
219 QueuedWorkClass::TurnWork,
220 DeliveryPolicy::EarliestSafeBoundary,
221 SlotPolicy::Exclusive,
222 MergeKey::Never,
223 ),
224 ];
225 assert_eq!(
226 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
227 1
228 );
229 }
230
231 #[test]
232 fn join_head_groups_matching_prefix_up_to_max() {
233 let join = |seq| {
234 candidate(
235 seq,
236 QueuedWorkClass::TurnWork,
237 DeliveryPolicy::EarliestSafeBoundary,
238 SlotPolicy::Join,
239 MergeKey::PayloadDefault,
240 )
241 };
242 let candidates = vec![join(1), join(2), join(3), join(4)];
243 assert_eq!(
244 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 3),
245 3
246 );
247 }
248
249 #[test]
250 fn join_group_breaks_on_policy_or_merge_key_mismatch() {
251 let candidates = vec![
252 candidate(
253 1,
254 QueuedWorkClass::TurnWork,
255 DeliveryPolicy::EarliestSafeBoundary,
256 SlotPolicy::Join,
257 MergeKey::Group("a".to_string()),
258 ),
259 candidate(
260 2,
261 QueuedWorkClass::TurnWork,
262 DeliveryPolicy::EarliestSafeBoundary,
263 SlotPolicy::Join,
264 MergeKey::Group("b".to_string()),
265 ),
266 ];
267 assert_eq!(
268 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
269 1
270 );
271 }
272
273 #[test]
274 fn active_turn_checkpoint_boundary_gates_on_delivery_policy() {
275 let candidates = vec![candidate(
276 1,
277 QueuedWorkClass::TurnWork,
278 DeliveryPolicy::AfterCurrentTurnCommit,
279 SlotPolicy::Exclusive,
280 MergeKey::Never,
281 )];
282 assert_eq!(
283 select_turn_work_claim_prefix(
284 &candidates,
285 QueuedWorkClaimBoundary::ActiveTurnCheckpoint,
286 8
287 ),
288 0
289 );
290 }
291
292 #[test]
293 fn leading_session_command_blocks_turn_work_claim() {
294 let candidates = vec![
295 candidate(
296 1,
297 QueuedWorkClass::SessionCommand,
298 DeliveryPolicy::EarliestSafeBoundary,
299 SlotPolicy::Exclusive,
300 MergeKey::Never,
301 ),
302 candidate(
303 2,
304 QueuedWorkClass::TurnWork,
305 DeliveryPolicy::EarliestSafeBoundary,
306 SlotPolicy::Exclusive,
307 MergeKey::Never,
308 ),
309 ];
310 assert_eq!(select_leading_session_command(&candidates), 1);
311 assert_eq!(
312 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
313 0
314 );
315 }
316
317 #[test]
318 fn later_session_command_does_not_join_turn_work_claim() {
319 let candidates = vec![
320 candidate(
321 1,
322 QueuedWorkClass::TurnWork,
323 DeliveryPolicy::EarliestSafeBoundary,
324 SlotPolicy::Join,
325 MergeKey::PayloadDefault,
326 ),
327 candidate(
328 2,
329 QueuedWorkClass::SessionCommand,
330 DeliveryPolicy::EarliestSafeBoundary,
331 SlotPolicy::Join,
332 MergeKey::PayloadDefault,
333 ),
334 ];
335 assert_eq!(select_leading_session_command(&candidates), 0);
336 assert_eq!(
337 select_turn_work_claim_prefix(&candidates, QueuedWorkClaimBoundary::Idle, 8),
338 1
339 );
340 }
341
342 #[test]
343 fn lease_derivation_is_deterministic_and_advances_fencing() {
344 let head = ClaimCandidate {
345 enqueue_seq: 7,
346 claim_fencing_token: 2,
347 work_class: QueuedWorkClass::TurnWork,
348 delivery_policy: DeliveryPolicy::EarliestSafeBoundary,
349 slot_policy: SlotPolicy::Exclusive,
350 merge_key: MergeKey::Never,
351 };
352 let owner = LeaseOwnerIdentity::opaque("owner", "owner:incarnation");
353 let lease = QueuedWorkClaimLease::derive(&head, "session", &owner, 1_000, 5);
354 assert_eq!(lease.fencing_token, 3);
355 assert_eq!(lease.claim_id, "qwc:7:3");
356 assert_eq!(lease.session_lease_generation, 5);
357 let again = QueuedWorkClaimLease::derive(&head, "session", &owner, 1_000, 5);
358 assert_eq!(lease.lease_token, again.lease_token);
359 }
360
361 #[test]
362 fn batch_id_includes_optional_nonce() {
363 let plain = derive_batch_id("session", Some("key"), 1_000, None);
364 let nonced = derive_batch_id("session", Some("key"), 1_000, Some(1));
365 assert_ne!(plain, nonced);
366 assert!(plain.starts_with("qwb:"));
367 }
368}