Skip to main content

apiplant_server/
queues.rs

1//! The subscriber: the loop that turns queued messages back into function calls.
2//!
3//! One task per process, started by [`crate::run_with`] when the app subscribes
4//! to anything. It does three things in a cycle:
5//!
6//! 1. **Wait** — for a `NOTIFY` from a publisher, or for `[queues] poll_secs`,
7//!    whichever comes first. The notification is what makes delivery feel
8//!    instant; the timeout is what makes it *correct*, since a notification can
9//!    be missed and a retry has no publisher to announce it.
10//! 2. **Claim** — take a batch with `FOR UPDATE SKIP LOCKED`, so several
11//!    replicas share the work instead of each doing all of it.
12//! 3. **Run** — invoke each message's function on a blocking worker, then mark
13//!    the row done or schedule the retry.
14//!
15//! ## Why it drains rather than handling one batch per wake
16//!
17//! A publisher that queues 500 messages fires notifications the listener will
18//! coalesce into far fewer wakeups — Postgres is allowed to, and does. A loop
19//! that handled one batch per notification would leave the rest sitting until
20//! the next poll. So a wake keeps claiming until a claim comes back empty.
21//!
22//! ## What a failure costs
23//!
24//! Nothing that reaches the caller, because there is no caller: the request
25//! that published this ended long ago. A handler that returns an error, panics,
26//! or is missing entirely leaves a row with the reason on it and a retry
27//! scheduled — and after `[queues] max_attempts`, a `failed` row somebody has
28//! to come and look at. That is the design: the queue's job is to make the
29//! failure *visible and re-runnable*, not to make it somebody's 500.
30
31use std::sync::Arc;
32use std::time::Duration;
33
34use apiplant_ai::Ai;
35use apiplant_cache::Cache;
36use apiplant_db::Db;
37use apiplant_email::Mailer;
38use apiplant_payments::Payments;
39use apiplant_queue::{Delivery, Listener, Queue};
40
41use crate::functions::{FunctionRegistry, HostBridge};
42
43/// Everything a subscriber needs to run a handler, cloned once at boot.
44///
45/// The same services a request-time invocation gets — a queued function is an
46/// ordinary function, and a handler that sends mail or reads the cache should
47/// not have to care which side of the queue it is on.
48pub struct Subscriber {
49    pub db: Db,
50    pub queue: Queue,
51    pub functions: Arc<FunctionRegistry>,
52    pub mailer: Option<Mailer>,
53    pub cache: Option<Cache>,
54    pub payments: Option<Payments>,
55    pub ai: Option<Ai>,
56    /// The database URL, for the listener's own dedicated connection.
57    pub database_url: String,
58    /// Identifies this process in `queue_message.claimed_by`. Worth having when
59    /// three replicas are up and one of them is the one that keeps dying.
60    pub worker: String,
61}
62
63/// Run until the process ends.
64///
65/// Never returns an error: a subscriber that gave up would leave messages
66/// queued with nothing to handle them, and the failure modes it could give up
67/// on — the database being briefly unreachable, a listener connection dropping
68/// — are all ones that fix themselves. Everything is logged and retried instead.
69pub async fn run(subscriber: Subscriber) {
70    let config = subscriber.queue.config().clone();
71    let poll = Duration::from_secs(config.poll_secs.max(1));
72
73    if let Err(error) = subscriber.queue.prepare().await {
74        // Only the index is missing, so this is slow rather than broken.
75        tracing::warn!(%error, "could not prepare the queue index; claims will be slower");
76    }
77
78    // Losing the listener is a latency problem, not a correctness one — the
79    // poll below finds every message either way — so failing to connect is a
80    // warning and the loop starts anyway.
81    let channel = config.channel();
82    let mut listener = match Listener::connect(&subscriber.database_url, &channel).await {
83        Ok(listener) => Some(listener),
84        Err(error) => {
85            tracing::warn!(
86                %error, channel,
87                "queue subscriber could not LISTEN; falling back to polling every {}s",
88                poll.as_secs()
89            );
90            None
91        }
92    };
93
94    tracing::info!(
95        worker = %subscriber.worker,
96        channel,
97        topics = ?subscriber.queue.topics(),
98        "queue subscriber started"
99    );
100
101    loop {
102        // A sweep on every cycle, before waiting: this is what picks up
103        // messages published while this process was starting, and retries whose
104        // backoff has expired since the last pass.
105        drain(&subscriber).await;
106
107        // Messages abandoned by a subscriber that died mid-handler. Cheap, and
108        // only ever does anything when something went wrong elsewhere.
109        if let Err(error) = subscriber.queue.reclaim().await {
110            tracing::warn!(%error, "could not reclaim abandoned messages");
111        }
112        if let Err(error) = subscriber.queue.prune().await {
113            tracing::warn!(%error, "could not prune handled messages");
114        }
115
116        // How long it is safe to sleep for. Normally the poll interval, but a
117        // message already scheduled — a retry waiting out its backoff — has an
118        // exact time it becomes claimable, and nothing will notify when it
119        // arrives. Without this, a 10-second backoff under a 30-second poll
120        // takes 30 seconds, and the configured number is a fiction.
121        let wait = match subscriber.queue.next_due().await {
122            Ok(Some(seconds)) => poll.min(Duration::from_secs(seconds)),
123            // Nothing scheduled, or the question failed: wait normally. The
124            // sweep at the top of the loop is the backstop either way.
125            _ => poll,
126        };
127
128        match &mut listener {
129            Some(active) => {
130                // Whichever comes first. The topic the notification names is
131                // ignored on purpose — see `Listener::recv`.
132                match tokio::time::timeout(wait, active.recv()).await {
133                    Ok(Ok(_topic)) => {}
134                    Ok(Err(error)) => {
135                        tracing::warn!(%error, "queue listener failed; polling until it recovers");
136                        listener = None;
137                    }
138                    // Nothing published; the sweep at the top of the loop is
139                    // the whole reason this timeout exists.
140                    Err(_) => {}
141                }
142            }
143            None => {
144                tokio::time::sleep(wait).await;
145                // Try to get notifications back. Until this succeeds the queue
146                // still works, just at poll speed.
147                if let Ok(reconnected) =
148                    Listener::connect(&subscriber.database_url, &channel).await
149                {
150                    tracing::info!(channel, "queue listener reconnected");
151                    listener = Some(reconnected);
152                }
153            }
154        }
155    }
156}
157
158/// Claim and handle until there is nothing claimable left.
159async fn drain(subscriber: &Subscriber) {
160    loop {
161        let batch = match subscriber.queue.claim(&subscriber.worker).await {
162            Ok(batch) => batch,
163            Err(error) => {
164                tracing::warn!(%error, "could not claim messages; will try again on the next pass");
165                return;
166            }
167        };
168        if batch.is_empty() {
169            return;
170        }
171        for delivery in batch {
172            handle(subscriber, delivery).await;
173        }
174    }
175}
176
177/// [`handle`], reachable from the integration tests so they can drive one
178/// message through without racing the real loop's timers.
179#[cfg(test)]
180pub(crate) async fn handle_for_test(subscriber: &Subscriber, delivery: Delivery) {
181    handle(subscriber, delivery).await
182}
183
184/// Run one message's handler and record what happened.
185async fn handle(subscriber: &Subscriber, delivery: Delivery) {
186    let result = invoke(subscriber, &delivery).await;
187
188    let outcome = match result {
189        Ok(_) => subscriber.queue.complete(&delivery.id).await.map(|_| ()),
190        Err(error) => subscriber.queue.fail(&delivery, &error).await.map(|_| ()),
191    };
192
193    // The one genuinely awkward case: the handler ran but its row could not be
194    // marked. The work is done and the message is still `running`, so it will
195    // be reclaimed after the lease and run a second time — which is exactly the
196    // at-least-once contract, and worth saying out loud in the log because the
197    // duplicate will otherwise look inexplicable.
198    if let Err(error) = outcome {
199        tracing::error!(
200            message_id = %delivery.id,
201            topic = %delivery.topic,
202            %error,
203            "handled a message but could not record the outcome; it will be delivered again"
204        );
205    }
206}
207
208/// Invoke the subscribed function with the message as its input.
209async fn invoke(subscriber: &Subscriber, delivery: &Delivery) -> Result<String, String> {
210    let Some(function) = subscriber.functions.get(&delivery.subscriber) else {
211        // A subscription naming a function that isn't loaded. Reported at boot
212        // too, but this is where it costs something, so it says so again with
213        // the message that is now stuck behind it.
214        return Err(format!(
215            "`{}` is subscribed to `{}` but no such function is loaded",
216            delivery.subscriber, delivery.topic
217        ));
218    };
219
220    let bridge = HostBridge::new(
221        subscriber.db.clone(),
222        tokio::runtime::Handle::current(),
223        function.config_json.clone(),
224        delivery.published_by.clone(),
225    )
226    .with_services(
227        subscriber.mailer.clone(),
228        subscriber.cache.clone(),
229        subscriber.payments.clone(),
230        subscriber.ai.clone(),
231    )
232    // A handler may publish in turn — a chain of steps, each queued — so the
233    // queue goes across too.
234    .with_queue(subscriber.queue.clone())
235    // The delivery envelope rides in the hook slot, which is where a function
236    // already looks for "why am I running". See `Delivery::context`.
237    .with_hook(delivery.context().to_string());
238
239    // The message body is the function's input, exactly as if it had been
240    // posted to the endpoint. That is what keeps a handler an ordinary function
241    // — callable by hand, testable, and usable over HTTP as well.
242    let input = delivery.payload.to_string();
243    let name = delivery.subscriber.clone();
244    let functions = Arc::clone(&subscriber.functions);
245
246    let result = tokio::task::spawn_blocking(move || {
247        let function = functions.get(&name).expect("checked above");
248        function.invoke(bridge, &input)
249    })
250    .await
251    .map_err(|_| "the handler panicked".to_string())?;
252
253    result.map_err(|message| {
254        // Nobody is waiting on this, so unlike the HTTP path there is no reason
255        // to withhold the internal detail — it goes on the row, which is the
256        // only place anybody will look for it.
257        match message.strip_prefix(apiplant_abi::INTERNAL_ERROR_PREFIX) {
258            Some(detail) => format!("handler faulted: {detail}"),
259            None => message,
260        }
261    })
262}