Skip to main content

cronoq_store_postgres/
lib.rs

1//! # cronoq-store-postgres
2//!
3//! A PostgreSQL [`Store`] for cronoq with active-passive high availability.
4//!
5//! - **Write-ahead state**: every engine state transition is committed in batches, one
6//!   transaction per batch, before submitters are acknowledged.
7//! - **Isolation**: each scheduler cluster lives in its own PostgreSQL schema
8//!   ([`PgStoreConfig::schema`]); clusters in different schemas share nothing, not even locks.
9//! - **Leader election**: a dedicated session holds an advisory lock derived from the schema;
10//!   acquiring it bumps a fencing epoch in `cronoq_leader`.
11//! - **Fencing**: each commit re-reads the epoch in the same transaction and fails with
12//!   [`StoreError::Fenced`] if another instance took over.
13//! - **Recovery**: a new leader loads live tasks, recently finished ones (for key
14//!   deduplication), every finished task a live task still depends on, and recurring
15//!   schedules. Rows that cannot be decoded are quarantined (marked failed) instead of
16//!   blocking recovery.
17//! - **Errors**: connection loss, timeouts, serialization failures and server shutdowns are
18//!   reported as [`StoreError::Unavailable`], which the scheduler retries.
19//!
20//! Times are stored as wall-clock Unix nanoseconds. PostgreSQL cannot store NUL characters in
21//! text, so NUL in strings (messages, keys, tags, ...) is stored as U+FFFD.
22//!
23//! ```no_run
24//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
25//! use bytes::Bytes;
26//! use cronoq::{Pool, Scheduler};
27//! use cronoq_store_postgres::PgStore;
28//!
29//! let store = PgStore::connect("postgres://localhost/cronoq").await?;
30//! let sched = Scheduler::<Bytes, Bytes>::builder()
31//!     .pool(Pool::new("default").executor_fn(|_ctx, p: std::sync::Arc<Bytes>| async move {
32//!         Ok::<_, cronoq::TaskError>((*p).clone())
33//!     }))
34//!     .store(store.clone())
35//!     .build() // blocks until this instance is the leader
36//!     .await?;
37//! tokio::select! {
38//!     _ = store.leadership_lost() => { /* step down: stop serving, exit or rejoin */ }
39//!     _ = tokio::signal::ctrl_c() => {}
40//! }
41//! # Ok(()) }
42//! ```
43
44#![warn(missing_docs)]
45
46use std::collections::hash_map::Entry;
47use std::collections::{HashMap, HashSet};
48use std::sync::{Arc, Mutex};
49use std::time::{Duration, SystemTime, UNIX_EPOCH};
50
51use async_trait::async_trait;
52use bytes::Bytes;
53use cronoq::{Store, StoreError, StoreQueries, TimeBase};
54use cronoq_core::{
55    Mutation, Priority, Recovered, RecoveredRecurring, RecoveredTask, RecoveredTerminal, TaskId,
56    TaskRef, TaskSpec, TaskStatus,
57};
58use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
59use sqlx::{AssertSqlSafe, Connection, PgConnection, PgPool, Row};
60use tokio::sync::watch;
61use tokio::task::JoinHandle;
62
63use cronoq::codec::*;
64
65/// Ordered schema migrations; `cronoq_schema_version` records how many were applied. Each one
66/// is idempotent so databases created before versioning upgrade cleanly.
67const MIGRATIONS: &[&str] = &[
68    include_str!("schema.sql"),
69    // v2: high-water mark of issued task ids, so pruning can never cause id reuse.
70    "ALTER TABLE cronoq_leader ADD COLUMN IF NOT EXISTS max_id BIGINT NOT NULL DEFAULT 0",
71    // v3: the occurrence a queued recurring schedule chains to.
72    "ALTER TABLE cronoq_recurring ADD COLUMN IF NOT EXISTS last_task_id BIGINT",
73    // v4: worker labels a task requires.
74    "ALTER TABLE cronoq_tasks ADD COLUMN IF NOT EXISTS labels JSONB NOT NULL DEFAULT '[]'::jsonb",
75    // v5: tenant, for fair sharing.
76    "ALTER TABLE cronoq_tasks ADD COLUMN IF NOT EXISTS tenant TEXT",
77];
78/// Status stored for tasks that are live but have not started an attempt yet.
79const QUEUED: &str = "queued";
80/// Fencing epochs stay within 1..=32767 so epoch-prefixed task ids fit in a signed BIGINT.
81const MAX_EPOCH: i32 = 32767;
82/// First key of the two-key advisory locks taken by this crate ("croq" / "crom").
83const LOCK_NS_LEADER: i32 = 0x6372_6f71;
84const LOCK_NS_MIGRATE: i32 = 0x6372_6f6d;
85/// Rows deleted per statement by [`PgStore::prune`].
86const PRUNE_BATCH: i64 = 1000;
87/// Longest wait between attempts to reach the database while acquiring leadership.
88const MAX_RETRY_DELAY: Duration = Duration::from_secs(5);
89
90/// Store configuration.
91#[derive(Clone, Debug)]
92pub struct PgStoreConfig {
93    /// PostgreSQL schema holding this cluster's tables (created if missing). Instances
94    /// configured with the same schema form one active-passive cluster; different schemas
95    /// are fully independent. Letters, digits and `_` only.
96    pub schema: String,
97    /// How long finished tasks are loaded on recovery, for key deduplication. Finished tasks
98    /// that live tasks depend on are always loaded.
99    pub retention: Duration,
100    /// Interval between attempts to take the leader lock.
101    pub lock_poll: Duration,
102    /// Interval of liveness checks on the leader lock session.
103    pub keepalive: Duration,
104    /// How long the leader lock session may stay unresponsive before this instance steps
105    /// down. Also used for the server-side TCP keepalive of that session.
106    pub session_timeout: Duration,
107    /// Upper bound on one commit; a slower commit fails as [`StoreError::Unavailable`] (and
108    /// is retried by the scheduler).
109    pub commit_timeout: Duration,
110    /// Size of the connection pool used for commits and queries.
111    pub max_connections: u32,
112}
113
114impl Default for PgStoreConfig {
115    fn default() -> Self {
116        PgStoreConfig {
117            schema: "public".into(),
118            retention: Duration::from_secs(3600),
119            lock_poll: Duration::from_millis(500),
120            keepalive: Duration::from_secs(1),
121            session_timeout: Duration::from_secs(10),
122            commit_timeout: Duration::from_secs(30),
123            max_connections: 8,
124        }
125    }
126}
127
128/// Persisted view of a task, for lookups by id or key.
129pub type TaskRecord = cronoq::TaskRecord<Bytes>;
130
131struct Leader {
132    epoch: u16,
133    keeper: JoinHandle<()>,
134}
135
136struct Inner {
137    pool: PgPool,
138    opts: PgConnectOptions,
139    cfg: PgStoreConfig,
140    /// Second key of the leader advisory lock: the schema's OID.
141    lock_id: i32,
142    leader: Mutex<Option<Leader>>,
143    /// `true` once leadership was lost (or before it was ever acquired: `false`).
144    lost: watch::Sender<bool>,
145}
146
147impl Drop for Inner {
148    fn drop(&mut self) {
149        if let Some(l) = self.leader.get_mut().unwrap().take() {
150            l.keeper.abort();
151        }
152    }
153}
154
155/// PostgreSQL-backed [`Store`]. Cheap to clone.
156#[derive(Clone)]
157pub struct PgStore {
158    inner: Arc<Inner>,
159}
160
161/// Map a database error, classifying failures that are worth retrying as `Unavailable`.
162fn db(e: sqlx::Error) -> StoreError {
163    use sqlx::Error as E;
164    let transient = match &e {
165        E::Io(_) | E::Tls(_) | E::PoolTimedOut | E::WorkerCrashed => true,
166        E::Database(d) => d.code().is_some_and(|c| {
167            // 08: connection exception, 53: insufficient resources, 57P: shutdown/crash,
168            // 40001/40P01: serialization failure/deadlock, 55P03: lock not available.
169            c.starts_with("08")
170                || c.starts_with("53")
171                || c.starts_with("57P")
172                || matches!(&*c, "40001" | "40P01" | "55P03")
173        }),
174        _ => false,
175    };
176    if transient {
177        StoreError::Unavailable(e.to_string())
178    } else {
179        StoreError::Other(e.to_string())
180    }
181}
182
183fn now_unix() -> i64 {
184    SystemTime::now()
185        .duration_since(UNIX_EPOCH)
186        .unwrap_or_default()
187        .as_nanos()
188        .min(i64::MAX as u128) as i64
189}
190
191/// A migration split into statements. Comment lines are dropped first; migrations have no
192/// function bodies or string literals containing semicolons.
193fn statements(sql: &str) -> Vec<String> {
194    let code: String = sql
195        .lines()
196        .filter(|l| !l.trim_start().starts_with("--"))
197        .collect::<Vec<_>>()
198        .join("\n");
199    code.split(';')
200        .map(|s| s.trim().to_string())
201        .filter(|s| !s.is_empty())
202        .collect()
203}
204
205fn valid_schema(s: &str) -> bool {
206    !s.is_empty()
207        && s.len() <= 63
208        && !s.starts_with(|c: char| c.is_ascii_digit())
209        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
210}
211
212impl PgStore {
213    /// Connect with default settings and apply the schema.
214    pub async fn connect(url: &str) -> Result<PgStore, StoreError> {
215        Self::connect_with(url, PgStoreConfig::default()).await
216    }
217
218    /// Connect with `cfg` and apply the schema.
219    pub async fn connect_with(url: &str, cfg: PgStoreConfig) -> Result<PgStore, StoreError> {
220        if !valid_schema(&cfg.schema) {
221            return Err(StoreError::Other(format!(
222                "invalid schema name {:?}: use letters, digits and '_'",
223                cfg.schema
224            )));
225        }
226        let opts: PgConnectOptions = url.parse().map_err(db)?;
227        let opts = opts.options([("search_path", format!("\"{}\"", cfg.schema))]);
228        let pool = PgPoolOptions::new()
229            .max_connections(cfg.max_connections.max(1))
230            .connect_with(opts.clone())
231            .await
232            .map_err(db)?;
233        Self::migrate_pool(&pool, &cfg.schema).await?;
234        let oid: i64 =
235            sqlx::query_scalar("SELECT oid::bigint FROM pg_namespace WHERE nspname = $1")
236                .bind(&cfg.schema)
237                .fetch_one(&pool)
238                .await
239                .map_err(db)?;
240        Ok(PgStore {
241            inner: Arc::new(Inner {
242                pool,
243                opts,
244                cfg,
245                lock_id: oid as u32 as i32,
246                leader: Mutex::new(None),
247                lost: watch::channel(false).0,
248            }),
249        })
250    }
251
252    /// Apply pending schema migrations (idempotent; serialized across instances that use
253    /// the same schema). [`PgStore::connect`] already does this.
254    pub async fn migrate(&self) -> Result<(), StoreError> {
255        Self::migrate_pool(&self.inner.pool, &self.inner.cfg.schema).await
256    }
257
258    async fn migrate_pool(pool: &PgPool, schema: &str) -> Result<(), StoreError> {
259        let mut tx = pool.begin().await.map_err(db)?;
260        sqlx::query("SELECT pg_advisory_xact_lock($1, hashtext($2))")
261            .bind(LOCK_NS_MIGRATE)
262            .bind(schema)
263            .execute(&mut *tx)
264            .await
265            .map_err(db)?;
266        let exists: bool =
267            sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = $1)")
268                .bind(schema)
269                .fetch_one(&mut *tx)
270                .await
271                .map_err(db)?;
272        if !exists {
273            // `schema` passed `valid_schema` when the store was configured: ASCII letters,
274            // digits and underscores only.
275            sqlx::query(AssertSqlSafe(format!(
276                "CREATE SCHEMA IF NOT EXISTS \"{schema}\""
277            )))
278            .execute(&mut *tx)
279            .await
280            .map_err(db)?;
281        }
282        // Tables that already exist are used as they are, and their owner controls their
283        // triggers, which run as this role. Refuse ones another role could have planted: only
284        // this role or a superuser may own them (`pg_has_role` would accept anything for a
285        // superuser connection, the case where a planted trigger gains the most).
286        let foreign: Vec<String> = sqlx::query_scalar(
287            "SELECT c.relname::text FROM pg_class c \
288             JOIN pg_namespace n ON n.oid = c.relnamespace \
289             JOIN pg_roles o ON o.oid = c.relowner \
290             WHERE n.nspname = $1 AND c.relname LIKE 'cronoq\\_%' \
291             AND o.rolname <> current_user AND NOT o.rolsuper \
292             ORDER BY 1",
293        )
294        .bind(schema)
295        .fetch_all(&mut *tx)
296        .await
297        .map_err(db)?;
298        if !foreign.is_empty() {
299            return Err(StoreError::Other(format!(
300                "schema \"{schema}\" holds cronoq tables owned by another role ({}); refusing to \
301                 use them (they must belong to the connecting role or a superuser)",
302                foreign.join(", ")
303            )));
304        }
305        sqlx::query(
306            "CREATE TABLE IF NOT EXISTS cronoq_schema_version \
307             (id SMALLINT PRIMARY KEY CHECK (id = 1), version INT NOT NULL)",
308        )
309        .execute(&mut *tx)
310        .await
311        .map_err(db)?;
312        sqlx::query(
313            "INSERT INTO cronoq_schema_version (id, version) VALUES (1, 0) \
314             ON CONFLICT (id) DO NOTHING",
315        )
316        .execute(&mut *tx)
317        .await
318        .map_err(db)?;
319        let version: i32 =
320            sqlx::query_scalar("SELECT version FROM cronoq_schema_version WHERE id = 1")
321                .fetch_one(&mut *tx)
322                .await
323                .map_err(db)?;
324        let version = usize::try_from(version).unwrap_or(0);
325        if version > MIGRATIONS.len() {
326            return Err(StoreError::Other(format!(
327                "database schema version {version} is newer than this build supports ({})",
328                MIGRATIONS.len()
329            )));
330        }
331        // One statement at a time: `raw_sql` on a connection yields a future that callers cannot
332        // prove `Send` (sqlx higher-ranked lifetime issue), which breaks `tokio::spawn`.
333        for m in &MIGRATIONS[version..] {
334            for stmt in statements(m) {
335                // Migrations are compile-time constants of this crate.
336                sqlx::query(AssertSqlSafe(stmt))
337                    .execute(&mut *tx)
338                    .await
339                    .map_err(db)?;
340            }
341        }
342        if version < MIGRATIONS.len() {
343            sqlx::query("UPDATE cronoq_schema_version SET version = $1 WHERE id = 1")
344                .bind(MIGRATIONS.len() as i32)
345                .execute(&mut *tx)
346                .await
347                .map_err(db)?;
348        }
349        tx.commit().await.map_err(db)
350    }
351
352    /// The connection pool (its sessions use the configured schema).
353    pub fn pool(&self) -> &PgPool {
354        &self.inner.pool
355    }
356
357    /// The configuration this store was opened with.
358    pub fn config(&self) -> &PgStoreConfig {
359        &self.inner.cfg
360    }
361
362    /// The two keys of the advisory lock held by the leader (`pg_locks.classid`, `objid`),
363    /// for monitoring.
364    pub fn leader_lock(&self) -> (i32, i32) {
365        (LOCK_NS_LEADER, self.inner.lock_id)
366    }
367
368    /// Current fencing epoch, if this instance holds leadership.
369    pub fn epoch(&self) -> Option<u16> {
370        if *self.inner.lost.borrow() {
371            return None;
372        }
373        self.inner.leader.lock().unwrap().as_ref().map(|l| l.epoch)
374    }
375
376    /// Whether this instance currently holds leadership.
377    pub fn is_leader(&self) -> bool {
378        self.epoch().is_some()
379    }
380
381    /// Resolves once leadership held by this instance is lost (lock session gone).
382    pub async fn leadership_lost(&self) {
383        let mut rx = self.inner.lost.subscribe();
384        let _ = rx.wait_for(|lost| *lost).await;
385    }
386
387    /// Give up leadership (releases the advisory lock).
388    pub async fn release_leadership(&self) {
389        let l = self.inner.leader.lock().unwrap().take();
390        if let Some(l) = l {
391            l.keeper.abort();
392            let _ = l.keeper.await;
393        }
394        self.inner.lost.send_replace(true);
395    }
396
397    /// Look up a task by id or key (the most recent task with that key).
398    pub async fn get_task(&self, target: &TaskRef) -> Result<Option<TaskRecord>, StoreError> {
399        let row = match target {
400            TaskRef::Id(id) => sqlx::query(select_record!("WHERE id = $1"))
401                .bind(id.0 as i64)
402                .fetch_optional(&self.inner.pool)
403                .await
404                .map_err(db)?,
405            TaskRef::Key(k) => {
406                sqlx::query(select_record!("WHERE key = $1 ORDER BY id DESC LIMIT 1"))
407                    .bind(clean(k))
408                    .fetch_optional(&self.inner.pool)
409                    .await
410                    .map_err(db)?
411            }
412        };
413        Ok(row.as_ref().map(record))
414    }
415
416    /// Finished tasks in id order: those in one of `pools` (any if empty) with one of
417    /// `statuses` (any finished status if empty) and ids above `after`, at most `limit`.
418    pub async fn list_finished(
419        &self,
420        pools: &[&str],
421        statuses: &[TaskStatus],
422        after: Option<TaskId>,
423        limit: usize,
424    ) -> Result<Vec<TaskRecord>, StoreError> {
425        let names: Vec<&str> = statuses.iter().map(|s| s.name()).collect();
426        let rows = sqlx::query(select_record!(
427            "WHERE terminal AND (cardinality($1::text[]) = 0 OR pool = ANY($1)) AND id > $2 \
428             AND (cardinality($3::text[]) = 0 OR status = ANY($3)) ORDER BY id LIMIT $4"
429        ))
430        .bind(pools)
431        .bind(after.map_or(-1, |a| a.0.min(i64::MAX as u64) as i64))
432        .bind(names)
433        .bind(limit.min(i64::MAX as usize) as i64)
434        .fetch_all(&self.inner.pool)
435        .await
436        .map_err(db)?;
437        Ok(rows.iter().map(record).collect())
438    }
439
440    /// Delete finished tasks last updated more than `older_than` ago, except those that live
441    /// tasks still depend on. Deletes in bounded batches; returns the number of rows removed.
442    pub async fn prune(&self, older_than: Duration) -> Result<u64, StoreError> {
443        let cutoff = now_unix().saturating_sub(dur_ns(older_than));
444        let mut total = 0;
445        loop {
446            let r = sqlx::query(
447                "WITH refs AS ( \
448                   SELECT (d->>'id')::bigint AS id, d->>'key' AS key \
449                   FROM cronoq_tasks l, jsonb_array_elements(l.deps) d \
450                   WHERE NOT l.terminal AND l.deps <> '[]'::jsonb), \
451                 victims AS ( \
452                   SELECT t.id FROM cronoq_tasks t \
453                   WHERE t.terminal AND t.updated_unix_ns < $1 \
454                   AND t.id NOT IN (SELECT id FROM refs WHERE id IS NOT NULL) \
455                   AND (t.key IS NULL OR t.key NOT IN (SELECT key FROM refs WHERE key IS NOT NULL)) \
456                   ORDER BY t.updated_unix_ns LIMIT $2) \
457                 DELETE FROM cronoq_tasks WHERE id IN (SELECT id FROM victims)",
458            )
459            .bind(cutoff)
460            .bind(PRUNE_BATCH)
461            .execute(&self.inner.pool)
462            .await
463            .map_err(db)?;
464            total += r.rows_affected();
465            if r.rows_affected() < PRUNE_BATCH as u64 {
466                return Ok(total);
467            }
468        }
469    }
470
471    async fn keeper(
472        store: std::sync::Weak<Inner>,
473        mut conn: PgConnection,
474        every: Duration,
475        limit: Duration,
476    ) {
477        loop {
478            tokio::time::sleep(every).await;
479            let alive = matches!(
480                tokio::time::timeout(limit, sqlx::query("SELECT 1").execute(&mut conn)).await,
481                Ok(Ok(_))
482            );
483            let Some(inner) = store.upgrade() else { return };
484            if !alive {
485                tracing::error!("cronoq leader lock session lost; stepping down");
486                inner.lost.send_replace(true);
487                // Dropping the connection closes the session, releasing the lock if the
488                // server still holds it.
489                return;
490            }
491        }
492    }
493
494    /// Open the lock session and wait for the lock; returns the session and the new epoch.
495    async fn try_lead(&self) -> Result<(PgConnection, i32), StoreError> {
496        let inner = &self.inner;
497        let idle = inner.cfg.session_timeout.as_secs().max(1).to_string();
498        let opts = inner.opts.clone().options([
499            ("tcp_keepalives_idle", idle.as_str()),
500            ("tcp_keepalives_interval", "1"),
501            ("tcp_keepalives_count", "3"),
502        ]);
503        let mut conn = PgConnection::connect_with(&opts).await.map_err(db)?;
504        loop {
505            let got: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1, $2)")
506                .bind(LOCK_NS_LEADER)
507                .bind(inner.lock_id)
508                .fetch_one(&mut conn)
509                .await
510                .map_err(db)?;
511            if got {
512                break;
513            }
514            tokio::time::sleep(inner.cfg.lock_poll).await;
515        }
516        let epoch: i32 = sqlx::query_scalar(
517            "UPDATE cronoq_leader SET epoch = CASE WHEN epoch >= $1 THEN 1 ELSE epoch + 1 END \
518             WHERE id = 1 RETURNING epoch",
519        )
520        .bind(MAX_EPOCH)
521        .fetch_one(&mut conn)
522        .await
523        .map_err(db)?;
524        Ok((conn, epoch))
525    }
526
527    async fn release(&self) {
528        PgStore::release_leadership(self).await
529    }
530
531    async fn commit_inner(
532        &self,
533        epoch: u16,
534        base: TimeBase,
535        batch: &[Mutation<Bytes>],
536        outputs: &[(TaskId, &Bytes)],
537    ) -> Result<(), StoreError> {
538        // Fold the batch: new tasks are inserted with their latest status; tasks inserted
539        // earlier get one update each. Later mutations win; a message carries forward until a
540        // later transition replaces it, so the result does not depend on batching.
541        let mut ins = Inserts::default();
542        let mut inserted: HashMap<TaskId, usize> = HashMap::new();
543        let mut updates: Vec<(TaskId, StatusRow)> = Vec::new();
544        let mut updated: HashMap<TaskId, usize> = HashMap::new();
545        let now = now_unix();
546        for m in batch {
547            match m {
548                Mutation::Submitted {
549                    id,
550                    seq,
551                    arrival,
552                    spec,
553                } => {
554                    if let Entry::Vacant(e) = inserted.entry(*id) {
555                        e.insert(ins.id.len());
556                        ins.push(base, now, *id, *seq, *arrival, spec);
557                    }
558                }
559                Mutation::Status {
560                    id,
561                    status: st,
562                    attempt,
563                    at,
564                    message,
565                } => {
566                    let row = StatusRow {
567                        status: *st,
568                        attempt: *attempt,
569                        at: to_unix(base, *at),
570                        message: message.as_deref().map(cleaned),
571                    };
572                    if let Some(&i) = inserted.get(id) {
573                        ins.set_status(i, row);
574                    } else {
575                        match updated.entry(*id) {
576                            Entry::Occupied(e) => updates[*e.get()].1.fold(row),
577                            Entry::Vacant(e) => {
578                                e.insert(updates.len());
579                                updates.push((*id, row));
580                            }
581                        }
582                    }
583                }
584                _ => {}
585            }
586        }
587
588        let mut tx = self.inner.pool.begin().await.map_err(db)?;
589        let current: i32 = match ins.id.iter().max() {
590            // Record the id high-water mark while checking the epoch.
591            Some(max) => sqlx::query_scalar(
592                "UPDATE cronoq_leader SET max_id = GREATEST(max_id, $1) WHERE id = 1 \
593                 RETURNING epoch",
594            )
595            .bind(*max)
596            .fetch_one(&mut *tx)
597            .await
598            .map_err(db)?,
599            None => sqlx::query_scalar("SELECT epoch FROM cronoq_leader WHERE id = 1 FOR SHARE")
600                .fetch_one(&mut *tx)
601                .await
602                .map_err(db)?,
603        };
604        if current != epoch as i32 {
605            self.inner.lost.send_replace(true);
606            return Err(StoreError::Fenced);
607        }
608
609        // 1. Transitions of existing tasks first, so keys they release can be reused by tasks
610        //    inserted in this batch.
611        if !updates.is_empty() {
612            let n = updates.len();
613            let mut ids = Vec::with_capacity(n);
614            let mut sts = Vec::with_capacity(n);
615            let mut terms = Vec::with_capacity(n);
616            let mut atts = Vec::with_capacity(n);
617            let mut ats = Vec::with_capacity(n);
618            let mut msgs: Vec<Option<String>> = Vec::with_capacity(n);
619            for (id, row) in updates {
620                ids.push(id.0 as i64);
621                sts.push(row.status.name());
622                terms.push(row.status.is_terminal());
623                atts.push(row.attempt as i32);
624                ats.push(row.at);
625                msgs.push(row.message);
626            }
627            let r = sqlx::query(
628                "UPDATE cronoq_tasks t SET status = u.status, terminal = u.terminal, attempt = u.attempt, \
629                 updated_unix_ns = u.at, message = COALESCE(u.message, t.message) \
630                 FROM UNNEST($1::bigint[], $2::text[], $3::bool[], $4::int[], $5::bigint[], $6::text[]) \
631                 AS u(id, status, terminal, attempt, at, message) WHERE t.id = u.id",
632            )
633            .bind(ids)
634            .bind(sts)
635            .bind(terms)
636            .bind(atts)
637            .bind(ats)
638            .bind(msgs)
639            .execute(&mut *tx)
640            .await
641            .map_err(db)?;
642            if r.rows_affected() != n as u64 {
643                tracing::warn!(
644                    expected = n,
645                    updated = r.rows_affected(),
646                    "status updates for tasks missing from the store (pruned?)"
647                );
648            }
649        }
650        // 2. New tasks, in one statement.
651        if !ins.id.is_empty() {
652            ins.execute(&mut tx).await?;
653        }
654        // 3. Outputs of tasks that succeeded in this batch.
655        if !outputs.is_empty() {
656            let ids: Vec<i64> = outputs.iter().map(|(id, _)| id.0 as i64).collect();
657            let outs: Vec<Vec<u8>> = outputs.iter().map(|(_, o)| o.to_vec()).collect();
658            let r = sqlx::query(
659                "UPDATE cronoq_tasks t SET output = u.output \
660                 FROM UNNEST($1::bigint[], $2::bytea[]) AS u(id, output) WHERE t.id = u.id",
661            )
662            .bind(ids)
663            .bind(outs)
664            .execute(&mut *tx)
665            .await
666            .map_err(db)?;
667            if r.rows_affected() != outputs.len() as u64 {
668                tracing::warn!(
669                    expected = outputs.len(),
670                    updated = r.rows_affected(),
671                    "outputs for tasks missing from the store"
672                );
673            }
674        }
675        // 4. Recurring schedules: rare, applied in order.
676        for m in batch {
677            match m {
678                Mutation::RecurringUpsert { spec } => {
679                    sqlx::query(
680                        "INSERT INTO cronoq_recurring (name, spec, template_payload) VALUES ($1, $2::jsonb, $3) \
681                         ON CONFLICT (name) DO UPDATE SET spec = EXCLUDED.spec, template_payload = EXCLUDED.template_payload",
682                    )
683                    .bind(clean(&spec.name))
684                    .bind(recurring_json(base, spec))
685                    .bind(spec.template.payload.to_vec())
686                    .execute(&mut *tx)
687                    .await
688                    .map_err(db)?;
689                }
690                Mutation::RecurringRemove { name } => {
691                    sqlx::query("DELETE FROM cronoq_recurring WHERE name = $1")
692                        .bind(clean(name))
693                        .execute(&mut *tx)
694                        .await
695                        .map_err(db)?;
696                }
697                Mutation::RecurringNext { name, next, last } => {
698                    sqlx::query(
699                        "UPDATE cronoq_recurring SET next_unix_ns = $2, last_task_id = $3 \
700                         WHERE name = $1",
701                    )
702                    .bind(clean(name))
703                    .bind(next.map(|t| to_unix(base, t)))
704                    .bind(last.map(|id| id.0 as i64))
705                    .execute(&mut *tx)
706                    .await
707                    .map_err(db)?;
708                }
709                _ => {}
710            }
711        }
712        tx.commit().await.map_err(db)
713    }
714}
715
716/// Decode a live task row.
717fn decode_task(base: TimeBase, r: &sqlx::postgres::PgRow) -> Result<RecoveredTask<Bytes>, String> {
718    let mut spec = TaskSpec::new(Bytes::from(r.get::<Vec<u8>, _>("payload")))
719        .kind(r.get::<String, _>("kind"))
720        .pool(r.get::<String, _>("pool"))
721        .priority(Priority::from_index(r.get::<i16, _>("priority") as usize).unwrap_or_default());
722    spec.key = r.get::<Option<String>, _>("key").map(Into::into);
723    spec.deadline = r
724        .get::<Option<i64>, _>("deadline_unix_ns")
725        .map(|u| from_unix(base, u));
726    if let Some(at) = r.get::<Option<i64>, _>("fixed_at_unix_ns") {
727        spec.fixed_start = Some(cronoq_core::FixedStart {
728            at: from_unix(base, at),
729            tolerance: ns_dur(
730                r.get::<Option<i64>, _>("fixed_tolerance_ns")
731                    .unwrap_or(1_000_000_000),
732            ),
733            on_miss: miss_from(
734                r.get::<Option<String>, _>("fixed_on_miss")
735                    .as_deref()
736                    .unwrap_or(""),
737            ),
738        });
739    }
740    spec.duration_hint = r.get::<Option<i64>, _>("duration_hint_ns").map(ns_dur);
741    spec.timeout = r.get::<Option<i64>, _>("timeout_ns").map(ns_dur);
742    spec.retry = retry_from(r.get::<Option<String>, _>("retry").as_deref())?;
743    spec.deps = deps_from(&r.get::<String, _>("deps"))?;
744    spec.resources = resources_from(&r.get::<String, _>("resources"))?
745        .into_iter()
746        .collect();
747    spec.tags = tags_from(&r.get::<String, _>("tags"))?;
748    spec.labels = tags_from(&r.get::<String, _>("labels"))?;
749    spec.tenant = r.get::<Option<String>, _>("tenant").map(Into::into);
750    Ok(RecoveredTask {
751        id: TaskId(r.get::<i64, _>("id") as u64),
752        seq: r.get::<i64, _>("seq") as u64,
753        arrival: from_unix(base, r.get("arrival_unix_ns")),
754        spec,
755        attempt: r.get::<i32, _>("attempt").max(0) as u32,
756    })
757}
758
759fn terminal_from(base: TimeBase, r: &sqlx::postgres::PgRow) -> Option<RecoveredTerminal> {
760    let status: String = r.get("status");
761    Some(RecoveredTerminal {
762        id: TaskId(r.get::<i64, _>("id") as u64),
763        key: r.get::<Option<String>, _>("key").map(Into::into),
764        status: TaskStatus::from_name(&status)?,
765        at: from_unix(base, r.get("updated_unix_ns")),
766    })
767}
768
769/// Selects the columns [`record`] reads, followed by `$rest`.
770macro_rules! select_record {
771    ($rest:literal) => {
772        concat!(
773            "SELECT id, key, pool, kind, priority, tenant, status, attempt, message, output, \
774             updated_unix_ns FROM cronoq_tasks ",
775            $rest
776        )
777    };
778}
779use select_record;
780
781fn record(r: &sqlx::postgres::PgRow) -> TaskRecord {
782    let status: String = r.get("status");
783    let updated: i64 = r.get("updated_unix_ns");
784    TaskRecord {
785        id: TaskId(r.get::<i64, _>("id") as u64),
786        key: r.get("key"),
787        pool: r.get("pool"),
788        kind: r.get("kind"),
789        priority: Priority::from_index(r.get::<i16, _>("priority") as usize).unwrap_or_default(),
790        tenant: r.get("tenant"),
791        status: TaskStatus::from_name(&status),
792        attempt: r.get::<i32, _>("attempt").max(0) as u32,
793        message: r.get("message"),
794        output: r.get::<Option<Vec<u8>>, _>("output").map(Bytes::from),
795        updated: UNIX_EPOCH + Duration::from_nanos(updated.max(0) as u64),
796    }
797}
798
799#[async_trait]
800impl StoreQueries<Bytes> for PgStore {
801    async fn get_task(&self, target: &TaskRef) -> Result<Option<TaskRecord>, StoreError> {
802        PgStore::get_task(self, target).await
803    }
804    async fn prune(&self, older_than: Duration) -> Result<u64, StoreError> {
805        PgStore::prune(self, older_than).await
806    }
807    async fn list_finished(
808        &self,
809        pools: &[&str],
810        statuses: &[TaskStatus],
811        after: Option<TaskId>,
812        limit: usize,
813    ) -> Result<Vec<TaskRecord>, StoreError> {
814        PgStore::list_finished(self, pools, statuses, after, limit).await
815    }
816    fn is_leader(&self) -> bool {
817        PgStore::is_leader(self)
818    }
819    async fn leadership_lost(&self) {
820        PgStore::leadership_lost(self).await
821    }
822}
823
824#[async_trait]
825impl Store<Bytes, Bytes> for PgStore {
826    async fn acquire_leadership(&self) -> Result<u16, StoreError> {
827        let inner = &self.inner;
828        let old = inner.leader.lock().unwrap().take();
829        if let Some(l) = old {
830            l.keeper.abort();
831            let _ = l.keeper.await;
832        }
833        let mut delay = inner.cfg.lock_poll.max(Duration::from_millis(50));
834        let (conn, epoch) = loop {
835            match self.try_lead().await {
836                Ok(v) => break v,
837                Err(e @ StoreError::Unavailable(_)) => {
838                    // Dropping the session (if any) releases a lock taken before the error.
839                    tracing::warn!(error = %e, "cannot reach the database to acquire leadership; retrying");
840                    tokio::time::sleep(delay).await;
841                    delay = (delay * 2).min(MAX_RETRY_DELAY);
842                }
843                Err(e) => return Err(e),
844            }
845        };
846        let keeper = tokio::spawn(Self::keeper(
847            Arc::downgrade(inner),
848            conn,
849            inner.cfg.keepalive,
850            inner.cfg.session_timeout,
851        ));
852        *inner.leader.lock().unwrap() = Some(Leader {
853            epoch: epoch as u16,
854            keeper,
855        });
856        inner.lost.send_replace(false);
857        tracing::info!(epoch, "acquired cronoq leadership");
858        Ok(epoch as u16)
859    }
860
861    async fn release_leadership(&self) {
862        self.release().await
863    }
864
865    async fn commit(
866        &self,
867        epoch: u16,
868        base: TimeBase,
869        batch: &[Mutation<Bytes>],
870        outputs: &[(TaskId, &Bytes)],
871    ) -> Result<(), StoreError> {
872        if *self.inner.lost.borrow() {
873            return Err(StoreError::Fenced);
874        }
875        match tokio::time::timeout(
876            self.inner.cfg.commit_timeout,
877            self.commit_inner(epoch, base, batch, outputs),
878        )
879        .await
880        {
881            Ok(r) => r,
882            // Commits are idempotent, so retrying one that did land is harmless.
883            Err(_) => Err(StoreError::Unavailable(format!(
884                "commit timed out after {:?}",
885                self.inner.cfg.commit_timeout
886            ))),
887        }
888    }
889
890    async fn recover(&self, base: TimeBase) -> Result<Recovered<Bytes>, StoreError> {
891        let pool = &self.inner.pool;
892        let mut rec = Recovered::default();
893        let max_id: i64 = sqlx::query_scalar(
894            "SELECT GREATEST((SELECT max_id FROM cronoq_leader WHERE id = 1), \
895             (SELECT COALESCE(MAX(id), 0) FROM cronoq_tasks))",
896        )
897        .fetch_one(pool)
898        .await
899        .map_err(db)?;
900        rec.next_id = if max_id > 0 { max_id as u64 + 1 } else { 0 };
901
902        let rows = sqlx::query(
903            "SELECT id, seq, arrival_unix_ns, pool, kind, priority, key, payload, deadline_unix_ns, \
904             fixed_at_unix_ns, fixed_tolerance_ns, fixed_on_miss, duration_hint_ns, timeout_ns, \
905             retry::text AS retry, deps::text AS deps, resources::text AS resources, \
906             tags::text AS tags, labels::text AS labels, tenant, attempt FROM cronoq_tasks \
907             WHERE NOT terminal \
908             ORDER BY id",
909        )
910        .fetch_all(pool)
911        .await
912        .map_err(db)?;
913        let mut corrupt: Vec<(i64, Option<String>, String)> = Vec::new();
914        for r in &rows {
915            match decode_task(base, r) {
916                Ok(t) => rec.tasks.push(t),
917                Err(e) => {
918                    let id: i64 = r.get("id");
919                    tracing::error!(task = id, error = %e, "quarantining undecodable task row");
920                    corrupt.push((id, r.get("key"), format!("corrupt task row: {e}")));
921                }
922            }
923        }
924        drop(rows);
925        let now = now_unix();
926        if !corrupt.is_empty() {
927            let ids: Vec<i64> = corrupt.iter().map(|c| c.0).collect();
928            let msgs: Vec<String> = corrupt.iter().map(|c| cleaned(&c.2)).collect();
929            sqlx::query(
930                "UPDATE cronoq_tasks t SET status = $3, terminal = TRUE, updated_unix_ns = $4, \
931                 message = u.message FROM UNNEST($1::bigint[], $2::text[]) AS u(id, message) \
932                 WHERE t.id = u.id",
933            )
934            .bind(ids)
935            .bind(msgs)
936            .bind(TaskStatus::Failed.name())
937            .bind(now)
938            .execute(pool)
939            .await
940            .map_err(db)?;
941        }
942
943        // Finished tasks: those within retention, plus any that live tasks depend on.
944        let cutoff = now.saturating_sub(dur_ns(self.inner.cfg.retention));
945        let mut dep_ids = HashSet::new();
946        let mut dep_keys = HashSet::new();
947        for t in &rec.tasks {
948            for d in &t.spec.deps {
949                match &d.target {
950                    TaskRef::Id(id) => dep_ids.insert(id.0 as i64),
951                    TaskRef::Key(k) => dep_keys.insert(cleaned(k)),
952                };
953            }
954        }
955        let rows = sqlx::query(
956            "SELECT id, key, status, updated_unix_ns FROM cronoq_tasks \
957             WHERE terminal AND (updated_unix_ns >= $1 OR id = ANY($2) OR key = ANY($3)) \
958             ORDER BY updated_unix_ns, id",
959        )
960        .bind(cutoff)
961        .bind(dep_ids.into_iter().collect::<Vec<_>>())
962        .bind(dep_keys.into_iter().collect::<Vec<_>>())
963        .fetch_all(pool)
964        .await
965        .map_err(db)?;
966        rec.terminal = rows.iter().filter_map(|r| terminal_from(base, r)).collect();
967        // Quarantined rows were just updated, so they come last in this order.
968        rec.terminal
969            .extend(corrupt.into_iter().map(|(id, key, _)| RecoveredTerminal {
970                id: TaskId(id as u64),
971                key: key.map(Into::into),
972                status: TaskStatus::Failed,
973                at: from_unix(base, now),
974            }));
975
976        let rows = sqlx::query(
977            "SELECT name, spec::text AS spec, template_payload, next_unix_ns, last_task_id \
978             FROM cronoq_recurring ORDER BY name",
979        )
980        .fetch_all(pool)
981        .await
982        .map_err(db)?;
983        for r in rows {
984            let name: String = r.get("name");
985            let spec: String = r.get("spec");
986            match recurring_from(base, name.clone(), &spec, r.get("template_payload")) {
987                Ok(spec) => {
988                    let next = r
989                        .get::<Option<i64>, _>("next_unix_ns")
990                        .map(|u| from_unix(base, u));
991                    let last = r
992                        .get::<Option<i64>, _>("last_task_id")
993                        .map(|id| TaskId(id as u64));
994                    rec.recurring.push(RecoveredRecurring { spec, next, last });
995                }
996                // Left in place (so it can be repaired) but not scheduled.
997                Err(e) => tracing::error!(
998                    schedule = %name,
999                    error = %e,
1000                    "skipping undecodable recurring schedule"
1001                ),
1002            }
1003        }
1004        Ok(rec)
1005    }
1006
1007    async fn load_output(&self, id: TaskId) -> Result<Option<Bytes>, StoreError> {
1008        let out: Option<Option<Vec<u8>>> =
1009            sqlx::query_scalar("SELECT output FROM cronoq_tasks WHERE id = $1 AND status = $2")
1010                .bind(id.0 as i64)
1011                .bind(TaskStatus::Succeeded.name())
1012                .fetch_optional(&self.inner.pool)
1013                .await
1014                .map_err(db)?;
1015        Ok(out.flatten().map(Bytes::from))
1016    }
1017}
1018
1019/// Latest transition of a task within a batch.
1020struct StatusRow {
1021    status: TaskStatus,
1022    attempt: u32,
1023    at: i64,
1024    message: Option<String>,
1025}
1026
1027impl StatusRow {
1028    /// Apply a later transition, keeping the message unless the later one has its own.
1029    fn fold(&mut self, later: StatusRow) {
1030        let message = later.message.or(self.message.take());
1031        *self = StatusRow { message, ..later };
1032    }
1033}
1034
1035/// Column-wise buffers for a multi-row `INSERT ... SELECT FROM UNNEST`.
1036#[derive(Default)]
1037struct Inserts {
1038    id: Vec<i64>,
1039    seq: Vec<i64>,
1040    arrival: Vec<i64>,
1041    pool: Vec<String>,
1042    kind: Vec<String>,
1043    priority: Vec<i16>,
1044    key: Vec<Option<String>>,
1045    payload: Vec<Vec<u8>>,
1046    deadline: Vec<Option<i64>>,
1047    fixed_at: Vec<Option<i64>>,
1048    fixed_tol: Vec<Option<i64>>,
1049    fixed_miss: Vec<Option<String>>,
1050    hint: Vec<Option<i64>>,
1051    timeout: Vec<Option<i64>>,
1052    retry: Vec<Option<String>>,
1053    deps: Vec<String>,
1054    resources: Vec<String>,
1055    tags: Vec<String>,
1056    labels: Vec<String>,
1057    tenant: Vec<Option<String>>,
1058    status: Vec<&'static str>,
1059    terminal: Vec<bool>,
1060    attempt: Vec<i32>,
1061    updated: Vec<i64>,
1062    message: Vec<Option<String>>,
1063}
1064
1065impl Inserts {
1066    fn push(
1067        &mut self,
1068        base: TimeBase,
1069        now: i64,
1070        id: TaskId,
1071        seq: u64,
1072        arrival: cronoq_core::Time,
1073        s: &TaskSpec<Bytes>,
1074    ) {
1075        let (fa, ft, fm) = fixed_cols(base, &s.fixed_start);
1076        self.id.push(id.0 as i64);
1077        self.seq.push(seq as i64);
1078        self.arrival.push(to_unix(base, arrival));
1079        self.pool.push(cleaned(&s.pool));
1080        self.kind.push(cleaned(&s.kind));
1081        self.priority.push(s.priority as i16);
1082        self.key.push(s.key.as_deref().map(cleaned));
1083        self.payload.push(s.payload.to_vec());
1084        self.deadline.push(s.deadline.map(|d| to_unix(base, d)));
1085        self.fixed_at.push(fa);
1086        self.fixed_tol.push(ft);
1087        self.fixed_miss.push(fm);
1088        self.hint.push(s.duration_hint.map(dur_ns));
1089        self.timeout.push(s.timeout.map(dur_ns));
1090        self.retry.push(retry_json(&s.retry));
1091        self.deps.push(deps_json(&s.deps));
1092        self.resources.push(resources_json(s));
1093        self.tags.push(tags_json(s));
1094        self.labels.push(labels_json(s));
1095        self.tenant.push(s.tenant.as_deref().map(cleaned));
1096        self.status.push(QUEUED);
1097        self.terminal.push(false);
1098        self.attempt.push(0);
1099        self.updated.push(now);
1100        self.message.push(None);
1101    }
1102
1103    /// Apply a transition to the task at row `i`.
1104    fn set_status(&mut self, i: usize, row: StatusRow) {
1105        self.status[i] = row.status.name();
1106        self.terminal[i] = row.status.is_terminal();
1107        self.attempt[i] = row.attempt as i32;
1108        self.updated[i] = row.at;
1109        if row.message.is_some() {
1110            self.message[i] = row.message;
1111        }
1112    }
1113
1114    async fn execute(
1115        self,
1116        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
1117    ) -> Result<(), StoreError> {
1118        sqlx::query(
1119            "INSERT INTO cronoq_tasks (id, seq, arrival_unix_ns, pool, kind, priority, key, payload, \
1120             deadline_unix_ns, fixed_at_unix_ns, fixed_tolerance_ns, fixed_on_miss, duration_hint_ns, timeout_ns, \
1121             retry, deps, resources, tags, status, terminal, attempt, updated_unix_ns, message, labels, tenant) \
1122             SELECT u.id, u.seq, u.arrival, u.pool, u.kind, u.priority, u.key, u.payload, u.deadline, u.fixed_at, \
1123             u.fixed_tol, u.fixed_miss, u.hint, u.timeout, u.retry::jsonb, u.deps::jsonb, u.resources::jsonb, \
1124             u.tags::jsonb, u.status, u.terminal, u.attempt, u.updated, u.message, u.labels::jsonb, u.tenant \
1125             FROM UNNEST($1::bigint[], $2::bigint[], $3::bigint[], $4::text[], $5::text[], $6::smallint[], \
1126             $7::text[], $8::bytea[], $9::bigint[], $10::bigint[], $11::bigint[], $12::text[], $13::bigint[], \
1127             $14::bigint[], $15::text[], $16::text[], $17::text[], $18::text[], $19::text[], $20::bool[], \
1128             $21::int[], $22::bigint[], $23::text[], $24::text[], $25::text[]) \
1129             AS u(id, seq, arrival, pool, kind, priority, key, payload, deadline, fixed_at, fixed_tol, fixed_miss, \
1130             hint, timeout, retry, deps, resources, tags, status, terminal, attempt, updated, message, labels, \
1131             tenant) \
1132             ON CONFLICT (id) DO NOTHING",
1133        )
1134        .bind(self.id)
1135        .bind(self.seq)
1136        .bind(self.arrival)
1137        .bind(self.pool)
1138        .bind(self.kind)
1139        .bind(self.priority)
1140        .bind(self.key)
1141        .bind(self.payload)
1142        .bind(self.deadline)
1143        .bind(self.fixed_at)
1144        .bind(self.fixed_tol)
1145        .bind(self.fixed_miss)
1146        .bind(self.hint)
1147        .bind(self.timeout)
1148        .bind(self.retry)
1149        .bind(self.deps)
1150        .bind(self.resources)
1151        .bind(self.tags)
1152        .bind(self.status)
1153        .bind(self.terminal)
1154        .bind(self.attempt)
1155        .bind(self.updated)
1156        .bind(self.message)
1157        .bind(self.labels)
1158        .bind(self.tenant)
1159        .execute(&mut **tx)
1160        .await
1161        .map_err(db)?;
1162        Ok(())
1163    }
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169
1170    #[test]
1171    fn connection_failures_are_transient() {
1172        let io = sqlx::Error::Io(std::io::Error::new(
1173            std::io::ErrorKind::ConnectionReset,
1174            "reset",
1175        ));
1176        assert!(db(io).is_transient());
1177        assert!(db(sqlx::Error::PoolTimedOut).is_transient());
1178        assert!(!db(sqlx::Error::RowNotFound).is_transient());
1179    }
1180
1181    #[test]
1182    fn schema_names_are_validated() {
1183        assert!(valid_schema("cronoq_prod2"));
1184        for bad in ["", "1abc", "a-b", "a\"b", &"x".repeat(64)] {
1185            assert!(!valid_schema(bad), "{bad}");
1186        }
1187    }
1188}