Skip to main content

cronoq_store_sqlite/
lib.rs

1//! # cronoq-store-sqlite
2//!
3//! A SQLite [`Store`] for cronoq: durable state in one local file, no database server.
4//!
5//! - **Write-ahead state**: every engine state transition is committed in batches, one
6//!   transaction per batch, before submitters are acknowledged. The file uses SQLite's WAL
7//!   journal and, by default, syncs it on every commit.
8//! - **Leader and standbys**: several processes on the same host may open the same file. One
9//!   leads through a lease row in `cronoq_leader` that it renews; the others wait and take over
10//!   once the lease runs out. Taking over bumps a fencing epoch.
11//! - **Fencing**: each commit re-reads the epoch and lease holder inside its write transaction
12//!   (SQLite serializes writers) and fails with [`StoreError::Fenced`] if another process took
13//!   over, so a paused old leader can never write after a standby replaced it.
14//! - **Recovery**: a new leader loads live tasks, recently finished ones (for key
15//!   deduplication), every finished task a live task still depends on, and recurring
16//!   schedules. Rows that cannot be decoded are quarantined (marked failed).
17//! - **Files**: the database is created readable and writable by its owner only (on Unix). Keep
18//!   it on a local filesystem: SQLite's WAL mode does not work over network filesystems.
19//!
20//! Times are stored as wall-clock Unix nanoseconds; strings are stored with NUL replaced by
21//! U+FFFD, as in the PostgreSQL store. Lease expiry uses the wall clock, which processes on one
22//! host share.
23//!
24//! ```no_run
25//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
26//! use bytes::Bytes;
27//! use cronoq::{Pool, Scheduler};
28//! use cronoq_store_sqlite::SqliteStore;
29//!
30//! let store = SqliteStore::open("/var/lib/cronoq/state.db").await?;
31//! let sched = Scheduler::<Bytes, Bytes>::builder()
32//!     .pool(Pool::new("default").executor_fn(|_ctx, p: std::sync::Arc<Bytes>| async move {
33//!         Ok::<_, cronoq::TaskError>((*p).clone())
34//!     }))
35//!     .store(store.clone())
36//!     .build() // blocks until this process is the leader
37//!     .await?;
38//! tokio::select! {
39//!     _ = store.leadership_lost() => { /* step down: stop serving, exit or rejoin */ }
40//!     _ = tokio::signal::ctrl_c() => {}
41//! }
42//! # Ok(()) }
43//! ```
44
45#![warn(missing_docs)]
46
47use std::collections::hash_map::Entry;
48use std::collections::{HashMap, HashSet};
49use std::path::Path;
50use std::sync::{Arc, Mutex};
51use std::time::{Duration, SystemTime, UNIX_EPOCH};
52
53use async_trait::async_trait;
54use bytes::Bytes;
55use cronoq::codec::*;
56use cronoq::{Store, StoreError, StoreQueries, TimeBase};
57use cronoq_core::{
58    Mutation, Priority, Recovered, RecoveredRecurring, RecoveredTask, RecoveredTerminal, TaskId,
59    TaskRef, TaskSpec, TaskStatus, Time,
60};
61use sqlx::sqlite::{
62    SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,
63    SqliteSynchronous,
64};
65use sqlx::{AssertSqlSafe, Row, Sqlite, Transaction};
66use tokio::sync::watch;
67use tokio::task::JoinHandle;
68
69/// Ordered schema migrations; `cronoq_schema_version` records how many were applied.
70const MIGRATIONS: &[&str] = &[include_str!("schema.sql")];
71/// Status stored for tasks that are live but have not started an attempt yet.
72const QUEUED: &str = "queued";
73/// Fencing epochs stay within 1..=32767 so epoch-prefixed task ids fit in a signed integer.
74const MAX_EPOCH: i64 = 32767;
75/// Rows deleted per statement by [`SqliteStore::prune`].
76const PRUNE_BATCH: i64 = 1000;
77/// Longest wait between attempts to reach the database while acquiring leadership.
78const MAX_RETRY_DELAY: Duration = Duration::from_secs(5);
79
80/// Persisted view of a task, for lookups by id or key.
81pub type TaskRecord = cronoq::TaskRecord<Bytes>;
82
83/// Store configuration.
84#[derive(Clone, Debug)]
85pub struct SqliteStoreConfig {
86    /// How long finished tasks are loaded on recovery, for key deduplication. Finished tasks
87    /// that live tasks depend on are always loaded.
88    pub retention: Duration,
89    /// How long a leader's lease lasts without renewal; a standby takes over once it runs out.
90    pub lease_ttl: Duration,
91    /// How often the leader renews its lease (well below `lease_ttl`).
92    pub lease_renew: Duration,
93    /// How often a standby checks whether the lease has run out.
94    pub lock_poll: Duration,
95    /// How long a statement waits for another process's write to finish.
96    pub busy_timeout: Duration,
97    /// Upper bound for one commit.
98    pub commit_timeout: Duration,
99    /// Sync the journal to disk on every commit (default). Without it commits are faster, but a
100    /// power loss can drop the most recent ones (the file stays consistent).
101    pub full_sync: bool,
102    /// Connections in the pool.
103    pub max_connections: u32,
104}
105
106impl Default for SqliteStoreConfig {
107    fn default() -> Self {
108        SqliteStoreConfig {
109            retention: Duration::from_secs(3600),
110            lease_ttl: Duration::from_secs(10),
111            lease_renew: Duration::from_secs(2),
112            lock_poll: Duration::from_millis(500),
113            busy_timeout: Duration::from_secs(5),
114            commit_timeout: Duration::from_secs(30),
115            full_sync: true,
116            max_connections: 4,
117        }
118    }
119}
120
121struct Leader {
122    epoch: u16,
123    keeper: JoinHandle<()>,
124}
125
126struct Inner {
127    pool: SqlitePool,
128    cfg: SqliteStoreConfig,
129    /// Identifies this store instance as the lease holder.
130    holder: String,
131    leader: Mutex<Option<Leader>>,
132    lost: watch::Sender<bool>,
133}
134
135impl Drop for Inner {
136    fn drop(&mut self) {
137        if let Some(l) = self.leader.get_mut().unwrap().take() {
138            l.keeper.abort();
139        }
140    }
141}
142
143/// SQLite-backed [`Store`]. Cheap to clone.
144#[derive(Clone)]
145pub struct SqliteStore {
146    inner: Arc<Inner>,
147}
148
149/// Map a database error, classifying failures that are worth retrying as `Unavailable`.
150fn db(e: sqlx::Error) -> StoreError {
151    use sqlx::Error as E;
152    let transient = match &e {
153        E::Io(_) | E::PoolTimedOut | E::WorkerCrashed => true,
154        // Primary result codes 5 (SQLITE_BUSY) and 6 (SQLITE_LOCKED): another process holds
155        // the write lock longer than the busy timeout.
156        E::Database(d) => d
157            .code()
158            .and_then(|c| c.parse::<i32>().ok())
159            .is_some_and(|c| matches!(c & 0xff, 5 | 6)),
160        _ => false,
161    };
162    if transient {
163        StoreError::Unavailable(e.to_string())
164    } else {
165        StoreError::Other(e.to_string())
166    }
167}
168
169fn now_unix() -> i64 {
170    SystemTime::now()
171        .duration_since(UNIX_EPOCH)
172        .unwrap_or_default()
173        .as_nanos()
174        .min(i64::MAX as u128) as i64
175}
176
177/// A migration split into statements. Comment lines are dropped first; migrations have no
178/// string literals containing semicolons.
179fn statements(sql: &str) -> Vec<String> {
180    let code: String = sql
181        .lines()
182        .filter(|l| !l.trim_start().starts_with("--"))
183        .collect::<Vec<_>>()
184        .join("\n");
185    code.split(';')
186        .map(|s| s.trim().to_string())
187        .filter(|s| !s.is_empty())
188        .collect()
189}
190
191/// A random identifier for this store instance.
192fn new_holder() -> String {
193    use std::hash::{BuildHasher, RandomState};
194    let (a, b) = (RandomState::new(), RandomState::new());
195    format!(
196        "{:x}-{:016x}{:016x}",
197        std::process::id(),
198        a.hash_one(now_unix()),
199        b.hash_one(std::thread::current().id())
200    )
201}
202
203/// Create the database file readable and writable by its owner only, if it does not exist.
204/// SQLite gives its journal files the same permissions.
205fn create_private(path: &Path) -> std::io::Result<()> {
206    #[cfg(unix)]
207    {
208        use std::os::unix::fs::OpenOptionsExt;
209        match std::fs::OpenOptions::new()
210            .write(true)
211            .create_new(true)
212            .mode(0o600)
213            .open(path)
214        {
215            Ok(_) => Ok(()),
216            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
217            Err(e) => Err(e),
218        }
219    }
220    #[cfg(not(unix))]
221    {
222        let _ = path;
223        Ok(())
224    }
225}
226
227impl SqliteStore {
228    /// Open (creating if needed) the database at `path` with default settings.
229    pub async fn open(path: impl AsRef<Path>) -> Result<SqliteStore, StoreError> {
230        Self::open_with(path, SqliteStoreConfig::default()).await
231    }
232
233    /// Open (creating if needed) the database at `path` with `cfg` and apply the schema.
234    pub async fn open_with(
235        path: impl AsRef<Path>,
236        cfg: SqliteStoreConfig,
237    ) -> Result<SqliteStore, StoreError> {
238        let path = path.as_ref();
239        // SQLite treats ":..." names and "file:" URIs specially (in-memory databases, options
240        // that turn off the locking fencing relies on); only plain file paths are accepted.
241        let text = path.to_string_lossy();
242        if text.is_empty()
243            || text.starts_with(':')
244            || text
245                .get(..5)
246                .is_some_and(|p| p.eq_ignore_ascii_case("file:"))
247        {
248            return Err(StoreError::Other(format!(
249                "invalid SQLite path {path:?}: use a plain file path on a local filesystem"
250            )));
251        }
252        create_private(path)
253            .map_err(|e| StoreError::Other(format!("creating {}: {e}", path.display())))?;
254        let opts = SqliteConnectOptions::new()
255            .filename(path)
256            .create_if_missing(true)
257            .journal_mode(SqliteJournalMode::Wal)
258            .synchronous(if cfg.full_sync {
259                SqliteSynchronous::Full
260            } else {
261                SqliteSynchronous::Normal
262            })
263            .busy_timeout(cfg.busy_timeout)
264            .foreign_keys(false);
265        let pool = SqlitePoolOptions::new()
266            .max_connections(cfg.max_connections.max(1))
267            .connect_with(opts)
268            .await
269            .map_err(db)?;
270        Self::migrate_pool(&pool).await?;
271        Ok(SqliteStore {
272            inner: Arc::new(Inner {
273                pool,
274                cfg,
275                holder: new_holder(),
276                leader: Mutex::new(None),
277                lost: watch::channel(false).0,
278            }),
279        })
280    }
281
282    /// Apply pending schema migrations (idempotent; serialized across processes).
283    /// [`SqliteStore::open`] already does this.
284    pub async fn migrate(&self) -> Result<(), StoreError> {
285        Self::migrate_pool(&self.inner.pool).await
286    }
287
288    async fn migrate_pool(pool: &SqlitePool) -> Result<(), StoreError> {
289        let mut tx = pool.begin_with("BEGIN IMMEDIATE").await.map_err(db)?;
290        sqlx::query(
291            "CREATE TABLE IF NOT EXISTS cronoq_schema_version \
292             (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)",
293        )
294        .execute(&mut *tx)
295        .await
296        .map_err(db)?;
297        sqlx::query(
298            "INSERT INTO cronoq_schema_version (id, version) VALUES (1, 0) \
299             ON CONFLICT (id) DO NOTHING",
300        )
301        .execute(&mut *tx)
302        .await
303        .map_err(db)?;
304        let version: i64 = sqlx::query_scalar("SELECT version FROM cronoq_schema_version")
305            .fetch_one(&mut *tx)
306            .await
307            .map_err(db)?;
308        let version = usize::try_from(version).unwrap_or(0);
309        if version > MIGRATIONS.len() {
310            return Err(StoreError::Other(format!(
311                "database schema version {version} is newer than this build supports ({})",
312                MIGRATIONS.len()
313            )));
314        }
315        for m in &MIGRATIONS[version..] {
316            for stmt in statements(m) {
317                // Migrations are compile-time constants of this crate.
318                sqlx::query(AssertSqlSafe(stmt))
319                    .execute(&mut *tx)
320                    .await
321                    .map_err(db)?;
322            }
323        }
324        if version < MIGRATIONS.len() {
325            sqlx::query("UPDATE cronoq_schema_version SET version = ?1 WHERE id = 1")
326                .bind(MIGRATIONS.len() as i64)
327                .execute(&mut *tx)
328                .await
329                .map_err(db)?;
330        }
331        tx.commit().await.map_err(db)
332    }
333
334    /// The connection pool, for monitoring queries.
335    pub fn pool(&self) -> &SqlitePool {
336        &self.inner.pool
337    }
338
339    /// The configuration this store was opened with.
340    pub fn config(&self) -> &SqliteStoreConfig {
341        &self.inner.cfg
342    }
343
344    /// Current fencing epoch, if this instance holds leadership.
345    pub fn epoch(&self) -> Option<u16> {
346        if *self.inner.lost.borrow() {
347            return None;
348        }
349        self.inner.leader.lock().unwrap().as_ref().map(|l| l.epoch)
350    }
351
352    /// Whether this instance currently holds leadership.
353    pub fn is_leader(&self) -> bool {
354        self.epoch().is_some()
355    }
356
357    /// Resolves once leadership held by this instance is lost (lease taken over or not
358    /// renewable).
359    pub async fn leadership_lost(&self) {
360        let mut rx = self.inner.lost.subscribe();
361        let _ = rx.wait_for(|lost| *lost).await;
362    }
363
364    /// Give up leadership, letting a standby take over at once.
365    pub async fn release_leadership(&self) {
366        let old = self.inner.leader.lock().unwrap().take();
367        if let Some(l) = old {
368            l.keeper.abort();
369            let _ = l.keeper.await;
370            let r = sqlx::query(
371                "UPDATE cronoq_leader SET holder = NULL, expires_unix_ns = 0 \
372                 WHERE id = 1 AND holder = ?1",
373            )
374            .bind(&self.inner.holder)
375            .execute(&self.inner.pool)
376            .await;
377            if let Err(e) = r {
378                tracing::warn!(error = %e, "could not clear the leader lease; it will expire");
379            }
380        }
381        self.inner.lost.send_replace(true);
382    }
383
384    /// Look up a task by id, or the newest task with a key.
385    pub async fn get_task(&self, target: &TaskRef) -> Result<Option<TaskRecord>, StoreError> {
386        let row = match target {
387            TaskRef::Id(id) => sqlx::query(select_record!("WHERE id = ?1"))
388                .bind(id.0 as i64)
389                .fetch_optional(&self.inner.pool)
390                .await
391                .map_err(db)?,
392            TaskRef::Key(k) => {
393                sqlx::query(select_record!("WHERE key = ?1 ORDER BY id DESC LIMIT 1"))
394                    .bind(clean(k))
395                    .fetch_optional(&self.inner.pool)
396                    .await
397                    .map_err(db)?
398            }
399        };
400        Ok(row.as_ref().map(record))
401    }
402
403    /// Finished tasks in id order: those in one of `pools` (any if empty) with one of
404    /// `statuses` (any finished status if empty) and ids above `after`, at most `limit`.
405    pub async fn list_finished(
406        &self,
407        pools: &[&str],
408        statuses: &[TaskStatus],
409        after: Option<TaskId>,
410        limit: usize,
411    ) -> Result<Vec<TaskRecord>, StoreError> {
412        let names: Vec<&str> = statuses.iter().map(|s| s.name()).collect();
413        let rows = sqlx::query(select_record!(
414            "WHERE terminal = 1 AND (json_array_length(?1) = 0 OR pool IN (SELECT value FROM json_each(?1))) \
415             AND id > ?2 \
416             AND (json_array_length(?3) = 0 OR status IN (SELECT value FROM json_each(?3))) \
417             ORDER BY id LIMIT ?4"
418        ))
419        .bind(serde_json::to_string(pools).expect("strings serialize"))
420        .bind(after.map_or(-1, |a| a.0.min(i64::MAX as u64) as i64))
421        .bind(serde_json::to_string(&names).expect("strings serialize"))
422        .bind(limit.min(i64::MAX as usize) as i64)
423        .fetch_all(&self.inner.pool)
424        .await
425        .map_err(db)?;
426        Ok(rows.iter().map(record).collect())
427    }
428
429    /// Delete finished tasks last updated more than `older_than` ago, except those that live
430    /// tasks still depend on. Deletes in bounded batches; returns the number of rows removed.
431    pub async fn prune(&self, older_than: Duration) -> Result<u64, StoreError> {
432        let cutoff = now_unix().saturating_sub(dur_ns(older_than));
433        let mut total = 0;
434        loop {
435            let r = sqlx::query(
436                "WITH refs AS ( \
437                   SELECT json_extract(d.value, '$.id') AS id, json_extract(d.value, '$.key') AS key \
438                   FROM cronoq_tasks l, json_each(l.deps) d \
439                   WHERE l.terminal = 0 AND l.deps <> '[]'), \
440                 victims AS ( \
441                   SELECT t.id FROM cronoq_tasks t \
442                   WHERE t.terminal = 1 AND t.updated_unix_ns < ?1 \
443                   AND t.id NOT IN (SELECT id FROM refs WHERE id IS NOT NULL) \
444                   AND (t.key IS NULL OR t.key NOT IN (SELECT key FROM refs WHERE key IS NOT NULL)) \
445                   ORDER BY t.updated_unix_ns LIMIT ?2) \
446                 DELETE FROM cronoq_tasks WHERE id IN (SELECT id FROM victims)",
447            )
448            .bind(cutoff)
449            .bind(PRUNE_BATCH)
450            .execute(&self.inner.pool)
451            .await
452            .map_err(db)?;
453            total += r.rows_affected();
454            if r.rows_affected() < PRUNE_BATCH as u64 {
455                return Ok(total);
456            }
457        }
458    }
459
460    /// Take the lease if it is free or expired; returns the new epoch, or `None` while another
461    /// instance holds it.
462    async fn try_lead(&self) -> Result<Option<u16>, StoreError> {
463        let inner = &self.inner;
464        let mut tx = inner.pool.begin_with("BEGIN IMMEDIATE").await.map_err(db)?;
465        let row =
466            sqlx::query("SELECT epoch, holder, expires_unix_ns FROM cronoq_leader WHERE id = 1")
467                .fetch_one(&mut *tx)
468                .await
469                .map_err(db)?;
470        let epoch: i64 = row.get("epoch");
471        let holder: Option<String> = row.get("holder");
472        let expires: i64 = row.get("expires_unix_ns");
473        let now = now_unix();
474        let free = holder.is_none() || holder.as_deref() == Some(&inner.holder) || expires <= now;
475        if !free {
476            return Ok(None); // dropping the transaction rolls it back
477        }
478        let next = if epoch >= MAX_EPOCH { 1 } else { epoch + 1 };
479        sqlx::query(
480            "UPDATE cronoq_leader SET epoch = ?1, holder = ?2, expires_unix_ns = ?3 WHERE id = 1",
481        )
482        .bind(next)
483        .bind(&inner.holder)
484        .bind(now.saturating_add(dur_ns(inner.cfg.lease_ttl)))
485        .execute(&mut *tx)
486        .await
487        .map_err(db)?;
488        tx.commit().await.map_err(db)?;
489        Ok(Some(next as u16))
490    }
491
492    /// Renew the lease until it is lost: taken over by another instance, or not renewed
493    /// before it ran out.
494    async fn keeper(store: std::sync::Weak<Inner>, epoch: u16, expires: i64) {
495        let mut expires = expires;
496        loop {
497            let Some(inner) = store.upgrade() else { return };
498            let every = inner.cfg.lease_renew;
499            let ttl = dur_ns(inner.cfg.lease_ttl);
500            drop(inner);
501            tokio::time::sleep(every).await;
502            let Some(inner) = store.upgrade() else { return };
503            let now = now_unix();
504            let r = sqlx::query(
505                "UPDATE cronoq_leader SET expires_unix_ns = ?1 \
506                 WHERE id = 1 AND epoch = ?2 AND holder = ?3",
507            )
508            .bind(now.saturating_add(ttl))
509            .bind(epoch as i64)
510            .bind(&inner.holder)
511            .execute(&inner.pool)
512            .await;
513            match r {
514                Ok(r) if r.rows_affected() == 1 => expires = now.saturating_add(ttl),
515                Ok(_) => {
516                    tracing::error!("cronoq leader lease was taken over; stepping down");
517                    inner.lost.send_replace(true);
518                    return;
519                }
520                Err(e) if now_unix() >= expires => {
521                    tracing::error!(error = %e, "cronoq leader lease could not be renewed before it ran out; stepping down");
522                    inner.lost.send_replace(true);
523                    return;
524                }
525                Err(e) => {
526                    tracing::warn!(error = %e, "renewing the cronoq leader lease failed; retrying")
527                }
528            }
529        }
530    }
531
532    async fn commit_inner(
533        &self,
534        epoch: u16,
535        base: TimeBase,
536        batch: &[Mutation<Bytes>],
537        outputs: &[(TaskId, &Bytes)],
538    ) -> Result<(), StoreError> {
539        // Fold the batch: new tasks are inserted with their latest status; tasks inserted
540        // earlier get one update each. Later mutations win; a message carries forward until a
541        // later transition replaces it, so the result does not depend on batching.
542        let now = now_unix();
543        let mut inserts: Vec<InsertRow> = Vec::new();
544        let mut inserted: HashMap<TaskId, usize> = HashMap::new();
545        let mut updates: Vec<(TaskId, StatusRow)> = Vec::new();
546        let mut updated: HashMap<TaskId, usize> = HashMap::new();
547        for m in batch {
548            match m {
549                Mutation::Submitted {
550                    id,
551                    seq,
552                    arrival,
553                    spec,
554                } => {
555                    if let Entry::Vacant(e) = inserted.entry(*id) {
556                        e.insert(inserts.len());
557                        inserts.push(InsertRow::new(base, now, *id, *seq, *arrival, spec));
558                    }
559                }
560                Mutation::Status {
561                    id,
562                    status: st,
563                    attempt,
564                    at,
565                    message,
566                } => {
567                    let row = StatusRow {
568                        status: *st,
569                        attempt: *attempt,
570                        at: to_unix(base, *at),
571                        message: message.as_deref().map(cleaned),
572                    };
573                    if let Some(&i) = inserted.get(id) {
574                        match &mut inserts[i].status {
575                            Some(s) => s.fold(row),
576                            None => inserts[i].status = Some(row),
577                        }
578                    } else {
579                        match updated.entry(*id) {
580                            Entry::Occupied(e) => updates[*e.get()].1.fold(row),
581                            Entry::Vacant(e) => {
582                                e.insert(updates.len());
583                                updates.push((*id, row));
584                            }
585                        }
586                    }
587                }
588                _ => {}
589            }
590        }
591
592        let inner = &self.inner;
593        let mut tx = inner.pool.begin_with("BEGIN IMMEDIATE").await.map_err(db)?;
594        let row = sqlx::query("SELECT epoch, holder FROM cronoq_leader WHERE id = 1")
595            .fetch_one(&mut *tx)
596            .await
597            .map_err(db)?;
598        let (current, holder): (i64, Option<String>) = (row.get("epoch"), row.get("holder"));
599        if current != epoch as i64 || holder.as_deref() != Some(&inner.holder) {
600            inner.lost.send_replace(true);
601            return Err(StoreError::Fenced);
602        }
603        // Record the id high-water mark and extend the lease while holding the write lock.
604        let max_id = inserts.iter().map(|r| r.id).max().unwrap_or(0);
605        sqlx::query(
606            "UPDATE cronoq_leader SET max_id = MAX(max_id, ?1), expires_unix_ns = ?2 WHERE id = 1",
607        )
608        .bind(max_id)
609        .bind(now.saturating_add(dur_ns(inner.cfg.lease_ttl)))
610        .execute(&mut *tx)
611        .await
612        .map_err(db)?;
613
614        // 1. Transitions of existing tasks first, so keys they release can be reused by tasks
615        //    inserted in this batch.
616        let mut missing = 0usize;
617        for (id, row) in &updates {
618            let r = sqlx::query(
619                "UPDATE cronoq_tasks SET status = ?2, terminal = ?3, attempt = ?4, \
620                 updated_unix_ns = ?5, message = COALESCE(?6, message) WHERE id = ?1",
621            )
622            .bind(id.0 as i64)
623            .bind(row.status.name())
624            .bind(row.status.is_terminal())
625            .bind(row.attempt as i64)
626            .bind(row.at)
627            .bind(row.message.as_deref())
628            .execute(&mut *tx)
629            .await
630            .map_err(db)?;
631            missing += (r.rows_affected() == 0) as usize;
632        }
633        if missing > 0 {
634            tracing::warn!(
635                missing,
636                "status updates for tasks missing from the store (pruned?)"
637            );
638        }
639        // 2. New tasks.
640        for r in &inserts {
641            r.execute(&mut tx).await?;
642        }
643        // 3. Outputs of tasks that succeeded in this batch.
644        let mut missing = 0usize;
645        for (id, out) in outputs {
646            let r = sqlx::query("UPDATE cronoq_tasks SET output = ?2 WHERE id = ?1")
647                .bind(id.0 as i64)
648                .bind(out.as_ref())
649                .execute(&mut *tx)
650                .await
651                .map_err(db)?;
652            missing += (r.rows_affected() == 0) as usize;
653        }
654        if missing > 0 {
655            tracing::warn!(missing, "outputs for tasks missing from the store");
656        }
657        // 4. Recurring schedules, in order.
658        for m in batch {
659            match m {
660                Mutation::RecurringUpsert { spec } => {
661                    sqlx::query(
662                        "INSERT INTO cronoq_recurring (name, spec, template_payload) VALUES (?1, ?2, ?3) \
663                         ON CONFLICT (name) DO UPDATE SET spec = excluded.spec, \
664                         template_payload = excluded.template_payload",
665                    )
666                    .bind(clean(&spec.name))
667                    .bind(recurring_json(base, spec))
668                    .bind(&spec.template.payload[..])
669                    .execute(&mut *tx)
670                    .await
671                    .map_err(db)?;
672                }
673                Mutation::RecurringRemove { name } => {
674                    sqlx::query("DELETE FROM cronoq_recurring WHERE name = ?1")
675                        .bind(clean(name))
676                        .execute(&mut *tx)
677                        .await
678                        .map_err(db)?;
679                }
680                Mutation::RecurringNext { name, next, last } => {
681                    sqlx::query(
682                        "UPDATE cronoq_recurring SET next_unix_ns = ?2, last_task_id = ?3 \
683                         WHERE name = ?1",
684                    )
685                    .bind(clean(name))
686                    .bind(next.map(|t| to_unix(base, t)))
687                    .bind(last.map(|id| id.0 as i64))
688                    .execute(&mut *tx)
689                    .await
690                    .map_err(db)?;
691                }
692                _ => {}
693            }
694        }
695        tx.commit().await.map_err(db)
696    }
697}
698
699/// Latest transition of a task within a batch.
700struct StatusRow {
701    status: TaskStatus,
702    attempt: u32,
703    at: i64,
704    message: Option<String>,
705}
706
707impl StatusRow {
708    /// Apply a later transition, keeping the message unless the later one has its own.
709    fn fold(&mut self, later: StatusRow) {
710        let message = later.message.or(self.message.take());
711        *self = StatusRow { message, ..later };
712    }
713}
714
715/// One new task row.
716struct InsertRow {
717    id: i64,
718    seq: i64,
719    arrival: i64,
720    pool: String,
721    kind: String,
722    priority: i64,
723    key: Option<String>,
724    payload: Bytes,
725    deadline: Option<i64>,
726    fixed: (Option<i64>, Option<i64>, Option<String>),
727    hint: Option<i64>,
728    timeout: Option<i64>,
729    retry: Option<String>,
730    deps: String,
731    resources: String,
732    tags: String,
733    labels: String,
734    tenant: Option<String>,
735    created: i64,
736    /// The latest transition within the batch; `None` while the task has not moved.
737    status: Option<StatusRow>,
738}
739
740impl InsertRow {
741    fn new(
742        base: TimeBase,
743        now: i64,
744        id: TaskId,
745        seq: u64,
746        arrival: Time,
747        s: &TaskSpec<Bytes>,
748    ) -> InsertRow {
749        InsertRow {
750            id: id.0 as i64,
751            seq: seq as i64,
752            arrival: to_unix(base, arrival),
753            pool: cleaned(&s.pool),
754            kind: cleaned(&s.kind),
755            priority: s.priority as i64,
756            key: s.key.as_deref().map(cleaned),
757            payload: (*s.payload).clone(),
758            deadline: s.deadline.map(|d| to_unix(base, d)),
759            fixed: fixed_cols(base, &s.fixed_start),
760            hint: s.duration_hint.map(dur_ns),
761            timeout: s.timeout.map(dur_ns),
762            retry: retry_json(&s.retry),
763            deps: deps_json(&s.deps),
764            resources: resources_json(s),
765            tags: tags_json(s),
766            labels: labels_json(s),
767            tenant: s.tenant.as_deref().map(cleaned),
768            created: now,
769            status: None,
770        }
771    }
772
773    async fn execute(&self, tx: &mut Transaction<'static, Sqlite>) -> Result<(), StoreError> {
774        let (status, terminal, attempt, at, message) = match &self.status {
775            Some(st) => (
776                st.status.name(),
777                st.status.is_terminal(),
778                st.attempt as i64,
779                st.at,
780                st.message.as_deref(),
781            ),
782            None => (QUEUED, false, 0, self.created, None),
783        };
784        sqlx::query(
785            "INSERT INTO cronoq_tasks (id, seq, arrival_unix_ns, pool, kind, priority, key, payload, \
786             deadline_unix_ns, fixed_at_unix_ns, fixed_tolerance_ns, fixed_on_miss, duration_hint_ns, \
787             timeout_ns, retry, deps, resources, tags, status, terminal, attempt, updated_unix_ns, message, \
788             labels, tenant) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, \
789             ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25) ON CONFLICT (id) DO NOTHING",
790        )
791        .bind(self.id)
792        .bind(self.seq)
793        .bind(self.arrival)
794        .bind(&self.pool)
795        .bind(&self.kind)
796        .bind(self.priority)
797        .bind(self.key.as_deref())
798        .bind(self.payload.as_ref())
799        .bind(self.deadline)
800        .bind(self.fixed.0)
801        .bind(self.fixed.1)
802        .bind(self.fixed.2.as_deref())
803        .bind(self.hint)
804        .bind(self.timeout)
805        .bind(self.retry.as_deref())
806        .bind(&self.deps)
807        .bind(&self.resources)
808        .bind(&self.tags)
809        .bind(status)
810        .bind(terminal)
811        .bind(attempt)
812        .bind(at)
813        .bind(message)
814        .bind(&self.labels)
815        .bind(self.tenant.as_deref())
816        .execute(&mut **tx)
817        .await
818        .map_err(db)?;
819        Ok(())
820    }
821}
822
823/// Decode a live task row.
824fn decode_task(base: TimeBase, r: &SqliteRow) -> Result<RecoveredTask<Bytes>, String> {
825    let mut spec = TaskSpec::new(Bytes::from(r.get::<Vec<u8>, _>("payload")))
826        .kind(r.get::<String, _>("kind"))
827        .pool(r.get::<String, _>("pool"))
828        .priority(
829            Priority::from_index(r.get::<i64, _>("priority").max(0) as usize).unwrap_or_default(),
830        );
831    spec.key = r.get::<Option<String>, _>("key").map(Into::into);
832    spec.deadline = r
833        .get::<Option<i64>, _>("deadline_unix_ns")
834        .map(|u| from_unix(base, u));
835    if let Some(at) = r.get::<Option<i64>, _>("fixed_at_unix_ns") {
836        spec.fixed_start = Some(cronoq_core::FixedStart {
837            at: from_unix(base, at),
838            tolerance: ns_dur(
839                r.get::<Option<i64>, _>("fixed_tolerance_ns")
840                    .unwrap_or(1_000_000_000),
841            ),
842            on_miss: miss_from(
843                r.get::<Option<String>, _>("fixed_on_miss")
844                    .as_deref()
845                    .unwrap_or(""),
846            ),
847        });
848    }
849    spec.duration_hint = r.get::<Option<i64>, _>("duration_hint_ns").map(ns_dur);
850    spec.timeout = r.get::<Option<i64>, _>("timeout_ns").map(ns_dur);
851    spec.retry = retry_from(r.get::<Option<String>, _>("retry").as_deref())?;
852    spec.deps = deps_from(&r.get::<String, _>("deps"))?;
853    spec.resources = resources_from(&r.get::<String, _>("resources"))?
854        .into_iter()
855        .collect();
856    spec.tags = tags_from(&r.get::<String, _>("tags"))?;
857    spec.labels = tags_from(&r.get::<String, _>("labels"))?;
858    spec.tenant = r.get::<Option<String>, _>("tenant").map(Into::into);
859    Ok(RecoveredTask {
860        id: TaskId(r.get::<i64, _>("id") as u64),
861        seq: r.get::<i64, _>("seq") as u64,
862        arrival: from_unix(base, r.get("arrival_unix_ns")),
863        spec,
864        attempt: r.get::<i64, _>("attempt").clamp(0, u32::MAX as i64) as u32,
865    })
866}
867
868fn terminal_from(base: TimeBase, r: &SqliteRow) -> Option<RecoveredTerminal> {
869    let status: String = r.get("status");
870    Some(RecoveredTerminal {
871        id: TaskId(r.get::<i64, _>("id") as u64),
872        key: r.get::<Option<String>, _>("key").map(Into::into),
873        status: TaskStatus::from_name(&status)?,
874        at: from_unix(base, r.get("updated_unix_ns")),
875    })
876}
877
878/// Selects the columns [`record`] reads, followed by `$rest`.
879macro_rules! select_record {
880    ($rest:literal) => {
881        concat!(
882            "SELECT id, key, pool, kind, priority, tenant, status, attempt, message, output, \
883             updated_unix_ns FROM cronoq_tasks ",
884            $rest
885        )
886    };
887}
888use select_record;
889
890fn record(r: &SqliteRow) -> TaskRecord {
891    let status: String = r.get("status");
892    let updated: i64 = r.get("updated_unix_ns");
893    TaskRecord {
894        id: TaskId(r.get::<i64, _>("id") as u64),
895        key: r.get("key"),
896        pool: r.get("pool"),
897        kind: r.get("kind"),
898        priority: Priority::from_index(r.get::<i64, _>("priority").max(0) as usize)
899            .unwrap_or_default(),
900        tenant: r.get("tenant"),
901        status: TaskStatus::from_name(&status),
902        attempt: r.get::<i64, _>("attempt").clamp(0, u32::MAX as i64) as u32,
903        message: r.get("message"),
904        output: r.get::<Option<Vec<u8>>, _>("output").map(Bytes::from),
905        updated: UNIX_EPOCH + Duration::from_nanos(updated.max(0) as u64),
906    }
907}
908
909#[async_trait]
910impl StoreQueries<Bytes> for SqliteStore {
911    async fn get_task(&self, target: &TaskRef) -> Result<Option<TaskRecord>, StoreError> {
912        SqliteStore::get_task(self, target).await
913    }
914    async fn prune(&self, older_than: Duration) -> Result<u64, StoreError> {
915        SqliteStore::prune(self, older_than).await
916    }
917    async fn list_finished(
918        &self,
919        pools: &[&str],
920        statuses: &[TaskStatus],
921        after: Option<TaskId>,
922        limit: usize,
923    ) -> Result<Vec<TaskRecord>, StoreError> {
924        SqliteStore::list_finished(self, pools, statuses, after, limit).await
925    }
926    fn is_leader(&self) -> bool {
927        SqliteStore::is_leader(self)
928    }
929    async fn leadership_lost(&self) {
930        SqliteStore::leadership_lost(self).await
931    }
932}
933
934#[async_trait]
935impl Store<Bytes, Bytes> for SqliteStore {
936    async fn acquire_leadership(&self) -> Result<u16, StoreError> {
937        let inner = &self.inner;
938        let old = inner.leader.lock().unwrap().take();
939        if let Some(l) = old {
940            l.keeper.abort();
941            let _ = l.keeper.await;
942        }
943        let mut delay = inner.cfg.lock_poll.max(Duration::from_millis(50));
944        let epoch = loop {
945            match self.try_lead().await {
946                Ok(Some(epoch)) => break epoch,
947                Ok(None) => tokio::time::sleep(inner.cfg.lock_poll).await,
948                Err(e @ StoreError::Unavailable(_)) => {
949                    tracing::warn!(error = %e, "cannot reach the database to acquire leadership; retrying");
950                    tokio::time::sleep(delay).await;
951                    delay = (delay * 2).min(MAX_RETRY_DELAY);
952                }
953                Err(e) => return Err(e),
954            }
955        };
956        let expires = now_unix().saturating_add(dur_ns(inner.cfg.lease_ttl));
957        let keeper = tokio::spawn(Self::keeper(Arc::downgrade(inner), epoch, expires));
958        *inner.leader.lock().unwrap() = Some(Leader { epoch, keeper });
959        inner.lost.send_replace(false);
960        tracing::info!(epoch, "acquired cronoq leadership");
961        Ok(epoch)
962    }
963
964    async fn release_leadership(&self) {
965        SqliteStore::release_leadership(self).await
966    }
967
968    async fn commit(
969        &self,
970        epoch: u16,
971        base: TimeBase,
972        batch: &[Mutation<Bytes>],
973        outputs: &[(TaskId, &Bytes)],
974    ) -> Result<(), StoreError> {
975        if *self.inner.lost.borrow() {
976            return Err(StoreError::Fenced);
977        }
978        match tokio::time::timeout(
979            self.inner.cfg.commit_timeout,
980            self.commit_inner(epoch, base, batch, outputs),
981        )
982        .await
983        {
984            Ok(r) => r,
985            // Commits are idempotent, so retrying one that did land is harmless.
986            Err(_) => Err(StoreError::Unavailable(format!(
987                "commit timed out after {:?}",
988                self.inner.cfg.commit_timeout
989            ))),
990        }
991    }
992
993    async fn recover(&self, base: TimeBase) -> Result<Recovered<Bytes>, StoreError> {
994        let pool = &self.inner.pool;
995        let mut rec = Recovered::default();
996        let max_id: i64 = sqlx::query_scalar(
997            "SELECT MAX((SELECT max_id FROM cronoq_leader WHERE id = 1), \
998             (SELECT COALESCE(MAX(id), 0) FROM cronoq_tasks))",
999        )
1000        .fetch_one(pool)
1001        .await
1002        .map_err(db)?;
1003        rec.next_id = if max_id > 0 { max_id as u64 + 1 } else { 0 };
1004
1005        let rows = sqlx::query(
1006            "SELECT id, seq, arrival_unix_ns, pool, kind, priority, key, payload, deadline_unix_ns, \
1007             fixed_at_unix_ns, fixed_tolerance_ns, fixed_on_miss, duration_hint_ns, timeout_ns, \
1008             retry, deps, resources, tags, labels, tenant, attempt FROM cronoq_tasks WHERE terminal = 0 \
1009             ORDER BY id",
1010        )
1011        .fetch_all(pool)
1012        .await
1013        .map_err(db)?;
1014        let mut corrupt: Vec<(i64, Option<String>, String)> = Vec::new();
1015        for r in &rows {
1016            match decode_task(base, r) {
1017                Ok(t) => rec.tasks.push(t),
1018                Err(e) => {
1019                    let id: i64 = r.get("id");
1020                    tracing::error!(task = id, error = %e, "quarantining undecodable task row");
1021                    corrupt.push((id, r.get("key"), format!("corrupt task row: {e}")));
1022                }
1023            }
1024        }
1025        drop(rows);
1026        let now = now_unix();
1027        for (id, _, message) in &corrupt {
1028            sqlx::query(
1029                "UPDATE cronoq_tasks SET status = ?2, terminal = 1, updated_unix_ns = ?3, \
1030                 message = ?4 WHERE id = ?1",
1031            )
1032            .bind(id)
1033            .bind(TaskStatus::Failed.name())
1034            .bind(now)
1035            .bind(cleaned(message))
1036            .execute(pool)
1037            .await
1038            .map_err(db)?;
1039        }
1040
1041        // Finished tasks: those within retention, plus any that live tasks depend on.
1042        let cutoff = now.saturating_sub(dur_ns(self.inner.cfg.retention));
1043        let mut dep_ids = HashSet::new();
1044        let mut dep_keys = HashSet::new();
1045        for t in &rec.tasks {
1046            for d in &t.spec.deps {
1047                match &d.target {
1048                    TaskRef::Id(id) => dep_ids.insert(id.0 as i64),
1049                    TaskRef::Key(k) => dep_keys.insert(cleaned(k)),
1050                };
1051            }
1052        }
1053        let ids =
1054            serde_json::to_string(&dep_ids.into_iter().collect::<Vec<_>>()).expect("serializable");
1055        let keys =
1056            serde_json::to_string(&dep_keys.into_iter().collect::<Vec<_>>()).expect("serializable");
1057        let rows = sqlx::query(
1058            "SELECT id, key, status, updated_unix_ns FROM cronoq_tasks \
1059             WHERE terminal = 1 AND (updated_unix_ns >= ?1 \
1060             OR id IN (SELECT value FROM json_each(?2)) \
1061             OR key IN (SELECT value FROM json_each(?3))) \
1062             ORDER BY updated_unix_ns, id",
1063        )
1064        .bind(cutoff)
1065        .bind(ids)
1066        .bind(keys)
1067        .fetch_all(pool)
1068        .await
1069        .map_err(db)?;
1070        rec.terminal = rows.iter().filter_map(|r| terminal_from(base, r)).collect();
1071        // Quarantined rows were just updated, so they come last in this order.
1072        rec.terminal
1073            .extend(corrupt.into_iter().map(|(id, key, _)| RecoveredTerminal {
1074                id: TaskId(id as u64),
1075                key: key.map(Into::into),
1076                status: TaskStatus::Failed,
1077                at: from_unix(base, now),
1078            }));
1079
1080        let rows = sqlx::query(
1081            "SELECT name, spec, template_payload, next_unix_ns, last_task_id FROM cronoq_recurring ORDER BY name",
1082        )
1083        .fetch_all(pool)
1084        .await
1085        .map_err(db)?;
1086        for r in rows {
1087            let name: String = r.get("name");
1088            let spec: String = r.get("spec");
1089            match recurring_from(base, name.clone(), &spec, r.get("template_payload")) {
1090                Ok(spec) => {
1091                    let next = r
1092                        .get::<Option<i64>, _>("next_unix_ns")
1093                        .map(|u| from_unix(base, u));
1094                    let last = r
1095                        .get::<Option<i64>, _>("last_task_id")
1096                        .map(|id| TaskId(id as u64));
1097                    rec.recurring.push(RecoveredRecurring { spec, next, last });
1098                }
1099                // Left in place (so it can be repaired) but not scheduled.
1100                Err(e) => tracing::error!(
1101                    schedule = %name,
1102                    error = %e,
1103                    "skipping undecodable recurring schedule"
1104                ),
1105            }
1106        }
1107        Ok(rec)
1108    }
1109
1110    async fn load_output(&self, id: TaskId) -> Result<Option<Bytes>, StoreError> {
1111        let out: Option<Option<Vec<u8>>> =
1112            sqlx::query_scalar("SELECT output FROM cronoq_tasks WHERE id = ?1 AND status = ?2")
1113                .bind(id.0 as i64)
1114                .bind(TaskStatus::Succeeded.name())
1115                .fetch_optional(&self.inner.pool)
1116                .await
1117                .map_err(db)?;
1118        Ok(out.flatten().map(Bytes::from))
1119    }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125
1126    #[test]
1127    fn busy_and_locked_are_transient() {
1128        assert!(matches!(
1129            db(sqlx::Error::PoolTimedOut),
1130            StoreError::Unavailable(_)
1131        ));
1132        assert!(matches!(db(sqlx::Error::RowNotFound), StoreError::Other(_)));
1133    }
1134
1135    #[test]
1136    fn holders_differ() {
1137        assert_ne!(new_holder(), new_holder());
1138    }
1139}