Skip to main content

fraiseql_functions/triggers/
source.rs

1//! The scheduling envelope for a [`PullSource`] — the generic orchestration that
2//! turns a native pull source into a rock-solid, single-firing, at-least-once
3//! ingress on a schedule (#573).
4//!
5//! One tick ([`run_source_once`]) does, under the source's advisory lease so it
6//! runs on exactly one replica:
7//!
8//! 1. load the durable cursor,
9//! 2. [`poll`](PullSource::poll) the source for everything new since it,
10//! 3. hand a non-empty batch to the [`IngestSink`], which emits it onto the durable spine and
11//!    advances the cursor **in one transaction** (atomic — no reprocess window) and dispatches
12//!    `after:ingest` once committed.
13//!
14//! ## Layering — why the sink is a seam
15//!
16//! This envelope owns *scheduling* (the lease, the cursor read, the poll), all of
17//! which this crate can express with no database driver. The *transactional* part —
18//! the spine write, the cursor advance, and the `after:ingest` dispatch — lives in
19//! the server (that is where the spine is) and is reached through [`IngestSink`],
20//! so the envelope stays driver-free and unit-testable with a stub sink. Keeping
21//! the transaction inside the sink (rather than owning it here) is what lets this
22//! crate avoid a hard `sqlx` dependency.
23
24use fraiseql_error::{FraiseQLError, Result};
25use fraiseql_observers::{
26    CursorSnapshot, LeaseGuardedRunner, ObserverError, RunOutcome, SourceCursorStore,
27};
28
29use super::ingest::{PullBatch, PullContext, PullSource};
30
31#[cfg(test)]
32mod tests;
33
34/// The durable-ingest seam the envelope drives.
35///
36/// One call must, **atomically**: emit the batch's messages onto the durable spine
37/// (deduplicated by idempotency key) and advance the cursor from `from` to the
38/// batch's `next_cursor`, in a single transaction; then, once committed, dispatch
39/// `after:ingest` for the newly-persisted messages (durable, at-least-once).
40///
41/// Implemented by the server, where the spine and the `after:ingest` dispatcher
42/// live. Splitting it out keeps this crate free of the database driver and lets the
43/// envelope be tested with an in-memory stub.
44#[allow(async_fn_in_trait)] // Reason: concrete-type use by the envelope; no dyn dispatch.
45pub trait IngestSink {
46    /// Transactionally emit `batch` onto the spine and advance the cursor from
47    /// `from`, then dispatch `after:ingest` post-commit.
48    ///
49    /// Returns `true` when the batch committed and the cursor advanced, `false`
50    /// when the cursor advance lost a lease-boundary race (another replica had
51    /// already moved it on) — in which case the transaction is rolled back and
52    /// nothing is ingested or dispatched.
53    ///
54    /// # Errors
55    ///
56    /// Returns a [`FraiseQLError`] if the spine write, the cursor advance, or the
57    /// commit fails; the caller then leaves the watermark unmoved for a retry.
58    async fn ingest(
59        &self,
60        source_name: &str,
61        batch: PullBatch,
62        from: &CursorSnapshot,
63    ) -> Result<bool>;
64}
65
66/// The outcome of one [`run_source_once`] tick.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum SourceOutcome {
69    /// Another replica held the lease; this tick did not poll.
70    SkippedNotLeader,
71    /// Polled, but the source returned nothing new and left the cursor where it
72    /// was; nothing to do.
73    NoData,
74    /// Advanced the cursor and ingested `messages` messages. `messages` is `0` when
75    /// the source advanced the watermark without emitting — e.g. it skipped a poison
76    /// message and moved past it.
77    Ingested {
78        /// How many messages the poll returned (before spine dedup); may be `0`.
79        messages: usize,
80    },
81    /// The cursor advance was rejected — another replica had already moved it on
82    /// (a lease-boundary race). The transaction rolled back; nothing ingested.
83    CursorRaceLost,
84}
85
86/// Run one tick of a native [`PullSource`] under its single-firing lease.
87///
88/// The cursor and lease are keyed on [`runner.source_name()`](LeaseGuardedRunner::source_name)
89/// — the unique per-instance name (e.g. the mailbox name), **not** the source's
90/// [`IngestSource`](super::ingest::IngestSource) routing discriminant, so two
91/// mailboxes of the same kind get distinct cursors and never coordinate on one
92/// lease. `runner`, `store`, and the sink's own spine/cursor handles must all
93/// target the same PostgreSQL database.
94///
95/// # Errors
96///
97/// Returns a [`FraiseQLError`] if acquiring the lease, loading the cursor, polling
98/// the source, or ingesting the batch fails. On any error nothing commits, so the
99/// cursor stays put and the next tick retries the same window (at-least-once).
100// Reason: the future is Send for concrete source/store/sink types (all do
101// Send sqlx/IMAP I/O); the source scheduler spawns those concrete instantiations,
102// where rustc enforces Send. clippy cannot prove it for the fully generic form.
103#[allow(clippy::future_not_send)]
104pub async fn run_source_once<S, P, K>(
105    runner: &LeaseGuardedRunner,
106    store: &S,
107    source: &P,
108    sink: &K,
109) -> Result<SourceOutcome>
110where
111    S: SourceCursorStore,
112    P: PullSource,
113    K: IngestSink,
114{
115    let name = runner.source_name();
116
117    let outcome = runner
118        .run(|| ingest_tick(store, source, sink, name))
119        .await
120        .map_err(|e| obs_err(&e))?;
121
122    match outcome {
123        RunOutcome::SkippedNotLeader => Ok(SourceOutcome::SkippedNotLeader),
124        RunOutcome::Ran(result) => result,
125    }
126}
127
128/// The body of one tick, run under the lease: load the cursor, poll, and hand a
129/// non-empty batch to the sink to ingest transactionally.
130#[allow(clippy::future_not_send)] // Reason: see `run_source_once`.
131async fn ingest_tick<S, P, K>(store: &S, source: &P, sink: &K, name: &str) -> Result<SourceOutcome>
132where
133    S: SourceCursorStore,
134    P: PullSource,
135    K: IngestSink,
136{
137    let cursor = store.load(name).await.map_err(|e| obs_err(&e))?;
138
139    let batch = source
140        .poll(&PullContext {
141            cursor: cursor.value.clone(),
142        })
143        .await
144        .map_err(|error| {
145            FraiseQLError::internal(format!("source '{name}' poll failed: {error}"))
146        })?;
147
148    // Advance the cursor whenever the source made progress — either it fetched
149    // messages, or it moved the watermark past input it consumed without emitting
150    // (e.g. a source that skips a poison message must still advance past it, or the
151    // next poll re-fetches it forever). A poll that returns nothing *and* leaves the
152    // cursor where it was is genuinely idle.
153    let current = cursor.value.clone().unwrap_or(serde_json::Value::Null);
154    let progressed = batch.next_cursor != current;
155    if batch.messages.is_empty() && !progressed {
156        return Ok(SourceOutcome::NoData);
157    }
158    let message_count = batch.messages.len();
159
160    if sink.ingest(name, batch, &cursor).await? {
161        Ok(SourceOutcome::Ingested {
162            messages: message_count,
163        })
164    } else {
165        Ok(SourceOutcome::CursorRaceLost)
166    }
167}
168
169/// Map an observer-coordination error onto the canonical framework error.
170fn obs_err(error: &ObserverError) -> FraiseQLError {
171    FraiseQLError::database(format!("source coordination: {error}"))
172}