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}