Skip to main content

apalis_sqlite/
shared.rs

1//! Shared SQLite storage for multiple workers.
2//!
3//! This module provides [`SqliteStorageFactory`], a factory for creating
4//! multiple [`SqliteStorage`] instances that share a single SQLite connection
5//! pool and task-fetching loop.
6//!
7//! ## Why shared storage?
8//!
9//! A SQLite backend can be used by multiple workers, but having every worker
10//! independently poll the database can result in unnecessary database queries
11//! and contention.
12//!
13//! [`SqliteStorageFactory`] addresses this by maintaining a
14//! single shared polling task for all storage instances created by the factory.
15//!
16//! When a task is inserted into the jobs table, SQLite's update hook notifies
17//! the shared poller.
18//!
19//! The poller fetches available tasks for all registered
20//! queues in a batch and routes each task to the [`SharedFetcher`] belonging to
21//! the corresponding storage instance.
22//!
23//! The resulting architecture is roughly:
24//!
25//! ```text
26//!                         SQLite
27//!                            │
28//!                     update hook
29//!                            │
30//!                            ▼
31//!                  ┌───────────────────┐
32//!                  │  Shared poller    │
33//!                  │                   │
34//!                  │ fetches tasks for │
35//!                  │ all registered    │
36//!                  │ queues in batches │
37//!                  └─────────┬─────────┘
38//!                            │
39//!              ┌─────────────┼─────────────┐
40//!              │             │             │
41//!              ▼             ▼             ▼
42//!          queue A       queue B       queue C
43//!              │             │             │
44//!              ▼             ▼             ▼
45//!          Worker A      Worker B      Worker C
46//! ```
47//!
48//! ## Creating a factory
49//!
50//! The factory owns the underlying [`SqlitePool`] and can create multiple
51//! backends from it:
52//!
53//! ```ignore
54//! # use apalis_sqlite::shared::SqliteStorageFactory;
55//! # use apalis_core::backend::factory::BackendFactory;
56//! let mut factory = SqliteStorageFactory::new("sqlite://jobs.db");
57//!
58//! let first = factory.create().unwrap();
59//! let second = factory.create().unwrap();
60//! ```
61//!
62//! Each backend has its own queue registration and receiver, while the
63//! database connection pool and polling task are shared.
64//!
65//! ## Queue registration
66//!
67//! Each backend created by the factory is associated with a queue derived from
68//! its [`Config`].
69//!
70//! A queue may only be registered once with a factory.
71//!
72//! Attempting to create another backend for an already-registered queue returns
73//! [`SharedSqliteError::NamespaceExists`].
74//!
75//! ## Task dispatch
76//!
77//! [`SharedFetcher`] implements [`Stream`] and receives tasks from the shared
78//! polling loop through an asynchronous channel.
79//!
80//!  The shared poller keeps running while at least one fetcher is being polled and dispatches each
81//! fetched task to the channel associated with its queue.
82//!
83//! The resulting backend is an [`Interleave`] combining the normal
84//! [`SqliteStorage`] implementation with [`SharedFetcher`].
85//!
86//!  This allows task insertion and other storage operations to continue using the normal SQLite
87//! backend while task consumption is coordinated by the shared polling loop.
88//!
89//! ## Pool configuration
90//!
91//! [`SqliteStorageFactory::new`] creates a pool with unlimited connection
92//! lifetime and idle timeout. For applications requiring more control over the
93//! SQLite pool, [`SqliteStorageFactory::new_with_pool_options`] accepts custom
94//! [`PoolOptions`].
95//!
96//! [`SqlitePool`]: sqlx::SqlitePool
97//! [`PoolOptions`]: sqlx::pool::PoolOptions
98//! [`Config`]: crate::Config
99//! [`Interleave`]: apalis_core::backend::ext::interleave::Interleave
100//! [`SqliteStorage`]: crate::SqliteStorage
101//! [`SqliteStorageFactory`]: crate::shared::SqliteStorageFactory
102//! [`SharedFetcher`]: crate::shared::SharedFetcher
103//! [`SharedSqliteError`]: crate::shared::SharedSqliteError
104//! [`Stream`]: futures::Stream
105use std::{
106    cmp::max,
107    collections::{HashMap, HashSet},
108    future::ready,
109    pin::Pin,
110    sync::Arc,
111    task::{Context, Poll},
112};
113
114use crate::{
115    Config, JOBS_TABLE, SqliteStorage, SqliteTask,
116    callback::{DbEvent, update_hook_callback},
117};
118use crate::{Error, from_row::SqliteTaskRow};
119
120use apalis_core::backend::{BackendConfig, ext::interleave::Interleave, factory::BackendFactory};
121use futures::{
122    FutureExt, SinkExt, Stream, StreamExt, TryStreamExt,
123    channel::mpsc::{self, Receiver, Sender},
124    future::{BoxFuture, Shared},
125    lock::Mutex,
126    ready,
127};
128use serde::{Serialize, de::DeserializeOwned};
129use sqlx::{Sqlite, SqlitePool, pool::PoolOptions, sqlite::SqliteOperation};
130
131/// An [`SqliteStorage`] interleaving with [`SharedFetcher`]
132pub type SharedSqliteStorage<Args> = Interleave<SqliteStorage<Args>, SharedFetcher>;
133
134type Registry = Arc<Mutex<HashMap<String, Sender<Result<SqliteTask, Error>>>>>;
135
136/// Shared Sqlite storage backend that can be used across multiple workers
137#[derive(Clone, Debug)]
138pub struct SqliteStorageFactory {
139    pool: SqlitePool,
140    registry: Registry,
141    drive: Shared<BoxFuture<'static, ()>>,
142}
143
144impl SqliteStorageFactory {
145    /// Get a reference to the underlying Sqlite connection pool
146    #[must_use]
147    pub fn pool(&self) -> &SqlitePool {
148        &self.pool
149    }
150}
151
152impl SqliteStorageFactory {
153    /// Create a new shared Sqlite storage backend with the given database URL and codec
154    #[must_use]
155    pub fn new(url: &str) -> SqliteStorageFactory {
156        Self::new_with_pool_options(
157            url,
158            PoolOptions::new().max_lifetime(None).idle_timeout(None),
159        )
160    }
161
162    /// Create a new shared Sqlite storage backend with the given database URL and pool options
163    #[must_use]
164    pub fn new_with_pool_options(url: &str, options: PoolOptions<Sqlite>) -> SqliteStorageFactory {
165        let (tx, rx) = mpsc::unbounded::<DbEvent>();
166        let pool = options
167            .after_connect(move |conn, _meta| {
168                let mut tx = tx.clone();
169                Box::pin(async move {
170                    let mut lock_handle = conn.lock_handle().await?;
171                    lock_handle.set_update_hook(move |ev| update_hook_callback(ev, &mut tx));
172                    Ok(())
173                })
174            })
175            .connect_lazy(url)
176            .expect("Failed to create Sqlite pool");
177
178        let registry: Registry = Registry::default();
179
180        let p = pool.clone();
181        let instances = registry.clone();
182        SqliteStorageFactory {
183            pool,
184            drive: async move {
185                rx.filter(|a| {
186                    ready(a.operation() == &SqliteOperation::Insert && a.table_name() == JOBS_TABLE)
187                })
188                .ready_chunks(instances.try_lock().map(|r| r.len()).unwrap_or(10))
189                .then(|events| {
190                    let row_ids = events.iter().map(|e| e.rowid()).collect::<HashSet<i64>>();
191                    let instances = instances.clone();
192                    let pool = p.clone();
193                    async move {
194                        let instances = instances.lock().await;
195                        let job_types = serde_json::to_string(
196                            &instances.keys().cloned().collect::<Vec<String>>(),
197                        )
198                        .map_err(Error::JsonError)?;
199                        let row_ids = serde_json::to_string(&row_ids).map_err(Error::JsonError)?;
200                        let mut tx = pool.begin().await?;
201                        let batch_size = max(10, instances.len()) as i32;
202                        let res: Vec<_> = sqlx::query_file_as!(
203                            SqliteTaskRow,
204                            "queries/backend/fetch_next_shared.sql",
205                            job_types,
206                            row_ids,
207                            batch_size,
208                        )
209                        .fetch(&mut *tx)
210                        .map_ok(|r| r.try_into())
211                        .try_collect()
212                        .await?;
213                        tx.commit().await?;
214                        Ok::<_, Error>(res)
215                    }
216                })
217                .map_ok(futures::stream::iter)
218                .try_flatten()
219                .for_each(|r: Result<SqliteTask, Error>| async {
220                    match r {
221                        Ok(task) => {
222                            let mut instances = instances.lock().await;
223                            if let Some(tx) = instances
224                                .get_mut(&task.queue().expect("Queue must be set").to_string())
225                                && let Err(e) = tx.send(Ok(task)).await
226                            {
227                                log::error!("Error pushing task: {e:?}");
228                            }
229                        }
230                        Err(e) => {
231                            log::error!("Error fetching tasks: {e:?}");
232                        }
233                    }
234                })
235                .await;
236            }
237            .boxed()
238            .shared(),
239            registry,
240        }
241    }
242}
243
244/// Errors that can occur when creating a shared Sqlite storage backend
245#[derive(Debug, thiserror::Error)]
246pub enum SharedSqliteError {
247    /// Namespace already exists in the registry
248    #[error("Namespace {0} already exists")]
249    NamespaceExists(String),
250    /// Could not acquire registry loc
251    #[error("Could not acquire registry lock")]
252    RegistryLocked,
253}
254
255impl<Args> BackendFactory<Args> for SqliteStorageFactory
256where
257    Args: Send + Sync + Serialize + DeserializeOwned + 'static,
258{
259    type Backend = SharedSqliteStorage<Args>;
260    type Error = SharedSqliteError;
261
262    fn create(&mut self) -> Result<Self::Backend, Self::Error>
263    where
264        <Self::Backend as BackendConfig>::Config: Default,
265    {
266        let config = Config::default().queue(std::any::type_name::<Args>());
267        self.create_with_config(config)
268    }
269
270    fn create_with_config(
271        &mut self,
272        config: <Self::Backend as BackendConfig>::Config,
273    ) -> Result<Self::Backend, Self::Error> {
274        let (tx, rx) = mpsc::channel(config.batch_size);
275        let mut r = self
276            .registry
277            .try_lock()
278            .ok_or(SharedSqliteError::RegistryLocked)?;
279        if r.insert(config.queue.to_string(), tx).is_some() {
280            return Err(SharedSqliteError::NamespaceExists(config.queue.to_string()));
281        }
282        Ok(Interleave::new(
283            SqliteStorage::new(&self.pool).with_config(config),
284            SharedFetcher {
285                poller: self.drive.clone(),
286                receiver: Arc::new(Mutex::new(rx)),
287            },
288        ))
289    }
290}
291
292/// A fetcher that uses a channel to receive jobs from a shared polling point
293#[derive(Clone, Debug)]
294pub struct SharedFetcher {
295    poller: Shared<BoxFuture<'static, ()>>,
296    receiver: Arc<Mutex<Receiver<Result<SqliteTask, Error>>>>,
297}
298
299impl Stream for SharedFetcher {
300    type Item = Result<SqliteTask, Error>;
301
302    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
303        let this = self.get_mut();
304        // Keep the poller alive by polling it, but ignoring the output
305        let _ = this.poller.poll_unpin(cx);
306
307        let mut guard = ready!(this.receiver.lock().poll_unpin(cx));
308        guard.poll_next_unpin(cx)
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use std::time::Duration;
315
316    use apalis_core::{
317        backend::TaskSink,
318        error::BoxDynError,
319        task::task_id::TaskId,
320        worker::{builder::WorkerBuilder, context::WorkerContext},
321    };
322
323    use super::*;
324
325    #[tokio::test]
326    async fn factory_worker() {
327        let mut factory = SqliteStorageFactory::new(":memory:");
328        SqliteStorage::setup(factory.pool()).await.unwrap();
329
330        let mut map_store = factory.create().unwrap();
331
332        let mut int_store: SharedSqliteStorage<usize> = factory.create().unwrap();
333
334        map_store
335            .push(HashMap::<String, i32>::from([("value".to_string(), 42)]))
336            .await
337            .unwrap();
338        int_store.push(99).await.unwrap();
339
340        async fn send_reminder<T>(
341            _: T,
342            _task_id: TaskId,
343            wrk: WorkerContext,
344        ) -> Result<(), BoxDynError> {
345            tokio::time::sleep(Duration::from_secs(2)).await;
346            wrk.stop().unwrap();
347            Ok(())
348        }
349
350        let int_worker = WorkerBuilder::new("rango-tango-2")
351            .backend(int_store)
352            .build(send_reminder);
353        let map_worker = WorkerBuilder::new("rango-tango-1")
354            .backend(map_store)
355            .build(send_reminder);
356        tokio::try_join!(int_worker.run(), map_worker.run()).unwrap();
357    }
358}