1use std::{collections::BTreeMap, num::NonZeroU32};
4
5use chrono::{DateTime, TimeDelta, Utc};
6use thiserror::Error;
7
8use crate::{
9 DeviceRegistry, DeviceToken, PushDeliveryStatus, PushError, PushOutcome, PushReceiptSource,
10 PushStoreError, PushStoreFuture, PushTicketId,
11};
12
13pub const RECEIPT_POLL_DELAY: TimeDelta = TimeDelta::minutes(15);
17
18pub const RECEIPT_RETENTION: TimeDelta = TimeDelta::hours(24);
23
24#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct PendingReceipt {
27 pub ticket: PushTicketId,
29 pub token: DeviceToken,
31 pub sent_at: DateTime<Utc>,
33}
34
35pub trait PendingReceiptStore: Send + Sync {
42 fn record(
46 &self,
47 receipts: Vec<PendingReceipt>,
48 due_at: DateTime<Utc>,
49 ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
50
51 fn take_due(
54 &self,
55 now: DateTime<Utc>,
56 retry_at: DateTime<Utc>,
57 limit: NonZeroU32,
58 ) -> PushStoreFuture<'_, Result<Vec<PendingReceipt>, PushStoreError>>;
59
60 fn delete(
62 &self,
63 tickets: Vec<PushTicketId>,
64 ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
65
66 fn purge(
71 &self,
72 sent_before: DateTime<Utc>,
73 limit: NonZeroU32,
74 ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
75
76 fn record_accepted(
83 &self,
84 outcomes: &[PushOutcome],
85 sent_at: DateTime<Utc>,
86 ) -> PushStoreFuture<'_, Result<u64, PushStoreError>> {
87 let receipts = accepted_receipts(outcomes, sent_at);
88 if receipts.is_empty() {
89 return Box::pin(std::future::ready(Ok(0)));
90 }
91 self.record(receipts, sent_at + RECEIPT_POLL_DELAY)
92 }
93}
94
95#[must_use]
97pub fn accepted_receipts(outcomes: &[PushOutcome], sent_at: DateTime<Utc>) -> Vec<PendingReceipt> {
98 outcomes
99 .iter()
100 .filter_map(|outcome| match &outcome.status {
101 PushDeliveryStatus::Accepted(ticket) => Some(PendingReceipt {
102 ticket: ticket.clone(),
103 token: outcome.token.clone(),
104 sent_at,
105 }),
106 PushDeliveryStatus::Delivered | PushDeliveryStatus::Rejected(_) => None,
107 })
108 .collect()
109}
110
111#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
113pub struct ReceiptPoll {
114 pub taken: u64,
116 pub settled: u64,
118 pub invalidated: u64,
120}
121
122#[derive(Clone, Debug, Error, Eq, PartialEq)]
126pub enum ReceiptPollError {
127 #[error(transparent)]
129 Store(#[from] PushStoreError),
130 #[error(transparent)]
132 Push(#[from] PushError),
133}
134
135pub async fn poll_pending_receipts<S, P, R>(
148 source: &S,
149 pending: &P,
150 registry: &R,
151 now: DateTime<Utc>,
152 limit: NonZeroU32,
153) -> Result<ReceiptPoll, ReceiptPollError>
154where
155 S: PushReceiptSource + ?Sized,
156 P: PendingReceiptStore + ?Sized,
157 R: DeviceRegistry + ?Sized,
158{
159 let due = pending
160 .take_due(now, now + RECEIPT_POLL_DELAY, limit)
161 .await?;
162 let taken = count(due.len());
163 if due.is_empty() {
164 return Ok(ReceiptPoll::default());
165 }
166 let receipts = source
167 .receipts(due.iter().map(|receipt| receipt.ticket.clone()).collect())
168 .await?;
169
170 let mut dead_by_send = BTreeMap::<DateTime<Utc>, Vec<DeviceToken>>::new();
171 let mut settled = Vec::new();
172 for pending_receipt in due {
173 let Some(receipt) = receipts.get(&pending_receipt.ticket) else {
174 continue;
175 };
176 if receipt.is_token_dead() {
177 dead_by_send
178 .entry(pending_receipt.sent_at)
179 .or_default()
180 .push(pending_receipt.token);
181 }
182 settled.push(pending_receipt.ticket);
183 }
184
185 let mut invalidated = 0;
186 for (sent_at, mut tokens) in dead_by_send {
187 tokens.sort();
188 tokens.dedup();
189 invalidated += registry.invalidate(tokens, sent_at).await?;
190 }
191 let settled = if settled.is_empty() {
192 0
193 } else {
194 pending.delete(settled).await?
195 };
196 Ok(ReceiptPoll {
197 taken,
198 settled,
199 invalidated,
200 })
201}
202
203fn count(len: usize) -> u64 {
204 u64::try_from(len).unwrap_or(u64::MAX)
205}
206
207#[cfg(all(test, feature = "test-support"))]
208mod tests {
209 use uuid::Uuid;
210
211 use super::*;
212 use crate::{
213 DevicePlatform, DeviceRegistration, FakePushSender, MemoryDeviceRegistry,
214 MemoryPendingReceiptStore, PushMessage, PushReceipt, PushRejection, PushSender,
215 };
216
217 type TestResult = Result<(), Box<dyn std::error::Error>>;
218
219 const LIMIT: NonZeroU32 = NonZeroU32::new(10).expect("ten is not zero");
220
221 fn at(minutes: i64) -> DateTime<Utc> {
222 DateTime::UNIX_EPOCH + TimeDelta::minutes(minutes)
223 }
224
225 fn token(value: &str) -> DeviceToken {
226 DeviceToken::new(value).expect("valid test token")
227 }
228
229 async fn register(registry: &MemoryDeviceRegistry, owner_id: Uuid, value: &str, minutes: i64) {
230 registry
231 .register(DeviceRegistration::new(
232 owner_id,
233 token(value),
234 DevicePlatform::Ios,
235 at(minutes),
236 ))
237 .await
238 .expect("memory register");
239 }
240
241 async fn registered(registry: &MemoryDeviceRegistry, owner_id: Uuid) -> Vec<String> {
242 let mut tokens = registry
243 .list_for_owner(owner_id)
244 .await
245 .expect("memory list")
246 .into_iter()
247 .map(|device| device.token.expose().to_owned())
248 .collect::<Vec<_>>();
249 tokens.sort();
250 tokens
251 }
252
253 #[tokio::test]
254 async fn a_late_device_not_registered_receipt_removes_the_token() -> TestResult {
255 let sender = FakePushSender::new();
256 let pending = MemoryPendingReceiptStore::new();
257 let registry = MemoryDeviceRegistry::new();
258 let owner = Uuid::now_v7();
259 let names = ["fine", "gone", "reinstalled", "slow"];
260 for name in names {
261 register(®istry, owner, name, 0).await;
262 sender.accept_without_receipt(token(name)).await;
263 }
264
265 let sent_at = at(1);
266 let outcomes = sender
267 .send(
268 names
269 .iter()
270 .map(|name| PushMessage::new(token(name), "t", "b"))
271 .collect(),
272 )
273 .await?;
274 assert_eq!(pending.record_accepted(&outcomes, sent_at).await?, 4);
275 assert_eq!(
276 poll_pending_receipts(&sender, &pending, ®istry, at(5), LIMIT).await?,
277 ReceiptPoll::default(),
278 "nothing is due before the poll delay"
279 );
280
281 let dead = PushReceipt::Rejected(PushRejection::DeviceNotRegistered);
282 sender
283 .settle_receipt(token("fine"), PushReceipt::Delivered)
284 .await;
285 sender.settle_receipt(token("gone"), dead.clone()).await;
286 sender.settle_receipt(token("reinstalled"), dead).await;
287 register(®istry, owner, "reinstalled", 10).await;
288
289 let due_at = sent_at + RECEIPT_POLL_DELAY;
290 let poll = poll_pending_receipts(&sender, &pending, ®istry, due_at, LIMIT).await?;
291 assert_eq!(
292 poll,
293 ReceiptPoll {
294 taken: 4,
295 settled: 3,
296 invalidated: 1
297 }
298 );
299 assert_eq!(
300 registered(®istry, owner).await,
301 ["fine", "reinstalled", "slow"],
302 "a registration after the send outlives the receipt"
303 );
304 let still_pending = pending.pending();
305 assert_eq!(still_pending.len(), 1);
306 assert_eq!(still_pending[0].token, token("slow"));
307
308 assert_eq!(
309 poll_pending_receipts(&sender, &pending, ®istry, due_at, LIMIT)
310 .await?
311 .taken,
312 0,
313 "a still-pending ticket waits for the next delay"
314 );
315 Ok(())
316 }
317
318 #[tokio::test]
319 async fn a_failed_receipt_request_keeps_the_tickets() -> TestResult {
320 let sender = FakePushSender::new();
321 let pending = MemoryPendingReceiptStore::new();
322 let registry = MemoryDeviceRegistry::new();
323 sender.accept_without_receipt(token("slow")).await;
324 let outcomes = sender
325 .send(vec![PushMessage::new(token("slow"), "t", "b")])
326 .await?;
327 pending.record_accepted(&outcomes, at(0)).await?;
328
329 sender
330 .fail_with(PushError::Transport {
331 class: baukit_http::RetryClass::Unavailable,
332 })
333 .await;
334 let error = poll_pending_receipts(&sender, &pending, ®istry, at(20), LIMIT)
335 .await
336 .expect_err("the receipt request fails");
337 assert!(matches!(error, ReceiptPollError::Push(_)));
338 assert_eq!(pending.pending().len(), 1);
339 Ok(())
340 }
341
342 #[tokio::test]
343 async fn settled_outcomes_record_nothing() -> TestResult {
344 let pending = MemoryPendingReceiptStore::new();
345 let outcomes = [PushOutcome {
346 token: token("fine"),
347 status: PushDeliveryStatus::Delivered,
348 }];
349 assert_eq!(pending.record_accepted(&outcomes, at(0)).await?, 0);
350 assert!(accepted_receipts(&outcomes, at(0)).is_empty());
351 Ok(())
352 }
353}