keepsake_sqlx/repository/expiry/
fulfillment.rs1use 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 #[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 #[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 #[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 #[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 #[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 #[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}