Skip to main content

keepsake_sqlx/repository/
expiry.rs

1use chrono::{DateTime, Utc};
2#[cfg(feature = "fulfillment-counters")]
3use keepsake::{ExpiryPolicy, FulfillmentSnapshot};
4#[cfg(feature = "fulfillment-counters")]
5use std::collections::BTreeMap;
6use uuid::Uuid;
7
8#[cfg(feature = "fulfillment-counters")]
9use super::FulfilledExpiryCandidate;
10use super::{
11    KeepsakeRepository, RelationCache, RepositoryResult, TimedExpiryCandidate, validate_limit,
12};
13
14impl<C> KeepsakeRepository<C>
15where
16    C: RelationCache,
17{
18    /// Lists due timed expiry candidates in stable batch order.
19    pub async fn due_timed_expiry(
20        &self,
21        now: DateTime<Utc>,
22        limit: i64,
23    ) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
24        let limit = validate_limit(limit)?;
25        let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
26            r"
27            select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
28            from keepsakes k
29            join keepsake_relation_definitions r on r.id = k.relation_id
30            where k.state = 'applied'
31              and r.enabled
32              and k.expires_at is not null
33              and k.expires_at <= $1
34            order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
35            limit $2
36            ",
37        )
38        .bind(now)
39        .bind(limit)
40        .fetch_all(&self.pool)
41        .await?;
42        Ok(rows)
43    }
44
45    /// Reads the persisted fulfillment snapshot (counters and checklist) for a keepsake.
46    #[cfg(feature = "fulfillment-counters")]
47    pub async fn fulfillment_snapshot(
48        &self,
49        keepsake_id: Uuid,
50    ) -> RepositoryResult<FulfillmentSnapshot> {
51        let counters = sqlx::query_as::<_, (String, i64)>(
52            r"
53            select key, value
54            from keepsake_fulfillment_counters
55            where keepsake_id = $1
56            ",
57        )
58        .bind(keepsake_id)
59        .fetch_all(&self.pool)
60        .await?
61        .into_iter()
62        .collect::<BTreeMap<_, _>>();
63
64        let checklist = sqlx::query_as::<_, (String, bool)>(
65            r"
66            select item, complete
67            from keepsake_fulfillment_checklist
68            where keepsake_id = $1
69            ",
70        )
71        .bind(keepsake_id)
72        .fetch_all(&self.pool)
73        .await?
74        .into_iter()
75        .collect::<BTreeMap<_, _>>();
76
77        Ok(FulfillmentSnapshot {
78            counters,
79            checklist,
80        })
81    }
82
83    /// Lists fulfillment expiry candidates in stable batch order.
84    #[cfg(feature = "fulfillment-counters")]
85    pub async fn due_fulfilled_expiry(
86        &self,
87        limit: i64,
88    ) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
89        let limit = validate_limit(limit)?;
90        let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
91            r"
92            select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
93            from keepsakes k
94            join keepsake_relation_definitions r on r.id = k.relation_id
95            where k.state = 'applied'
96              and r.enabled
97              and k.expiry_policy->>'type' = 'when_fulfilled'
98            order by k.relation_id, k.subject_kind, k.subject_id, k.id
99            limit $1
100            ",
101        )
102        .bind(limit)
103        .fetch_all(&self.pool)
104        .await?;
105        Ok(rows)
106    }
107
108    /// Expires a stable batch whose persisted fulfillment snapshots satisfy policy.
109    #[cfg(feature = "fulfillment-counters")]
110    pub async fn expire_due_fulfilled(
111        &self,
112        now: DateTime<Utc>,
113        limit: i64,
114    ) -> RepositoryResult<u64> {
115        let limit = validate_limit(limit)?;
116        let mut tx = self.pool.begin().await?;
117        let target =
118            usize::try_from(limit).map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
119        let mut after = None;
120        let mut satisfied_ids = Vec::new();
121
122        while satisfied_ids.len() < target {
123            let remaining = i64::try_from(target - satisfied_ids.len())
124                .map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
125            let candidates = due_fulfilled_expiry_tx(&mut tx, after.as_ref(), remaining).await?;
126            if candidates.is_empty() {
127                break;
128            }
129            after = candidates.last().map(FulfilledExpiryCursor::from);
130            satisfied_ids.extend(satisfied_fulfillment_ids_tx(&mut tx, candidates).await?);
131        }
132
133        if satisfied_ids.is_empty() {
134            tx.commit().await?;
135            return Ok(0);
136        }
137
138        let result = sqlx::query(
139            r"
140            update keepsakes
141            set state = 'expired', fulfilled_at = $2, updated_at = $2
142            where id = any($1)
143              and state = 'applied'
144              and exists (
145                select 1
146                from keepsake_relation_definitions r
147                where r.id = keepsakes.relation_id and r.enabled
148              )
149            ",
150        )
151        .bind(&satisfied_ids)
152        .bind(now)
153        .execute(&mut *tx)
154        .await?;
155        tx.commit().await?;
156        Ok(result.rows_affected())
157    }
158
159    /// Expires a stable batch of due timed keepsakes.
160    pub async fn expire_due_timed(&self, now: DateTime<Utc>, limit: i64) -> RepositoryResult<u64> {
161        let limit = validate_limit(limit)?;
162        let mut tx = self.pool.begin().await?;
163        let rows = due_timed_expiry_tx(&mut tx, now, limit).await?;
164        let ids = rows
165            .into_iter()
166            .map(|row| row.keepsake_id)
167            .collect::<Vec<Uuid>>();
168        if ids.is_empty() {
169            tx.commit().await?;
170            return Ok(0);
171        }
172
173        let result = sqlx::query(
174            r"
175            update keepsakes
176            set state = 'expired', updated_at = $2
177            where id = any($1)
178              and state = 'applied'
179              and exists (
180                select 1
181                from keepsake_relation_definitions r
182                where r.id = keepsakes.relation_id and r.enabled
183              )
184            ",
185        )
186        .bind(&ids)
187        .bind(now)
188        .execute(&mut *tx)
189        .await?;
190        tx.commit().await?;
191        Ok(result.rows_affected())
192    }
193
194    /// Upserts a simple fulfillment counter projection.
195    #[cfg(feature = "fulfillment-counters")]
196    pub async fn upsert_counter_projection(
197        &self,
198        keepsake_id: Uuid,
199        key: &str,
200        value: i64,
201        observed_at: DateTime<Utc>,
202    ) -> RepositoryResult<()> {
203        sqlx::query(
204            r"
205            insert into keepsake_fulfillment_counters
206                (keepsake_id, key, value, observed_at)
207            values ($1, $2, $3, $4)
208            on conflict (keepsake_id, key) do update set
209                value = excluded.value,
210                observed_at = excluded.observed_at
211            ",
212        )
213        .bind(keepsake_id)
214        .bind(key)
215        .bind(value)
216        .bind(observed_at)
217        .execute(&self.pool)
218        .await?;
219        Ok(())
220    }
221
222    /// Atomically adds `delta` to a fulfillment counter and returns the new value.
223    ///
224    /// Unlike [`upsert_counter_projection`](Self::upsert_counter_projection), the
225    /// increment is computed in the database, so concurrent writers cannot lose
226    /// updates to a read-modify-write race.
227    #[cfg(feature = "fulfillment-counters")]
228    pub async fn increment_counter_projection(
229        &self,
230        keepsake_id: Uuid,
231        key: &str,
232        delta: i64,
233        observed_at: DateTime<Utc>,
234    ) -> RepositoryResult<i64> {
235        let (value,) = sqlx::query_as::<_, (i64,)>(
236            r"
237            insert into keepsake_fulfillment_counters
238                (keepsake_id, key, value, observed_at)
239            values ($1, $2, $3, $4)
240            on conflict (keepsake_id, key) do update set
241                value = keepsake_fulfillment_counters.value + excluded.value,
242                observed_at = excluded.observed_at
243            returning value
244            ",
245        )
246        .bind(keepsake_id)
247        .bind(key)
248        .bind(delta)
249        .bind(observed_at)
250        .fetch_one(&self.pool)
251        .await?;
252        Ok(value)
253    }
254
255    /// Upserts a checklist item completion projection.
256    #[cfg(feature = "fulfillment-counters")]
257    pub async fn upsert_checklist_projection(
258        &self,
259        keepsake_id: Uuid,
260        item: &str,
261        complete: bool,
262        observed_at: DateTime<Utc>,
263    ) -> RepositoryResult<()> {
264        sqlx::query(
265            r"
266            insert into keepsake_fulfillment_checklist
267                (keepsake_id, item, complete, observed_at)
268            values ($1, $2, $3, $4)
269            on conflict (keepsake_id, item) do update set
270                complete = excluded.complete,
271                observed_at = excluded.observed_at
272            ",
273        )
274        .bind(keepsake_id)
275        .bind(item)
276        .bind(complete)
277        .bind(observed_at)
278        .execute(&self.pool)
279        .await?;
280        Ok(())
281    }
282}
283
284#[cfg(feature = "fulfillment-counters")]
285async fn satisfied_fulfillment_ids_tx(
286    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
287    candidates: Vec<FulfilledExpiryCandidate>,
288) -> RepositoryResult<Vec<Uuid>> {
289    let candidate_ids = candidates
290        .iter()
291        .map(|candidate| candidate.keepsake_id)
292        .collect::<Vec<_>>();
293    if candidate_ids.is_empty() {
294        return Ok(Vec::new());
295    }
296
297    let counter_rows = sqlx::query_as::<_, (Uuid, String, i64)>(
298        r"
299            select keepsake_id, key, value
300            from keepsake_fulfillment_counters
301            where keepsake_id = any($1)
302            ",
303    )
304    .bind(&candidate_ids)
305    .fetch_all(&mut **tx)
306    .await?;
307    let mut counters_by_keepsake = BTreeMap::<Uuid, BTreeMap<String, i64>>::new();
308    for (keepsake_id, key, value) in counter_rows {
309        counters_by_keepsake
310            .entry(keepsake_id)
311            .or_default()
312            .insert(key, value);
313    }
314
315    let checklist_rows = sqlx::query_as::<_, (Uuid, String, bool)>(
316        r"
317            select keepsake_id, item, complete
318            from keepsake_fulfillment_checklist
319            where keepsake_id = any($1)
320            ",
321    )
322    .bind(&candidate_ids)
323    .fetch_all(&mut **tx)
324    .await?;
325    let mut checklist_by_keepsake = BTreeMap::<Uuid, BTreeMap<String, bool>>::new();
326    for (keepsake_id, item, complete) in checklist_rows {
327        checklist_by_keepsake
328            .entry(keepsake_id)
329            .or_default()
330            .insert(item, complete);
331    }
332
333    Ok(candidates
334        .into_iter()
335        .filter_map(|candidate| {
336            let ExpiryPolicy::WhenFulfilled { policy } = candidate.expiry_policy else {
337                return None;
338            };
339            let snapshot = FulfillmentSnapshot {
340                counters: counters_by_keepsake
341                    .remove(&candidate.keepsake_id)
342                    .unwrap_or_default(),
343                checklist: checklist_by_keepsake
344                    .remove(&candidate.keepsake_id)
345                    .unwrap_or_default(),
346            };
347            policy
348                .is_fulfilled(&snapshot)
349                .then_some(candidate.keepsake_id)
350        })
351        .collect())
352}
353
354async fn due_timed_expiry_tx(
355    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
356    now: DateTime<Utc>,
357    limit: i64,
358) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
359    let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
360        r"
361        select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
362        from keepsakes k
363        join keepsake_relation_definitions r on r.id = k.relation_id
364        where k.state = 'applied'
365          and r.enabled
366          and k.expires_at is not null
367          and k.expires_at <= $1
368        order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
369        limit $2
370        for update of k skip locked
371        for share of r
372        ",
373    )
374    .bind(now)
375    .bind(limit)
376    .fetch_all(&mut **tx)
377    .await?;
378    Ok(rows)
379}
380
381#[cfg(feature = "fulfillment-counters")]
382async fn due_fulfilled_expiry_tx(
383    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
384    after: Option<&FulfilledExpiryCursor>,
385    limit: i64,
386) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
387    let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
388        r"
389        select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
390        from keepsakes k
391        join keepsake_relation_definitions r on r.id = k.relation_id
392        where k.state = 'applied'
393          and r.enabled
394          and k.expiry_policy->>'type' = 'when_fulfilled'
395          and (
396            $2::uuid is null
397            or (k.relation_id, k.subject_kind, k.subject_id, k.id) > ($2, $3::text, $4::text, $5::uuid)
398          )
399        order by k.relation_id, k.subject_kind, k.subject_id, k.id
400        limit $1
401        for update of k skip locked
402        for share of r
403        ",
404    )
405    .bind(limit)
406    .bind(after.map(|cursor| cursor.relation_id))
407    .bind(after.map(|cursor| cursor.subject_kind.as_str()))
408    .bind(after.map(|cursor| cursor.subject_id.as_str()))
409    .bind(after.map(|cursor| cursor.keepsake_id))
410    .fetch_all(&mut **tx)
411    .await?;
412    Ok(rows)
413}
414
415#[cfg(feature = "fulfillment-counters")]
416#[derive(Debug, Clone)]
417struct FulfilledExpiryCursor {
418    relation_id: Uuid,
419    subject_kind: String,
420    subject_id: String,
421    keepsake_id: Uuid,
422}
423
424#[cfg(feature = "fulfillment-counters")]
425impl From<&FulfilledExpiryCandidate> for FulfilledExpiryCursor {
426    fn from(candidate: &FulfilledExpiryCandidate) -> Self {
427        Self {
428            relation_id: candidate.relation_id,
429            subject_kind: candidate.subject_kind.clone(),
430            subject_id: candidate.subject_id.clone(),
431            keepsake_id: candidate.keepsake_id,
432        }
433    }
434}