obix 0.14.4

Implementation of outbox backed by PG / sqlx
Documentation
use es_entity::clock::{Clock, ClockHandle};
use serde::{Deserialize, Serialize};

pub const DEFAULT_PERSIST_EVENTS_BATCH_SIZE: usize = 5000;

/// How long the persistent cache waits before the first gap-fill attempt
/// for a stalled broadcast sequence. Most gaps are in-flight transactions
/// that committed out of sequence-allocation order and resolve on their own
/// within a few ms (and this process's own commit-failed allocations are
/// compensated reactively, never waiting for a fill attempt at all);
/// attempting immediately wastes a page re-read per gap. At high posting
/// rates (hundreds of sequences/s) there is nearly always an in-flight
/// frontier gap, so a sub-second grace is guaranteed-too-short and turns
/// the fill into a permanent background load — hence seconds, not
/// milliseconds. Attempts are read-only until the missing sequences are
/// *provably abandoned* (every transaction that could have produced them
/// has ended — see `MailboxTables::abandonment_proof_passed`), so a fill
/// can never collide with a live writer regardless of this setting.
///
/// This grace no longer paces draining a backlog of *committed* rows — the
/// feeder drains those from memory, or reads them at DB speed. It governs the
/// hole case exactly as before.
pub const DEFAULT_GAP_FILL_GRACE: std::time::Duration = std::time::Duration::from_secs(2);

/// Ceiling on the rows a single backfill page read may return, and the width
/// of a gap-fill stall episode's window (an episode's re-read is a page read,
/// so one episode covers what one page read can see).
///
/// A query-shape knob, distinct from `event_cache_size` (how far behind a
/// listener may fall and still be served from memory) and `event_buffer_size`
/// (how much may be in flight): this bounds how long a catch-up read holds a
/// pooled connection and how large a result set it materialises. Keep it well
/// under `event_cache_size`, so a listener that consumes a page lands back
/// inside the memory window instead of paging forever.
///
/// It is also the depth of the channel a backfill delivers through, so it
/// bounds how far the reader may run ahead of its consumer — one page in
/// flight while the next is fetched. Sizing that channel by `event_buffer_size`
/// instead would let the reader outrun a slow consumer by a whole second
/// buffer's worth of events, all of it read from the database and then evicted.
pub const DEFAULT_BACKFILL_PAGE_SIZE: usize = 1000;

/// Maximum number of placeholder rows a single gap-fill query may insert.
/// Bounds the worst case (a mass rollback or long outage leaving thousands
/// of lost sequences) to a small, predictable statement instead of one
/// giant insert; the fixed 1s retry cadence picks up the remainder, so a
/// cap delays recovery of a pathological backlog without ever losing
/// sequences.
///
/// This caps one *insert*, not an episode: the episode window is the
/// [`backfill page`](DEFAULT_BACKFILL_PAGE_SIZE), so a gap wider than this cap
/// fills across several passes of one episode — one grace period, one marker,
/// batches at the loop cadence.
pub const DEFAULT_GAP_FILL_BATCH_LIMIT: usize = 1000;

/// How long the per-process notifier coalesces committed-batch reports
/// before emitting one `pg_notify` wake-up hint. Notify-bearing commits
/// serialize on a cluster-wide lock, so app transactions no longer notify;
/// this bounds the added cross-process wake-up latency (in-process delivery
/// is unaffected).
pub const DEFAULT_NOTIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(25);

/// How long the persistent cache goes without authoritative progress (a
/// newly-seen committed row or a confirmed head read) before polling the
/// sequence head (the O(1) `last_value` query). Backstops lost wake-ups: a
/// writer crashing between commit and notify, a dead remote notifier, or
/// external writers that never notify.
pub const DEFAULT_IDLE_RESYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);

/// Width, in `sequence` units, of each `persistent_outbox_events` partition.
///
/// A fixed schema constant, deliberately **not** runtime-configurable: it must
/// equal the range of the initial `p0` partition hard-coded in the migration
/// (`[0, 2_000_000)`), or the maintainer would create partitions that overlap
/// `p0`. Changing the size means editing both this constant and the migration
/// together.
///
/// Sized from real event data: outbox rows measure ~760 B each (payloads
/// average a few hundred bytes and rarely TOAST), so 2M rows ≈ ~1.5 GB per
/// partition — small enough that the hot partition stays cache-resident and a
/// per-partition vacuum is quick, while keeping the partition count low.
pub const DEFAULT_PARTITION_WIDTH: u64 = 2_000_000;

/// How many partitions ahead of the current sequence head the maintainer
/// keeps created — including on the initial synchronous `ensure` at
/// registration, so a fresh install starts with a multi-partition runway
/// (the migration itself ships only `p0` + `DEFAULT`). `premake * width` must
/// comfortably exceed the events produced between two maintainer ticks so the
/// head never reaches the last pre-made boundary (which would spill into the
/// `DEFAULT` partition). Empty partitions are cheap, so this errs generous:
/// 5 * 2M = 10M sequences of headroom by default.
pub const DEFAULT_PARTITION_PREMAKE: u64 = 5;

/// How often the partition maintainer wakes to pre-create partitions ahead of
/// the head. Each tick is idempotent (`CREATE ... IF NOT EXISTS`), so this is
/// a cheap steady-state poll; premake margin, not cadence, is the safety
/// budget against bursts.
pub const DEFAULT_PARTITION_MAINTAINER_INTERVAL: std::time::Duration =
    std::time::Duration::from_secs(3600);

