Skip to main content

lash_core/store/
queued_work.rs

1//! Dialect-independent queued-work claim logic shared by durable backends.
2//!
3//! The SQL backends (sqlite, postgres) load candidate batch rows ordered by
4//! `enqueue_seq` and pre-filtered to ready batches that are not held by a
5//! live claim, then apply the same pure state machine: a delivery-policy
6//! boundary gate, slot-policy/merge-key prefix grouping, and fencing-token /
7//! lease derivation. That state machine lives here so the backends own only
8//! their SQL reads and writes while the claim contract has a single
9//! implementation, exercised against every backend by the shared
10//! `runtime_persistence` conformance suite.
11
12use 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/// Decoded claim-relevant fields of one ready queued-work batch row.
23///
24/// Backends build these from their candidate rows, presented in
25/// `enqueue_seq` ascending order and already filtered to
26/// `available_at_ms <= now` with no live claim.
27#[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
37/// How many candidate rows a backend should scan when selecting up to
38/// `max_batches` claimable batches. Joinable groups are matched as a prefix,
39/// so scanning a bounded surplus keeps one round trip sufficient.
40pub fn claim_scan_limit(max_batches: usize) -> i64 {
41    (max_batches as i64).saturating_add(32)
42}
43
44/// Select a leading session-command batch.
45///
46/// Returns `1` only when the earliest ready claimable batch is a
47/// [`QueuedWorkClass::SessionCommand`]. Session commands are intentionally
48/// claimed one batch at a time so the runtime applies each mutation and
49/// completion through its normal fenced commit path before moving to the next.
50pub 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
58/// Select the prefix of turn-work `candidates` that a single claim may take.
59///
60/// Returns the number of leading candidates to claim (`0` means no claim):
61///
62/// * The queue head must be [`QueuedWorkClass::TurnWork`]. Earlier ready
63///   session commands are never skipped or materialized as turn input.
64/// * An [`QueuedWorkClaimBoundary::ActiveTurnCheckpoint`] boundary only
65///   admits work whose head batch is
66///   [`DeliveryPolicy::EarliestSafeBoundary`].
67/// * An [`SlotPolicy::Exclusive`] head claims exactly one batch.
68/// * A [`SlotPolicy::Join`] head extends through immediately following
69///   `Join` batches with the same delivery policy and merge key, up to
70///   `max_batches`.
71pub 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/// A freshly derived lease for a selected claim prefix.
109///
110/// The fencing token advances past the head batch's last observed token, the
111/// claim id is stable for (head batch, fencing token), and the lease token is
112/// an opaque proof-of-ownership digest the backend stamps on every claimed
113/// row.
114#[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
153/// Derive the durable id for a newly enqueued batch.
154///
155/// `nonce` disambiguates batches enqueued within the same millisecond;
156/// backends whose id uniqueness already comes from elsewhere pass `None`.
157pub 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
170/// Apply the shared lease-renewal decision: the lease holds only when every
171/// batch row in the claim accepted the new expiry stamp.
172pub 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
189/// Apply the shared completion-fencing decision: a completion may delete its
190/// batches only when the live store still shows the claim owning every one
191/// of them (`owned_rows` rows matched the claim id + lease token).
192pub 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}