1#![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
65const MIGRATIONS: &[&str] = &[
68 include_str!("schema.sql"),
69 "ALTER TABLE cronoq_leader ADD COLUMN IF NOT EXISTS max_id BIGINT NOT NULL DEFAULT 0",
71 "ALTER TABLE cronoq_recurring ADD COLUMN IF NOT EXISTS last_task_id BIGINT",
73 "ALTER TABLE cronoq_tasks ADD COLUMN IF NOT EXISTS labels JSONB NOT NULL DEFAULT '[]'::jsonb",
75 "ALTER TABLE cronoq_tasks ADD COLUMN IF NOT EXISTS tenant TEXT",
77];
78const QUEUED: &str = "queued";
80const MAX_EPOCH: i32 = 32767;
82const LOCK_NS_LEADER: i32 = 0x6372_6f71;
84const LOCK_NS_MIGRATE: i32 = 0x6372_6f6d;
85const PRUNE_BATCH: i64 = 1000;
87const MAX_RETRY_DELAY: Duration = Duration::from_secs(5);
89
90#[derive(Clone, Debug)]
92pub struct PgStoreConfig {
93 pub schema: String,
97 pub retention: Duration,
100 pub lock_poll: Duration,
102 pub keepalive: Duration,
104 pub session_timeout: Duration,
107 pub commit_timeout: Duration,
110 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
128pub 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 lock_id: i32,
142 leader: Mutex<Option<Leader>>,
143 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#[derive(Clone)]
157pub struct PgStore {
158 inner: Arc<Inner>,
159}
160
161fn 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 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
191fn 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 pub async fn connect(url: &str) -> Result<PgStore, StoreError> {
215 Self::connect_with(url, PgStoreConfig::default()).await
216 }
217
218 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 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 sqlx::query(AssertSqlSafe(format!(
276 "CREATE SCHEMA IF NOT EXISTS \"{schema}\""
277 )))
278 .execute(&mut *tx)
279 .await
280 .map_err(db)?;
281 }
282 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 for m in &MIGRATIONS[version..] {
334 for stmt in statements(m) {
335 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 pub fn pool(&self) -> &PgPool {
354 &self.inner.pool
355 }
356
357 pub fn config(&self) -> &PgStoreConfig {
359 &self.inner.cfg
360 }
361
362 pub fn leader_lock(&self) -> (i32, i32) {
365 (LOCK_NS_LEADER, self.inner.lock_id)
366 }
367
368 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 pub fn is_leader(&self) -> bool {
378 self.epoch().is_some()
379 }
380
381 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 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 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 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 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 return;
490 }
491 }
492 }
493
494 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 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 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 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 if !ins.id.is_empty() {
652 ins.execute(&mut tx).await?;
653 }
654 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 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
716fn 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
769macro_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 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 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 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 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 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
1019struct StatusRow {
1021 status: TaskStatus,
1022 attempt: u32,
1023 at: i64,
1024 message: Option<String>,
1025}
1026
1027impl StatusRow {
1028 fn fold(&mut self, later: StatusRow) {
1030 let message = later.message.or(self.message.take());
1031 *self = StatusRow { message, ..later };
1032 }
1033}
1034
1035#[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 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}