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}