keepsake_sqlx/repository/
expiry.rs1use 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 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 #[cfg(feature = "fulfillment-counters")]
50 pub async fn fulfillment_snapshot(
51 &self,
52 keepsake_id: Uuid,
53 ) -> RepositoryResult<FulfillmentSnapshot> {
54 let counters = sqlx::query_as::<_, (String, i64)>(
55 r"
56 select key, value
57 from keepsake_fulfillment_counters
58 where keepsake_id = $1
59 ",
60 )
61 .bind(keepsake_id)
62 .fetch_all(&self.pool)
63 .await?
64 .into_iter()
65 .collect::<BTreeMap<_, _>>();
66
67 Ok(FulfillmentSnapshot {
68 counters,
69 checklist: BTreeMap::new(),
70 })
71 }
72
73 #[cfg(feature = "fulfillment-counters")]
75 pub async fn due_fulfilled_expiry(
76 &self,
77 limit: i64,
78 ) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
79 let limit = validate_limit(limit)?;
80 let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
81 r"
82 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
83 from keepsakes k
84 where k.state = 'applied'
85 and k.expiry_policy->>'type' = 'when_fulfilled'
86 order by k.relation_id, k.subject_kind, k.subject_id, k.id
87 limit $1
88 ",
89 )
90 .bind(limit)
91 .fetch_all(&self.pool)
92 .await?;
93 Ok(rows)
94 }
95
96 #[cfg(feature = "fulfillment-counters")]
102 pub async fn expire_due_fulfilled(
103 &self,
104 now: DateTime<Utc>,
105 limit: i64,
106 ) -> RepositoryResult<u64> {
107 let limit = validate_limit(limit)?;
108 let mut tx = self.pool.begin().await?;
109 let candidates = due_fulfilled_expiry_tx(&mut tx, limit).await?;
110 let candidate_ids = candidates
111 .iter()
112 .map(|candidate| candidate.keepsake_id)
113 .collect::<Vec<_>>();
114
115 if candidate_ids.is_empty() {
116 tx.commit().await?;
117 return Ok(0);
118 }
119
120 let counter_rows = sqlx::query_as::<_, (Uuid, String, i64)>(
121 r"
122 select keepsake_id, key, value
123 from keepsake_fulfillment_counters
124 where keepsake_id = any($1)
125 ",
126 )
127 .bind(&candidate_ids)
128 .fetch_all(&mut *tx)
129 .await?;
130 let mut counters_by_keepsake = BTreeMap::<Uuid, BTreeMap<String, i64>>::new();
131 for (keepsake_id, key, value) in counter_rows {
132 counters_by_keepsake
133 .entry(keepsake_id)
134 .or_default()
135 .insert(key, value);
136 }
137
138 let satisfied_ids = candidates
139 .into_iter()
140 .filter_map(|candidate| {
141 let ExpiryPolicy::WhenFulfilled { policy } = candidate.expiry_policy else {
142 return None;
143 };
144 let snapshot = FulfillmentSnapshot {
145 counters: counters_by_keepsake
146 .remove(&candidate.keepsake_id)
147 .unwrap_or_default(),
148 checklist: BTreeMap::new(),
149 };
150 policy
151 .is_fulfilled(&snapshot)
152 .then_some(candidate.keepsake_id)
153 })
154 .collect::<Vec<_>>();
155
156 if satisfied_ids.is_empty() {
157 tx.commit().await?;
158 return Ok(0);
159 }
160
161 let result = sqlx::query(
162 r"
163 update keepsakes
164 set state = 'expired', fulfilled_at = $2, updated_at = $2
165 where id = any($1)
166 and state = 'applied'
167 and exists (
168 select 1
169 from keepsake_relation_definitions r
170 where r.id = keepsakes.relation_id and r.enabled
171 )
172 ",
173 )
174 .bind(&satisfied_ids)
175 .bind(now)
176 .execute(&mut *tx)
177 .await?;
178 tx.commit().await?;
179 Ok(result.rows_affected())
180 }
181
182 pub async fn expire_due_timed(&self, now: DateTime<Utc>, limit: i64) -> RepositoryResult<u64> {
184 let limit = validate_limit(limit)?;
185 let mut tx = self.pool.begin().await?;
186 let rows = due_timed_expiry_tx(&mut tx, now, limit).await?;
187 let ids = rows
188 .into_iter()
189 .map(|row| row.keepsake_id)
190 .collect::<Vec<Uuid>>();
191 if ids.is_empty() {
192 tx.commit().await?;
193 return Ok(0);
194 }
195
196 let result = sqlx::query(
197 r"
198 update keepsakes
199 set state = 'expired', updated_at = $2
200 where id = any($1)
201 and state = 'applied'
202 and exists (
203 select 1
204 from keepsake_relation_definitions r
205 where r.id = keepsakes.relation_id and r.enabled
206 )
207 ",
208 )
209 .bind(&ids)
210 .bind(now)
211 .execute(&mut *tx)
212 .await?;
213 tx.commit().await?;
214 Ok(result.rows_affected())
215 }
216
217 #[cfg(feature = "fulfillment-counters")]
219 pub async fn upsert_counter_projection(
220 &self,
221 keepsake_id: Uuid,
222 key: &str,
223 value: i64,
224 observed_at: DateTime<Utc>,
225 ) -> RepositoryResult<()> {
226 sqlx::query(
227 r"
228 insert into keepsake_fulfillment_counters
229 (keepsake_id, key, value, observed_at)
230 values ($1, $2, $3, $4)
231 on conflict (keepsake_id, key) do update set
232 value = excluded.value,
233 observed_at = excluded.observed_at
234 ",
235 )
236 .bind(keepsake_id)
237 .bind(key)
238 .bind(value)
239 .bind(observed_at)
240 .execute(&self.pool)
241 .await?;
242 Ok(())
243 }
244}
245
246async fn due_timed_expiry_tx(
247 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
248 now: DateTime<Utc>,
249 limit: i64,
250) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
251 let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
252 r"
253 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
254 from keepsakes k
255 join keepsake_relation_definitions r on r.id = k.relation_id
256 where k.state = 'applied'
257 and r.enabled
258 and k.expires_at is not null
259 and k.expires_at <= $1
260 order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
261 limit $2
262 for update of k skip locked
263 for share of r
264 ",
265 )
266 .bind(now)
267 .bind(limit)
268 .fetch_all(&mut **tx)
269 .await?;
270 Ok(rows)
271}
272
273#[cfg(feature = "fulfillment-counters")]
274async fn due_fulfilled_expiry_tx(
275 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
276 limit: i64,
277) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
278 let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
279 r"
280 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
281 from keepsakes k
282 where k.state = 'applied'
283 and k.expiry_policy->>'type' = 'when_fulfilled'
284 order by k.relation_id, k.subject_kind, k.subject_id, k.id
285 limit $1
286 for update of k skip locked
287 ",
288 )
289 .bind(limit)
290 .fetch_all(&mut **tx)
291 .await?;
292 Ok(rows)
293}