reliar-inbox 0.2.0

Transactional inbox deduplication: InboxStore/InboxHandler contracts, claim/complete/fail/purge semantics (no storage or transport dependency).
Documentation
//! Retention: [`InboxStore::purge`](crate::InboxStore::purge)'s request and report.

use std::num::NonZeroU32;
use std::time::Duration;

/// A retention sweep request. Mirrors `reliar-outbox`'s `PurgeRequest` in spirit.
///
/// **No `InboxSettings` and no `from_env` in this slice** — this request is the only knob and it
/// is passed, not configured.
///
/// ```
/// use reliar_inbox::InboxPurgeRequest;
/// use std::num::NonZeroU32;
///
/// let request = InboxPurgeRequest::default().batch_size(NonZeroU32::new(200).unwrap());
/// assert_eq!(request.batch_size.get(), 200);
/// assert!(request.incomplete_retention.is_none());
/// ```
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
#[non_exhaustive]
pub struct InboxPurgeRequest {
    /// Delete completed rows older than this. `None` skips the sweep.
    ///
    /// **A completed row may only be deleted once no redelivery of that message can still
    /// arrive** — losing it means the next redelivery re-runs the handler. The floor is your
    /// consumer's `max_deliver × ack_wait` plus its backoff, plus anything the producing side
    /// can still republish inside the outbox's duplicate window. Default **7 days**, matching
    /// `reliar-outbox`'s `RetentionSettings::published_retention`.
    #[cfg_attr(
        feature = "serde",
        serde(
            rename = "completed_retention_ms",
            with = "reliar_core::serde_millis::optional_millis"
        )
    )]
    pub completed_retention: Option<Duration>,

    /// Delete rows that are **never completed, not dead** — [`crate::InboxState::Claimed`] or
    /// [`crate::InboxState::Retrying`] — older than this, aged by [`crate::InboxRecord::updated_at`]
    /// (ADR 0042 Amendment C.2: the three retention categories partition the table by
    /// `completed_at`/`dead_at`/neither, so an incomplete row is collectable regardless of
    /// `attempts`). Default **`None`** — like `dead_retention`, deliberately loud: those rows may
    /// be the operator's only record of a handler that keeps failing.
    #[cfg_attr(
        feature = "serde",
        serde(
            rename = "incomplete_retention_ms",
            with = "reliar_core::serde_millis::optional_millis"
        )
    )]
    pub incomplete_retention: Option<Duration>,

    /// Delete rows that reached `max_attempts`, older than this. Default **`None`** —
    /// deliberately loud, exactly like the outbox's `dead_retention`: a dead row is the
    /// operator's only record of a handler that kept failing, and
    /// [`crate::InboxDeadLetters::purge_dead`] deletes by id whenever they want it gone sooner.
    #[cfg_attr(
        feature = "serde",
        serde(
            rename = "dead_retention_ms",
            with = "reliar_core::serde_millis::optional_millis"
        )
    )]
    pub dead_retention: Option<Duration>,

    /// Row cap per sweep per category, so a purge never takes a long lock or a long
    /// transaction. Default 1000. A `LIMIT 0` purge would delete nothing and could never make
    /// progress, so the type itself rules it out (ADR 0058 §2).
    pub batch_size: NonZeroU32,
}

/// The default [`InboxPurgeRequest::batch_size`] (ADR 0058 §2).
const DEFAULT_PURGE_BATCH_SIZE: NonZeroU32 = NonZeroU32::new(1_000).unwrap();

/// **Hand-written, never derived**: `batch_size` is `NonZeroU32`, which has no `Default` impl, so
/// a derived `Default` would not compile — and before ADR 0058 §2, it would have given
/// `None`/`None`/`None`/`0`, a purge that deletes nothing and reports success.
impl Default for InboxPurgeRequest {
    fn default() -> Self {
        Self {
            completed_retention: Some(Duration::from_secs(7 * 24 * 60 * 60)),
            incomplete_retention: None,
            dead_retention: None,
            batch_size: DEFAULT_PURGE_BATCH_SIZE,
        }
    }
}

impl InboxPurgeRequest {
    /// Sets [`Self::completed_retention`].
    ///
    /// ```
    /// use reliar_inbox::InboxPurgeRequest;
    /// let request = InboxPurgeRequest::default().completed_retention(None);
    /// assert!(request.completed_retention.is_none());
    /// ```
    #[must_use]
    pub const fn completed_retention(mut self, retention: Option<Duration>) -> Self {
        self.completed_retention = retention;

        self
    }

    /// Sets [`Self::incomplete_retention`].
    ///
    /// ```
    /// use reliar_inbox::InboxPurgeRequest;
    /// use std::time::Duration;
    /// let request = InboxPurgeRequest::default().incomplete_retention(Some(Duration::from_secs(60)));
    /// assert_eq!(request.incomplete_retention, Some(Duration::from_secs(60)));
    /// ```
    #[must_use]
    pub const fn incomplete_retention(mut self, retention: Option<Duration>) -> Self {
        self.incomplete_retention = retention;

        self
    }

    /// Sets [`Self::dead_retention`].
    ///
    /// ```
    /// use reliar_inbox::InboxPurgeRequest;
    /// use std::time::Duration;
    /// let request = InboxPurgeRequest::default().dead_retention(Some(Duration::from_secs(60)));
    /// assert_eq!(request.dead_retention, Some(Duration::from_secs(60)));
    /// ```
    #[must_use]
    pub const fn dead_retention(mut self, retention: Option<Duration>) -> Self {
        self.dead_retention = retention;

        self
    }

    /// Sets [`Self::batch_size`].
    ///
    /// ```
    /// use reliar_inbox::InboxPurgeRequest;
    /// use std::num::NonZeroU32;
    /// let batch_size = NonZeroU32::new(200).unwrap();
    /// let request = InboxPurgeRequest::default().batch_size(batch_size);
    /// assert_eq!(request.batch_size, batch_size);
    /// ```
    #[must_use]
    pub const fn batch_size(mut self, batch_size: NonZeroU32) -> Self {
        self.batch_size = batch_size;

        self
    }
}

/// What one [`InboxStore::purge`](crate::InboxStore::purge) call did.
///
/// ```
/// use reliar_inbox::InboxPurgeReport;
///
/// let report = InboxPurgeReport::new(3, 1, 0);
/// assert_eq!(report.completed_deleted, 3);
/// assert_eq!(InboxPurgeReport::default().completed_deleted, 0);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct InboxPurgeReport {
    /// Completed rows deleted.
    pub completed_deleted: u64,

    /// Incomplete rows deleted.
    pub incomplete_deleted: u64,

    /// Dead rows deleted.
    pub dead_deleted: u64,
}

impl InboxPurgeReport {
    /// Builds a purge report.
    ///
    /// ```
    /// use reliar_inbox::InboxPurgeReport;
    /// let report = InboxPurgeReport::new(3, 1, 2);
    /// assert_eq!(report.completed_deleted, 3);
    /// assert_eq!(report.incomplete_deleted, 1);
    /// assert_eq!(report.dead_deleted, 2);
    /// ```
    #[must_use]
    pub const fn new(completed_deleted: u64, incomplete_deleted: u64, dead_deleted: u64) -> Self {
        Self {
            completed_deleted,
            incomplete_deleted,
            dead_deleted,
        }
    }
}