Skip to main content

baukit_push/
pending_receipt.rs

1//! Deferred receipt polling for notifications the provider accepted but had not settled.
2
3use 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
13/// How long a recorded ticket waits before its first poll, and between polls.
14///
15/// Expo recommends checking receipts 15 minutes after the send.
16pub const RECEIPT_POLL_DELAY: TimeDelta = TimeDelta::minutes(15);
17
18/// How long the provider keeps a receipt after the send.
19///
20/// Expo clears receipts after 24 hours. A ticket older than this never
21/// settles; [`PendingReceiptStore::purge`] removes it.
22pub const RECEIPT_RETENTION: TimeDelta = TimeDelta::hours(24);
23
24/// An accepted notification whose receipt has not been read yet.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct PendingReceipt {
27    /// The ticket the provider issued for the notification.
28    pub ticket: PushTicketId,
29    /// The device token the notification went to.
30    pub token: DeviceToken,
31    /// The instant the send started; invalidation compares it.
32    pub sent_at: DateTime<Utc>,
33}
34
35/// Storage port for tickets whose receipts are still outstanding.
36///
37/// Each ticket carries a due instant. [`PendingReceiptStore::take_due`] moves
38/// the tickets it returns to a later due instant, so concurrent pollers take
39/// disjoint batches and a ticket that stays unsettled goes to the back of the
40/// queue instead of blocking newer ones.
41pub trait PendingReceiptStore: Send + Sync {
42    /// Records tickets first due at `due_at`; returns how many were new.
43    ///
44    /// A ticket that is already recorded is left unchanged.
45    fn record(
46        &self,
47        receipts: Vec<PendingReceipt>,
48        due_at: DateTime<Utc>,
49    ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
50
51    /// Takes up to `limit` tickets due at or before `now`, oldest due first,
52    /// and makes them due again at `retry_at`.
53    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    /// Deletes tickets whose receipts settled; returns how many went.
61    fn delete(
62        &self,
63        tickets: Vec<PushTicketId>,
64    ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
65
66    /// Deletes up to `limit` tickets sent before `sent_before`; returns how many went.
67    ///
68    /// Pass `now - RECEIPT_RETENTION`. Call it again until it returns less
69    /// than `limit`.
70    fn purge(
71        &self,
72        sent_before: DateTime<Utc>,
73        limit: NonZeroU32,
74    ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
75
76    /// Records every [`PushDeliveryStatus::Accepted`] outcome of one send.
77    ///
78    /// Call it after [`PushSender::send`](crate::PushSender::send) with the
79    /// instant the send started. The tickets are first due
80    /// [`RECEIPT_POLL_DELAY`] later. Outcomes without an accepted ticket cost
81    /// no store round trip.
82    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/// Returns a pending receipt for each accepted outcome of one send.
96#[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/// What one [`poll_pending_receipts`] run did.
112#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
113pub struct ReceiptPoll {
114    /// Tickets taken from the store. Fewer than the limit means the queue is drained.
115    pub taken: u64,
116    /// Tickets whose receipt settled and that were deleted.
117    pub settled: u64,
118    /// Device tokens removed from the registry.
119    pub invalidated: u64,
120}
121
122/// A failure that stopped a receipt poll.
123///
124/// Taken tickets stay recorded and come due again after [`RECEIPT_POLL_DELAY`].
125#[derive(Clone, Debug, Error, Eq, PartialEq)]
126pub enum ReceiptPollError {
127    /// The pending receipt store or the registry failed.
128    #[error(transparent)]
129    Store(#[from] PushStoreError),
130    /// The provider's receipt request failed.
131    #[error(transparent)]
132    Push(#[from] PushError),
133}
134
135/// Polls one batch of due receipts and applies what settled.
136///
137/// Takes up to `limit` due tickets, fetches their receipts, invalidates every
138/// `DeviceNotRegistered` token with the `sent_at` of its own send, and deletes
139/// the settled tickets. Tickets without a receipt yet stay recorded for a
140/// later run. Invalidation runs before deletion, so a failure in between
141/// repeats the invalidation on the next run, which removes nothing new.
142///
143/// # Errors
144///
145/// Returns [`ReceiptPollError`] when the store, the registry, or the receipt
146/// request fails.
147pub 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(&registry, 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, &registry, 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(&registry, owner, "reinstalled", 10).await;
288
289        let due_at = sent_at + RECEIPT_POLL_DELAY;
290        let poll = poll_pending_receipts(&sender, &pending, &registry, 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(&registry, 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, &registry, 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, &registry, 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}