Skip to main content

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}