/// How many groups the commit-lane fold emits between sparse checkpoints. The
/// order is computed, so a checkpoint only bounds how far a restart or a
/// lagging subscriber's backfill has to re-fold.
pub const DEFAULT_COMMIT_CHECKPOINT_EVERY: usize = 1_000;

/// Longest the commit-lane fold goes without a checkpoint while emitting,
/// regardless of group count; see [`DEFAULT_COMMIT_CHECKPOINT_EVERY`].
pub const DEFAULT_COMMIT_CHECKPOINT_INTERVAL: std::time::Duration =
    std::time::Duration::from_secs(5);

/// Whether this outbox runs the commit-ordered lane. Opt-in: the fold passes
/// over every event, in every process that runs the outbox.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitLane {
    /// No sequencer runs here: registering a
    /// [`CommitOrder`](crate::CommitOrder) subscriber fails with
    /// [`CommitLaneDisabled`](crate::out::CommitLaneDisabled).
    #[default]
    Disabled,
    /// This process sequences the commit lane and can host `CommitOrder`
    /// subscribers.
    Enabled,
}

#[derive(Clone, bon::Builder)]
pub struct MailboxConfig {
    #[builder(default = 100)]
    pub event_buffer_size: usize,
    #[builder(default = 1000)]
    pub event_cache_size: usize,
    #[builder(default = 10)]
    pub event_cache_trim_percent: u8,
    #[builder(default = DEFAULT_PERSIST_EVENTS_BATCH_SIZE)]
    pub persist_events_batch_size: usize,
    /// Rows a single backfill page read may return; see
    /// [`DEFAULT_BACKFILL_PAGE_SIZE`]. The reader waits for the consumer to
    /// have room before issuing a read, but the read itself is this size.
    #[builder(default = DEFAULT_BACKFILL_PAGE_SIZE)]
    pub backfill_page_size: usize,
    /// Grace period before the first proactive gap-fill attempt for a
    /// stalled broadcast sequence; see [`DEFAULT_GAP_FILL_GRACE`]. Retries
    /// after a fill attempt are unaffected (fixed 1s interval).
    #[builder(default = DEFAULT_GAP_FILL_GRACE)]
    pub gap_fill_grace: std::time::Duration,
    /// Maximum placeholder rows a single gap-fill query may insert; see
    /// [`DEFAULT_GAP_FILL_BATCH_LIMIT`]. The remainder is picked up by
    /// subsequent attempts — sequences are never skipped, only filled
    /// later.
    #[builder(default = DEFAULT_GAP_FILL_BATCH_LIMIT)]
    pub gap_fill_batch_limit: usize,
    /// Coalescing window of the per-process debounced notifier; see
    /// [`DEFAULT_NOTIFY_DEBOUNCE`]. Deliberately no per-commit escape hatch.
    #[builder(default = DEFAULT_NOTIFY_DEBOUNCE)]
    pub notify_debounce: std::time::Duration,
    /// Progress-silence threshold before the persistent cache polls the
    /// sequence head; see [`DEFAULT_IDLE_RESYNC_INTERVAL`].
    #[builder(default = DEFAULT_IDLE_RESYNC_INTERVAL)]
    pub idle_resync_interval: std::time::Duration,
    /// How many partitions ahead of the head the maintainer keeps created;
    /// see [`DEFAULT_PARTITION_PREMAKE`]. (Partition *width* is the fixed
    /// [`DEFAULT_PARTITION_WIDTH`] constant, not configurable — it is coupled to
    /// the migration's `p0` range.)
    #[builder(default = DEFAULT_PARTITION_PREMAKE)]
    pub partition_premake: u64,
    /// Poll interval of the partition maintainer job; see
    /// [`DEFAULT_PARTITION_MAINTAINER_INTERVAL`].
    #[builder(default = DEFAULT_PARTITION_MAINTAINER_INTERVAL)]
    pub partition_maintainer_interval: std::time::Duration,
    /// Whether this outbox runs the commit-ordered lane; see [`CommitLane`].
    /// Defaults to [`Disabled`](CommitLane::Disabled).
    #[builder(default)]
    pub commit_lane: CommitLane,
    /// Groups between sparse checkpoints of the commit-lane fold; see
    /// [`DEFAULT_COMMIT_CHECKPOINT_EVERY`]. Ignored when the lane is
    /// `Disabled`.
    #[builder(default = DEFAULT_COMMIT_CHECKPOINT_EVERY)]
    pub commit_checkpoint_every: usize,
    /// Longest the commit-lane fold goes without a checkpoint while
    /// emitting; see [`DEFAULT_COMMIT_CHECKPOINT_INTERVAL`].
    #[builder(default = DEFAULT_COMMIT_CHECKPOINT_INTERVAL)]
    pub commit_checkpoint_interval: std::time::Duration,
    #[builder(default = Clock::handle().clone())]
    pub clock: ClockHandle,
}

#[cfg(test)]
mod tests {
    use crate::out::CommitLaneDisabled;
    use es_entity::errlanes::{Fail, lanes};

    /// `CommitLaneDisabled` is the one rejected case the lane carrier can
    /// carry — a bare `?`/`.into()` must land it in the `Rejected` lane, not
    /// get swallowed into `Fatal` as an unclassified error would.
    #[test]
    fn commit_lane_disabled_enters_the_lane_carrier_as_rejected() {
        let err: Fail<CommitLaneDisabled, lanes!(Transient, Fatal)> = CommitLaneDisabled.into();
        assert!(matches!(err, Fail::Rejected(CommitLaneDisabled)));
    }
}