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::{DeliveryPolicy, MergeKey, QueuedWorkClaimBoundary, QueuedWorkCompletion, SlotPolicy};
18
19/// Decoded claim-relevant fields of one ready queued-work batch row.
20///
21/// Backends build these from their candidate rows, presented in
22/// `enqueue_seq` ascending order and already filtered to
23/// `available_at_ms <= now` with no live claim.
24#[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
34/// How many candidate rows a backend should scan when selecting up to
35/// `max_batches` claimable batches. Joinable groups are matched as a prefix,
36/// so scanning a bounded surplus keeps one round trip sufficient.
37pub fn claim_scan_limit(max_batches: usize) -> i64 {
38    (max_batches as i64).saturating_add(32)
39}
40
41/// Select a leading session-command batch.
42///
43/// Returns `1` only when the earliest ready claimable batch is a
44/// [`QueuedWorkClass::SessionCommand`]. Session commands are intentionally
45/// claimed one batch at a time so the runtime applies each mutation and
46/// completion through its normal fenced commit path before moving to the next.
47pub 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
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. `session_lease_generation` is the caller's live session-execution-lease
114/// fencing token; the backend records it so the claim is live exactly while
115/// that generation still holds the session lease (see ADR 0029).
116#[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
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 completion-fencing decision: a completion may delete its
171/// batches only when the live store still shows the claim owning every one
172/// of them (`owned_rows` rows matched the claim id + lease token).
173pub 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}