Skip to main content

keepsake_sqlx/repository/expiry/
fulfillment.rs

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