1#![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
69const MIGRATIONS: &[&str] = &[include_str!("schema.sql")];
71const QUEUED: &str = "queued";
73const MAX_EPOCH: i64 = 32767;
75const PRUNE_BATCH: i64 = 1000;
77const MAX_RETRY_DELAY: Duration = Duration::from_secs(5);
79
80pub type TaskRecord = cronoq::TaskRecord<Bytes>;
82
83#[derive(Clone, Debug)]
85pub struct SqliteStoreConfig {
86 pub retention: Duration,
89 pub lease_ttl: Duration,
91 pub lease_renew: Duration,
93 pub lock_poll: Duration,
95 pub busy_timeout: Duration,
97 pub commit_timeout: Duration,
99 pub full_sync: bool,
102 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 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#[derive(Clone)]
145pub struct SqliteStore {
146 inner: Arc<Inner>,
147}
148
149fn 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 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
177fn 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
191fn 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
203fn 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 pub async fn open(path: impl AsRef<Path>) -> Result<SqliteStore, StoreError> {
230 Self::open_with(path, SqliteStoreConfig::default()).await
231 }
232
233 pub async fn open_with(
235 path: impl AsRef<Path>,
236 cfg: SqliteStoreConfig,
237 ) -> Result<SqliteStore, StoreError> {
238 let path = path.as_ref();
239 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 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 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 pub fn pool(&self) -> &SqlitePool {
336 &self.inner.pool
337 }
338
339 pub fn config(&self) -> &SqliteStoreConfig {
341 &self.inner.cfg
342 }
343
344 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 pub fn is_leader(&self) -> bool {
354 self.epoch().is_some()
355 }
356
357 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 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 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 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 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 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); }
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 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 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 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 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 for r in &inserts {
641 r.execute(&mut tx).await?;
642 }
643 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 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
699struct StatusRow {
701 status: TaskStatus,
702 attempt: u32,
703 at: i64,
704 message: Option<String>,
705}
706
707impl StatusRow {
708 fn fold(&mut self, later: StatusRow) {
710 let message = later.message.or(self.message.take());
711 *self = StatusRow { message, ..later };
712 }
713}
714
715struct 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 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
823fn 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
878macro_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 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 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 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 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}