Skip to main content

baukit_push/
delivery_claim.rs

1//! At-most-once daily delivery claims for scheduled pushes.
2
3use std::num::NonZeroU32;
4
5use chrono::{DateTime, NaiveDate, Utc};
6use uuid::Uuid;
7
8use crate::{PushStoreError, PushStoreFuture, PushValidationError};
9
10/// Longest delivery kind accepted, in bytes.
11pub const MAX_DELIVERY_KIND_LENGTH: usize = 64;
12
13/// A product-defined name for one kind of scheduled notification.
14///
15/// 1 to [`MAX_DELIVERY_KIND_LENGTH`] bytes of `[a-z0-9_.-]`, starting with a
16/// letter or digit, for example `training_reminder`.
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct DeliveryKind(String);
19
20impl DeliveryKind {
21    /// Validates a kind name.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`PushValidationError::DeliveryKind`] for any name outside the rule.
26    pub fn new(kind: impl Into<String>) -> Result<Self, PushValidationError> {
27        let kind = kind.into();
28        let starts_well = kind
29            .bytes()
30            .next()
31            .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit());
32        let valid = starts_well
33            && kind.len() <= MAX_DELIVERY_KIND_LENGTH
34            && kind.bytes().all(|byte| {
35                byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.-".contains(&byte)
36            });
37        if valid {
38            Ok(Self(kind))
39        } else {
40            Err(PushValidationError::DeliveryKind)
41        }
42    }
43
44    /// Returns the kind name.
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49}
50
51/// The right to send one kind of notification to one owner on one local date.
52#[derive(Clone, Debug, Eq, Hash, PartialEq)]
53pub struct DeliveryClaim {
54    /// The account the notification is for.
55    pub owner_id: Uuid,
56    /// The date in the owner's time zone the notification belongs to.
57    pub local_date: NaiveDate,
58    /// The kind of notification.
59    pub kind: DeliveryKind,
60}
61
62impl DeliveryClaim {
63    /// Creates a claim key.
64    #[must_use]
65    pub const fn new(owner_id: Uuid, local_date: NaiveDate, kind: DeliveryKind) -> Self {
66        Self {
67            owner_id,
68            local_date,
69            kind,
70        }
71    }
72}
73
74/// Storage port for daily delivery claims.
75///
76/// A scheduled sender claims before it sends, so two workers evaluating the
77/// same owner never both deliver. If the whole send fails, it releases the
78/// claim and a later run may try again. Event-driven senders do not need this
79/// port. Claims grow by one row per owner, kind, and day, so schedule
80/// [`DeliveryClaimStore::purge`].
81pub trait DeliveryClaimStore: Send + Sync {
82    /// Records the claim; returns `false` when someone already holds it.
83    fn claim(
84        &self,
85        claim: DeliveryClaim,
86        claimed_at: DateTime<Utc>,
87    ) -> PushStoreFuture<'_, Result<bool, PushStoreError>>;
88
89    /// Drops a claim after a failed send; returns whether it existed.
90    fn release(&self, claim: DeliveryClaim) -> PushStoreFuture<'_, Result<bool, PushStoreError>>;
91
92    /// Deletes up to `limit` claims for local dates before `before`, oldest
93    /// date first; returns how many went.
94    ///
95    /// Call it again until it returns less than `limit`.
96    fn purge(
97        &self,
98        before: NaiveDate,
99        limit: NonZeroU32,
100    ) -> PushStoreFuture<'_, Result<u64, PushStoreError>>;
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn a_kind_is_a_short_lowercase_identifier() {
109        for valid in ["training_reminder", "weekly-summary", "daily.v2", "7day"] {
110            assert!(DeliveryKind::new(valid).is_ok(), "{valid}");
111        }
112        assert!(DeliveryKind::new("a".repeat(MAX_DELIVERY_KIND_LENGTH)).is_ok());
113        for invalid in [
114            String::new(),
115            "_leading".to_owned(),
116            "Upper".to_owned(),
117            "with space".to_owned(),
118            "a".repeat(MAX_DELIVERY_KIND_LENGTH + 1),
119        ] {
120            assert_eq!(
121                DeliveryKind::new(invalid),
122                Err(PushValidationError::DeliveryKind)
123            );
124        }
125    }
126}