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}