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}