use std::sync::Arc;
use std::time::Duration;
use apiplant_ai::Ai;
use apiplant_cache::Cache;
use apiplant_db::Db;
use apiplant_email::Mailer;
use apiplant_payments::Payments;
use apiplant_queue::{Delivery, Listener, Queue};
use crate::functions::{FunctionRegistry, HostBridge};
pub struct Subscriber {
pub db: Db,
pub queue: Queue,
pub functions: Arc<FunctionRegistry>,
pub mailer: Option<Mailer>,
pub cache: Option<Cache>,
pub payments: Option<Payments>,
pub ai: Option<Ai>,
pub database_url: String,
pub worker: String,
}
pub async fn run(subscriber: Subscriber) {
let config = subscriber.queue.config().clone();
let poll = Duration::from_secs(config.poll_secs.max(1));
if let Err(error) = subscriber.queue.prepare().await {
tracing::warn!(%error, "could not prepare the queue index; claims will be slower");
}
let channel = config.channel();
let mut listener = match Listener::connect(&subscriber.database_url, &channel).await {
Ok(listener) => Some(listener),
Err(error) => {
tracing::warn!(
%error, channel,
"queue subscriber could not LISTEN; falling back to polling every {}s",
poll.as_secs()
);
None
}
};
tracing::info!(
worker = %subscriber.worker,
channel,
topics = ?subscriber.queue.topics(),
"queue subscriber started"
);
loop {
drain(&subscriber).await;
if let Err(error) = subscriber.queue.reclaim().await {
tracing::warn!(%error, "could not reclaim abandoned messages");
}
if let Err(error) = subscriber.queue.prune().await {
tracing::warn!(%error, "could not prune handled messages");
}
let wait = match subscriber.queue.next_due().await {
Ok(Some(seconds)) => poll.min(Duration::from_secs(seconds)),
_ => poll,
};
match &mut listener {
Some(active) => {
match tokio::time::timeout(wait, active.recv()).await {
Ok(Ok(_topic)) => {}
Ok(Err(error)) => {
tracing::warn!(%error, "queue listener failed; polling until it recovers");
listener = None;
}
Err(_) => {}
}
}
None => {
tokio::time::sleep(wait).await;
if let Ok(reconnected) = Listener::connect(&subscriber.database_url, &channel).await
{
tracing::info!(channel, "queue listener reconnected");
listener = Some(reconnected);
}
}
}
}
}
async fn drain(subscriber: &Subscriber) {
loop {
let batch = match subscriber.queue.claim(&subscriber.worker).await {
Ok(batch) => batch,
Err(error) => {
tracing::warn!(%error, "could not claim messages; will try again on the next pass");
return;
}
};
if batch.is_empty() {
return;
}
for delivery in batch {
handle(subscriber, delivery).await;
}
}
}
#[cfg(test)]
pub(crate) async fn handle_for_test(subscriber: &Subscriber, delivery: Delivery) {
handle(subscriber, delivery).await
}
async fn handle(subscriber: &Subscriber, delivery: Delivery) {
let result = invoke(subscriber, &delivery).await;
let outcome = match result {
Ok(_) => subscriber.queue.complete(&delivery.id).await.map(|_| ()),
Err(error) => subscriber.queue.fail(&delivery, &error).await.map(|_| ()),
};
if let Err(error) = outcome {
tracing::error!(
message_id = %delivery.id,
topic = %delivery.topic,
%error,
"handled a message but could not record the outcome; it will be delivered again"
);
}
}
async fn invoke(subscriber: &Subscriber, delivery: &Delivery) -> Result<String, String> {
let Some(function) = subscriber.functions.get(&delivery.subscriber) else {
return Err(format!(
"`{}` is subscribed to `{}` but no such function is loaded",
delivery.subscriber, delivery.topic
));
};
let bridge = HostBridge::new(
subscriber.db.clone(),
tokio::runtime::Handle::current(),
function.config_json.clone(),
delivery.published_by.clone(),
)
.with_services(
subscriber.mailer.clone(),
subscriber.cache.clone(),
subscriber.payments.clone(),
subscriber.ai.clone(),
)
.with_queue(subscriber.queue.clone())
.with_hook(delivery.context().to_string());
let input = delivery.payload.to_string();
let name = delivery.subscriber.clone();
let functions = Arc::clone(&subscriber.functions);
let result = tokio::task::spawn_blocking(move || {
let function = functions.get(&name).expect("checked above");
function.invoke(bridge, &input)
})
.await
.map_err(|_| "the handler panicked".to_string())?;
result.map_err(|message| {
match message.strip_prefix(apiplant_abi::INTERNAL_ERROR_PREFIX) {
Some(detail) => format!("handler faulted: {detail}"),
None => message,
}
})
}