apalis_postgres/factory.rs
1//! PostgreSQL backend factory with shared `LISTEN/NOTIFY` polling.
2//!
3//! [`PostgresStorageFactory`] creates independent PostgreSQL backends while
4//! sharing a single PostgreSQL notification listener. Each backend is
5//! registered by its queue name and receives task IDs for newly inserted
6//! jobs belonging to that queue.
7//!
8//! This avoids creating a separate [`PgListener`] for every worker and allows
9//! multiple queue types to share the same database connection pool.
10//!
11//! The factory creates [`PostgresStorage`] instances configured with
12//! [`StreamStrategy`]. PostgreSQL notifications wake the corresponding
13//! backend, which then performs its normal database polling.
14//!
15//! # Example
16//!
17//! ```no_run
18//! use apalis::prelude::*;
19//! use apalis_postgres::factory::PostgresStorageFactory;
20//! use sqlx::PgPool;
21//!
22//! #[tokio::main]
23//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
24//! let pool = PgPool::connect(
25//! &std::env::var("DATABASE_URL")?
26//! ).await?;
27//!
28//! let mut factory = PostgresStorageFactory::new(pool);
29//!
30//! let mut backend = factory.create()?;
31//!
32//! backend.push(42).await?;
33//!
34//! let worker = WorkerBuilder::new("numbers")
35//! .backend(backend)
36//! .build(|task: u64| async move {
37//! println!("processing {task}");
38//! });
39//!
40//! worker.run().await?;
41//! Ok(())
42//! }
43//! ```
44//!
45//! A factory can also create multiple queues. Each queue is independently
46//! notified when a matching job is inserted:
47//!
48//! ```ignore
49//! # use apalis::prelude::*;
50//! # use apalis_postgres::factory::PostgresStorageFactory;
51//! # use sqlx::PgPool;
52//! # async fn example(pool: PgPool) -> Result<(), Box<dyn std::error::Error>> {
53//! let mut factory = PostgresStorageFactory::new(pool);
54//!
55//! let emails = factory.create::<Email>()?;
56//! let reports = factory.create::<Report>()?;
57//! # Ok(())
58//! # }
59//! # struct Email;
60//! # struct Report;
61//! ```
62//!
63//! [`PostgresStorage`]: crate::PostgresStorage
64//! [`PgListener`]: sqlx::postgres::PgListener
65//! [`StreamStrategy`]: apalis_core::backend::ext::poll_strategy::StreamStrategy
66use std::{
67 collections::HashMap,
68 pin::Pin,
69 sync::Arc,
70 task::{Context, Poll},
71};
72
73use crate::{PgTaskId, PostgresStorage, config::Config, pubsub::InsertEvent};
74use apalis_core::backend::{
75 BackendConfig,
76 ext::{
77 BackendExt,
78 poll_strategy::{PollWith, StreamStrategy},
79 },
80 factory::BackendFactory,
81};
82
83use futures::{
84 FutureExt, SinkExt, Stream, StreamExt,
85 channel::mpsc::{self, Receiver, Sender},
86 future::{BoxFuture, Shared},
87 lock::Mutex,
88};
89use sqlx::{PgPool, postgres::PgListener};
90
91/// A factory for creating PostgreSQL-backed task queues.
92///
93/// `PostgresStorageFactory` maintains a shared PostgreSQL `LISTEN` connection
94/// and routes job insertion notifications to the corresponding queue.
95///
96/// Each queue created by the factory is identified by its queue name. Multiple
97/// backends can therefore share a single notification listener while retaining
98/// independent task polling and processing.
99///
100/// # Example
101///
102/// ```ignore
103/// use apalis_core::backend::factory::BackendFactory;
104/// use apalis_postgres::factory::PostgresStorageFactory;
105/// use sqlx::PgPool;
106///
107/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
108/// let pool = PgPool::connect("postgres://localhost/apalis").await?;
109/// let mut factory = PostgresStorageFactory::new(pool);
110///
111/// let backend = factory.create::<u64>()?;
112/// # let _ = backend;
113/// # Ok(())
114/// # }
115/// ```
116pub struct PostgresStorageFactory {
117 pool: PgPool,
118 registry: Arc<Mutex<HashMap<String, Sender<PgTaskId>>>>,
119 drive: Shared<BoxFuture<'static, ()>>,
120}
121
122impl PostgresStorageFactory {
123 /// Creates a new factory backed by the given PostgreSQL connection pool.
124 ///
125 /// A single PostgreSQL notification listener is shared by all backends
126 /// created by this factory.
127 pub fn new(pool: PgPool) -> Self {
128 let registry: Arc<Mutex<HashMap<String, Sender<PgTaskId>>>> =
129 Arc::new(Mutex::new(HashMap::default()));
130 let p = pool.clone();
131 let instances = registry.clone();
132 Self {
133 pool,
134 drive: async move {
135 let mut listener = PgListener::connect_with(&p).await.unwrap();
136 listener.listen("apalis::job::insert").await.unwrap();
137 listener
138 .into_stream()
139 .filter_map(|notification| {
140 let instances = instances.clone();
141 async move {
142 let pg_notification = notification.ok()?;
143 let payload = pg_notification.payload();
144 let ev: InsertEvent = serde_json::from_str(payload).ok()?;
145 let instances = instances.lock().await;
146 if instances.get(&ev.job_type).is_some() {
147 return Some(ev);
148 }
149 None
150 }
151 })
152 .for_each(|ev| {
153 let instances = instances.clone();
154 async move {
155 let mut instances = instances.lock().await;
156 let sender = instances.get_mut(&ev.job_type).unwrap();
157 sender.send(ev.id).await.unwrap();
158 }
159 })
160 .await;
161 }
162 .boxed()
163 .shared(),
164 registry,
165 }
166 }
167}
168
169/// Errors returned when creating a PostgreSQL backend from a
170/// [`PostgresStorageFactory`].
171///
172/// [`PostgresStorageFactory`]: crate::factory::PostgresStorageFactory
173#[derive(Debug, thiserror::Error)]
174pub enum PostgresFactoryError {
175 /// Namespace not found
176 #[error("namespace already exists: {0}")]
177 NamespaceExists(String),
178
179 /// Registry locked
180 #[error("registry locked")]
181 RegistryLocked,
182}
183
184impl<Args> BackendFactory<Args> for PostgresStorageFactory {
185 type Backend = PollWith<PostgresStorage<Args>, StreamStrategy<SharedFetcher>>;
186 type Error = PostgresFactoryError;
187
188 fn create(&mut self) -> Result<Self::Backend, Self::Error>
189 where
190 <Self::Backend as BackendConfig>::Config: Default,
191 {
192 self.create_with_config(Config::default().queue(std::any::type_name::<Args>()))
193 }
194 fn create_with_config(&mut self, config: Config) -> Result<Self::Backend, Self::Error> {
195 let mut registry = self
196 .registry
197 .try_lock()
198 .ok_or(PostgresFactoryError::RegistryLocked)?;
199
200 let (tx, rx) = mpsc::channel(config.batch_size * registry.len());
201 if registry.insert(config.queue.to_string(), tx).is_some() {
202 return Err(PostgresFactoryError::NamespaceExists(
203 config.queue.to_string(),
204 ));
205 }
206 Ok(PostgresStorage::new(&self.pool)
207 .with_config(config)
208 .poll_with_stream(SharedFetcher {
209 poller: self.drive.clone(),
210 receiver: Arc::new(Mutex::new(rx)),
211 }))
212 }
213}
214
215/// A stream of task IDs received from the shared PostgreSQL notification
216/// listener.
217///
218/// `SharedFetcher` keeps the shared notification driver alive while exposing
219/// notifications for a specific queue as a [`Stream`].
220///
221/// The fetcher does not perform database polling itself. Instead, it yields
222/// task IDs received through PostgreSQL `LISTEN/NOTIFY`, allowing the backend's
223/// polling strategy to use those notifications as a wake-up signal.
224///
225/// [`Stream`]: futures::Stream
226#[derive(Clone, Debug)]
227pub struct SharedFetcher {
228 poller: Shared<BoxFuture<'static, ()>>,
229 receiver: Arc<Mutex<Receiver<PgTaskId>>>,
230}
231
232impl Stream for SharedFetcher {
233 type Item = PgTaskId;
234 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
235 let this = self.get_mut();
236 // Keep the poller alive by polling it, but ignoring the output
237 let _ = this.poller.poll_unpin(cx);
238
239 // Delegate actual items to receiver
240 let mut receiver = this.receiver.try_lock();
241 if let Some(ref mut rx) = receiver {
242 rx.poll_next_unpin(cx)
243 } else {
244 Poll::Pending
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use std::time::Duration;
252
253 use apalis_core::{
254 backend::TaskSink,
255 error::BoxDynError,
256 worker::{builder::WorkerBuilder, context::WorkerContext},
257 };
258 use futures::stream;
259
260 use super::*;
261
262 #[tokio::test]
263 async fn basic_worker() {
264 let pool = PgPool::connect(std::env::var("DATABASE_URL").unwrap().as_str())
265 .await
266 .unwrap();
267 let mut store = PostgresStorageFactory::new(pool);
268
269 let mut map_store = store.create().unwrap();
270
271 let mut int_store = store.create().unwrap();
272
273 map_store
274 .push_stream(&mut stream::iter(vec![HashMap::<String, String>::new()]))
275 .await
276 .unwrap();
277 int_store.push(99).await.unwrap();
278
279 async fn send_reminder<T>(
280 _: T,
281 _task_id: PgTaskId,
282 wrk: WorkerContext,
283 ) -> Result<(), BoxDynError> {
284 tokio::time::sleep(Duration::from_secs(2)).await;
285 wrk.stop().unwrap();
286 Ok(())
287 }
288
289 let int_worker = WorkerBuilder::new("rango-tango-3")
290 .backend(int_store)
291 .build(send_reminder);
292 let map_worker = WorkerBuilder::new("rango-tango-4")
293 .backend(map_store)
294 .build(send_reminder);
295 tokio::try_join!(int_worker.run(), map_worker.run()).unwrap();
296 }
297}