Skip to main content

boson_core/backend/
queue_backend.rs

1//! [`QueueBackend`] — queue persistence trait for custom storage adapters.
2//!
3//! **App authors:** pick a shipped backend by topology (mem = Mode 1 only; `SQLite`/Postgres/Redis/NATS
4//! for shared queues) using the [`boson`](https://docs.rs/uf-boson) crate
5//! [backend table](https://docs.rs/uf-boson/latest/boson/index.html#mode-1--embedded-one-binary).
6//!
7//! **Adapter authors:** see **How to implement** on [`QueueBackend`] for a step-by-step guide and
8//! reference adapters.
9
10use std::fmt::Debug;
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15
16use crate::error::Result;
17use crate::models::{Job, JobStatus, Run, RunStatus, TaskConfig, TaskRunStats};
18
19/// Default router key for single-backend hosts.
20pub const DEFAULT_BACKEND_NAME: &str = "default";
21
22/// Whether [`QueueBackend::enqueue_with_policies`] inserted or reused a job.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum JobEnqueueDisposition {
25    /// A new job row was inserted.
26    InsertedNew,
27    /// An existing non-terminal job matched the idempotency key.
28    ReusedIdempotent,
29}
30
31/// Stable async trait for queue persistence (jobs, runs, config, leases).
32///
33/// Implement this trait in a separate adapter crate (for example `myorg-boson-backend-redis`) using
34/// only DTOs from `boson-core`. The runtime holds `Arc<dyn QueueBackend>` and calls these methods
35/// from the worker loop, enqueue path, and HTTP admin handlers.
36///
37/// # How to implement
38///
39/// 1. **Create an adapter crate** — depend on `boson-core`, `async-trait`, and your storage client.
40/// 2. **Map DTOs to storage** — persist [`Job`], [`Run`], [`TaskConfig`], and lease records using
41///    the types in [`crate::models`]; do not invent parallel schemas.
42/// 3. **Implement every method** — group by jobs, runs, task config, and leases (see below).
43/// 4. **Honor the contract** — especially idempotent enqueue, atomic claim, and lease exclusivity.
44/// 5. **Wire at boot** — pass `Arc::new(YourBackend::new(...))` to
45///    [`boson_runtime::BosonBuilder::queue_backend`](https://docs.rs/boson-runtime/latest/boson_runtime/struct.BosonBuilder.html#method.queue_backend).
46/// 6. **Validate** — copy integration tests from [`MemQueueBackend`](https://docs.rs/boson-backend-mem/latest/boson_backend_mem/struct.MemQueueBackend.html)
47///    or [`SqlQueueBackend`](https://docs.rs/boson-backend-sql-common/latest/boson_backend_sql_common/struct.SqlQueueBackend.html).
48///
49/// # Method groups
50///
51/// | Group | Key methods | Notes |
52/// |-------|-------------|-------|
53/// | **Jobs** | [`enqueue_with_policies`](Self::enqueue_with_policies), [`try_claim_job`](Self::try_claim_job), [`list_queued_for_pool_sorted`](Self::list_queued_for_pool_sorted) | Apply [`TaskConfig`] rate limits on enqueue; claim must be atomic |
54/// | **Runs** | [`upsert_run`](Self::upsert_run), [`finish_run`](Self::finish_run), [`task_run_stats`](Self::task_run_stats) | One run row per execution attempt |
55/// | **Task config** | [`get_task_config`](Self::get_task_config), [`upsert_task_config`](Self::upsert_task_config) | Optional per-task overrides stored by the runtime |
56/// | **Leases** | [`try_claim_run_lease`](Self::try_claim_run_lease), [`extend_lease`](Self::extend_lease), [`expired_lease_job_pairs`](Self::expired_lease_job_pairs) | Required for multi-worker deploys; may no-op for single-node dev |
57///
58/// # Reference implementations
59///
60/// - [`MemQueueBackend`](https://docs.rs/boson-backend-mem/latest/boson_backend_mem/struct.MemQueueBackend.html) — in-memory, ideal starting point (~200 lines per module)
61/// - [`SqlQueueBackend`](https://docs.rs/boson-backend-sql-common/latest/boson_backend_sql_common/struct.SqlQueueBackend.html) — shared SQL for `SQLite`/`PostgreSQL`
62///
63/// # Example
64///
65/// Skeleton only — every trait method must be implemented (see reference adapters above):
66///
67/// ```ignore
68/// use std::sync::Arc;
69///
70/// use async_trait::async_trait;
71/// use boson_core::{Job, JobEnqueueDisposition, QueueBackend, Result, TaskConfig};
72/// use boson_runtime::Boson;
73///
74/// #[derive(Debug)]
75/// pub struct MyQueueBackend {
76///     // pool, client, or in-memory store
77/// }
78///
79/// #[async_trait]
80/// impl QueueBackend for MyQueueBackend {
81///     async fn enqueue_with_policies(
82///         &self,
83///         job: Job,
84///         task_config: &TaskConfig,
85///     ) -> Result<(String, JobEnqueueDisposition)> {
86///         // insert job; honor idempotency_key and rate limits from task_config
87///         todo!()
88///     }
89///
90///     async fn try_claim_job(&self, job_id: &str) -> Result<Option<Job>> {
91///         // atomically queued -> running, or return None
92///         todo!()
93///     }
94///
95///     // ... implement all remaining QueueBackend methods
96/// }
97///
98/// // Integrator wiring (see boson crate Getting started — Mode 1 / Mode 2):
99/// let boson = Boson::builder()
100///     .queue_backend(Arc::new(MyQueueBackend { /* ... */ }))
101///     .execution_context_factory(boson_core::JsonExecutionContextFactory)
102///     .auto_registry()
103///     .build()?;
104/// ```
105///
106/// # Contract
107///
108/// - Implementations must be `Send + Sync`; hosts hold `Arc<dyn QueueBackend>`.
109/// - Idempotency: when `job.idempotency_key` is set, `enqueue_with_policies` returns
110///   [`ReusedIdempotent`](JobEnqueueDisposition::ReusedIdempotent) with the existing job id
111///   if a non-terminal job already exists for that key.
112/// - Claim: `try_claim_job` must atomically transition `queued` → `running` or return `None`.
113/// - Leases: `try_claim_run_lease` returns `None` when another worker holds an active lease.
114#[async_trait]
115pub trait QueueBackend: Send + Sync + Debug {
116    // --- Jobs ---
117
118    /// Persist or update a job row.
119    async fn upsert_job(&self, job: &Job) -> Result<()>;
120
121    /// Insert job with idempotency semantics (see trait-level contract).
122    async fn enqueue_with_policies(
123        &self,
124        job: Job,
125        task_config: &TaskConfig,
126    ) -> Result<(String, JobEnqueueDisposition)>;
127
128    /// Load one job by id.
129    async fn get_job(&self, job_id: &str) -> Result<Option<Job>>;
130
131    /// List jobs with optional status filter and pagination.
132    async fn list_jobs(
133        &self,
134        status_filter: Option<JobStatus>,
135        offset: usize,
136        limit: usize,
137    ) -> Result<Vec<Job>>;
138
139    /// Cancel a job if it is still active (`queued` or `running`).
140    async fn cancel_job_if_active(&self, job_id: &str) -> Result<()>;
141
142    /// Atomically claim a queued job for execution.
143    async fn try_claim_job(&self, job_id: &str) -> Result<Option<Job>>;
144
145    /// Revert a job to `queued` (retry path).
146    async fn revert_job_to_queued(&self, job_id: &str) -> Result<()>;
147
148    /// Distinct pool names with queued jobs.
149    async fn distinct_pools_queued(&self) -> Result<Vec<String>>;
150
151    /// Queued jobs for one pool, sorted by priority then created time.
152    async fn list_queued_for_pool_sorted(&self, pool: &str, limit: usize) -> Result<Vec<Job>>;
153
154    /// Atomically claim the highest-priority queued job from `pool` when supported.
155    ///
156    /// Backends that support single-round-trip claim (Redis, NATS `WorkQueue`) override this to
157    /// pop and claim in one operation. The default returns `None` so callers fall back to
158    /// [`Self::list_queued_for_pool_sorted`] + [`Self::try_claim_job`].
159    async fn pop_claim_from_pool(&self, _pool: &str) -> Result<Option<Job>> {
160        Ok(None)
161    }
162
163    /// Count jobs, optionally filtered by status.
164    async fn count_jobs(&self, status_filter: Option<JobStatus>) -> Result<u64>;
165
166    /// Count jobs for one task, optionally filtered by status.
167    async fn count_jobs_for_task(
168        &self,
169        task_name: &str,
170        status: Option<JobStatus>,
171    ) -> Result<u64>;
172
173    /// Count active (`queued` + `running`) jobs for rate-limit checks.
174    async fn count_active_jobs_for_task(&self, task_name: &str) -> Result<u32>;
175
176    /// Find non-terminal job by idempotency key.
177    ///
178    /// Used by [`Self::enqueue_with_policies`] to return
179    /// [`JobEnqueueDisposition::ReusedIdempotent`](JobEnqueueDisposition::ReusedIdempotent)
180    /// when a job with the same key is still `queued` or `running`. Returns `None` when no
181    /// matching non-terminal job exists.
182    async fn find_nonterminal_by_idempotency_key(
183        &self,
184        key: &str,
185    ) -> Result<Option<String>>;
186
187    // --- Runs ---
188
189    /// Persist or update a run row.
190    async fn upsert_run(&self, run: &Run) -> Result<()>;
191
192    /// Load one run by id.
193    async fn get_run(&self, run_id: &str) -> Result<Option<Run>>;
194
195    /// List runs with optional job filter and pagination.
196    async fn list_runs(
197        &self,
198        job_id_filter: Option<&str>,
199        offset: usize,
200        limit: usize,
201    ) -> Result<Vec<Run>>;
202
203    /// Mark a run terminal with outcome fields.
204    async fn finish_run(
205        &self,
206        run_id: &str,
207        status: RunStatus,
208        duration_ms: Option<i64>,
209        error_message: Option<String>,
210    ) -> Result<()>;
211
212    /// Count runs, optionally filtered by job id.
213    async fn count_runs(&self, job_id_filter: Option<&str>) -> Result<u64>;
214
215    /// Count runs with `started_at >= since`.
216    async fn count_runs_since(&self, since: DateTime<Utc>) -> Result<u64>;
217
218    /// Aggregate run totals for one task.
219    async fn task_run_stats(&self, task_name: &str) -> Result<TaskRunStats>;
220
221    // --- Task config ---
222
223    /// Load task config by name.
224    async fn get_task_config(&self, task_name: &str) -> Result<Option<TaskConfig>>;
225
226    /// Persist task config.
227    async fn upsert_task_config(&self, config: &TaskConfig) -> Result<()>;
228
229    // --- Leases (distributed workers) ---
230    //
231    // Required when multiple worker processes share one backend (`BOSON_LEASE_TTL_SECS` > 0).
232    // Single-node dev backends may no-op these methods.
233
234    /// Attempt to claim a run lease for `job_id` (stored as lease task id).
235    ///
236    /// Returns `Some(lease_id)` when this worker holds the lease, or `None` when another worker
237    /// holds an active lease for the same job.
238    async fn try_claim_run_lease(
239        &self,
240        job_id: &str,
241        worker_id: &str,
242        ttl_secs: i64,
243    ) -> Result<Option<String>>;
244
245    /// Extend lease TTL for a held lease.
246    async fn extend_lease(&self, lease_id: &str, ttl_secs: i64) -> Result<()>;
247
248    /// Release a held lease.
249    async fn release_lease(&self, lease_id: &str) -> Result<()>;
250
251    /// Expired leases as `(lease_record_id, job_id)`.
252    async fn expired_lease_job_pairs(&self) -> Result<Vec<(String, String)>>;
253}
254
255/// Resolve the default backend from the process-global [`QueueRouter`](crate::QueueRouter).
256///
257/// # Errors
258///
259/// Returns [`BosonError::Internal`](crate::BosonError::Internal) when the global router
260/// was not installed via [`QueueRouter::set_global`](crate::QueueRouter::set_global).
261/// Propagates [`QueueRouter::resolve`](crate::QueueRouter::resolve) errors for the default name.
262pub fn default_backend_from_global() -> Result<Arc<dyn QueueBackend>> {
263    let router = crate::QueueRouter::try_global().ok_or_else(|| {
264        crate::BosonError::Internal("QueueRouter::set_global was not called".into())
265    })?;
266    router.resolve(DEFAULT_BACKEND_NAME)
267}