1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
//! The producer-side queue handle.
//!
//! A [`Queue`] turns a typed [`Task`] into a stored job and enqueues it, applying
//! the deduplication flow when the task opts in. It needs only the [`Task`] trait,
//! never a handler or the worker's shared state, so a producer binary can depend
//! on it without pulling in execution logic. It is a thin handle over a [`Store`];
//! clone it freely.
use crate::context::JournalEntry;
use crate::error::Error;
use crate::store::{
CleanupCriteria, HistoryFilter, JobRecord, JournalAppend, JournalOutcome, JournalRecord,
MergePayload, NewJob, Snapshot, Store,
};
use crate::task::{Merge, Pending, Task};
use chrono::{DateTime, Utc};
use std::sync::Arc;
use ulid::Ulid;
/// A handle for enqueuing tasks onto a backing [`Store`].
#[derive(Clone)]
pub struct Queue {
store: Arc<dyn Store>,
}
impl Queue {
/// Build a queue handle over a store.
pub fn new(store: Arc<dyn Store>) -> Queue {
Queue { store }
}
/// The underlying store, for callers that need direct access.
pub fn store(&self) -> &Arc<dyn Store> {
&self.store
}
/// Enqueue a task for immediate processing.
///
/// The job becomes eligible to claim at once. If the task deduplicates and a
/// pending sibling exists, the task's `merge` decision is applied instead.
/// Returns the id of the surviving job (the existing one on a merge, a new one
/// otherwise).
pub async fn enqueue<T: Task>(&self, task: T) -> Result<Ulid, Error> {
let now = Utc::now();
self.submit(task, now, now).await
}
/// Enqueue a task to first become eligible at `when`.
///
/// Until `when`, the job is invisible to claims. The deduplication flow still
/// applies. Returns the id of the surviving job.
pub async fn enqueue_at<T: Task>(&self, task: T, when: DateTime<Utc>) -> Result<Ulid, Error> {
let now = Utc::now();
self.submit(task, now, when).await
}
/// Query jobs by the history filter, most recently created first.
///
/// This reads individual job rows for inspection and history; it is distinct
/// from [`stats`](Queue::stats), which reports live aggregate state.
pub async fn jobs(&self, filter: &HistoryFilter) -> Result<Vec<JobRecord>, Error> {
self.store.query_jobs(filter).await
}
/// Fetch a single job by id, including its `payload` and `carry`, or `None`
/// if no such job exists.
///
/// A primary-key point lookup for detail inspection, distinct from
/// [`jobs`](Queue::jobs), which scans a filtered, paginated history page.
pub async fn job(&self, id: Ulid) -> Result<Option<JobRecord>, Error> {
self.store.job(id).await
}
/// Fetch a single job's journal timeline in chronological order.
pub async fn job_journal(&self, id: Ulid) -> Result<Vec<JournalRecord>, Error> {
self.store.journal(id).await
}
/// Bulk-delete terminal jobs matching the criteria, cascading to their
/// journal entries. Returns the number of jobs deleted.
///
/// Retention is **caller-driven**: venturi never prunes on its own. The
/// worker claims, runs, and recovers jobs, but completed and dead rows
/// accumulate until a caller deletes them, so bounding history is the
/// consumer's responsibility. How to drive it is open — a periodic sweep, or
/// an opportunistic call on the write path (e.g. cleaning up on each enqueue)
/// are both reasonable.
///
/// Cost tracks the `finished_at` index the default adapter maintains: a sweep
/// with nothing yet due is an indexed probe over zero rows, not a table scan,
/// so driving it opportunistically on every enqueue is cheap; a sweep that
/// does delete additionally pays for the rows removed and their cascaded
/// journal entries.
pub async fn cleanup(&self, criteria: &CleanupCriteria) -> Result<u64, Error> {
self.store.cleanup(criteria).await
}
/// A live snapshot of queue state: backlog depth and oldest-pending age per
/// kind, the in-flight (claimed) count, and dead counts per kind.
pub async fn stats(&self) -> Result<Snapshot, Error> {
self.store.stats().await
}
/// The deduplication-aware enqueue: a plain insert when the task does not
/// deduplicate or has no pending sibling, otherwise the task's `merge`
/// decision applied to that sibling.
async fn submit<T: Task>(
&self,
task: T,
created_at: DateTime<Utc>,
visible_at: DateTime<Utc>,
) -> Result<Ulid, Error> {
let Some(dedup_key) = task.dedup_key() else {
return self.insert(&task, created_at, visible_at, None).await;
};
let key = dedup_key.into_string();
// Dedup is a read-modify-write across three store calls (read the
// candidate, compute the merge below, write it via `apply_merge`), not one
// transaction. Concurrent enqueues of the same key therefore resolve
// last-writer-wins; see `Task::merge` for the contract.
let Some(candidate) = self.store.dedup_candidate(T::KIND, &key).await? else {
return self.insert(&task, created_at, visible_at, Some(key)).await;
};
// Reconstruct the existing job's typed state so the decision is informed
// by its content and history.
let existing_payload: T = serde_json::from_value(candidate.payload.clone())?;
let existing_carry: T::Carry = if candidate.carry.is_null() {
T::Carry::default()
} else {
serde_json::from_value(candidate.carry.clone())?
};
let journal = self
.store
.journal(candidate.id)
.await?
.into_iter()
.map(JournalEntry::from_record)
.collect();
let pending = Pending::new(
existing_payload,
existing_carry,
candidate.run_count,
journal,
);
match task.merge(&pending) {
Merge::Independent => self.insert(&task, created_at, visible_at, Some(key)).await,
Merge::Keep => {
self.apply_merge(&candidate, None, &task, created_at, visible_at, &key)
.await
}
Merge::Replace => {
let update = MergePayload {
payload: serde_json::to_value(&task)?,
carry: serde_json::to_value(T::Carry::default())?,
priority: task.priority().as_smallint(),
};
self.apply_merge(
&candidate,
Some(update),
&task,
created_at,
visible_at,
&key,
)
.await
}
Merge::With {
task: merged,
carry,
} => {
let update = MergePayload {
payload: serde_json::to_value(&merged)?,
carry: serde_json::to_value(carry)?,
priority: merged.priority().as_smallint(),
};
self.apply_merge(
&candidate,
Some(update),
&task,
created_at,
visible_at,
&key,
)
.await
}
}
}
/// Apply a Keep/Replace/With merge to the candidate, recording a `merged`
/// journal entry. If the candidate is no longer pending (it was claimed in the
/// meantime), fall back to a fresh enqueue of the incoming task so no work is
/// lost.
///
/// The fallback is a brand-new job: it does not inherit the now-claimed
/// candidate's `run_count`, `failure_count`, or carry. A `Merge::With` that
/// intended to continue the in-flight work therefore starts from `Default`
/// carry rather than the candidate's accumulated state.
async fn apply_merge<T: Task>(
&self,
candidate: &crate::store::JobRecord,
update: Option<MergePayload>,
incoming: &T,
created_at: DateTime<Utc>,
visible_at: DateTime<Utc>,
key: &str,
) -> Result<Ulid, Error> {
let journal = JournalAppend {
kind: candidate.kind.clone(),
run_no: candidate.run_count,
recorded_at: Utc::now(),
outcome: JournalOutcome::Merged,
note: Some("enqueue merged into pending job".to_owned()),
attachment: None,
};
if self.store.merge_into(candidate.id, update, journal).await? {
crate::observability::merged(&candidate.kind);
Ok(candidate.id)
} else {
self.insert(incoming, created_at, visible_at, Some(key.to_owned()))
.await
}
}
/// Serialize a task into a fresh [`NewJob`] and insert it.
async fn insert<T: Task>(
&self,
task: &T,
created_at: DateTime<Utc>,
visible_at: DateTime<Utc>,
dedup_key: Option<String>,
) -> Result<Ulid, Error> {
let id = Ulid::new();
let job = NewJob {
id,
kind: T::KIND.to_owned(),
payload: serde_json::to_value(task)?,
priority: task.priority().as_smallint(),
created_at,
visible_at,
carry: serde_json::to_value(T::Carry::default())?,
dedup_key,
};
self.store.enqueue(&job).await?;
crate::observability::enqueued(T::KIND);
Ok(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Status;
use crate::task::{DedupKey, Merge, Pending};
use crate::test_support::FakeStore;
use serde::{Deserialize, Serialize};
/// A dedup task that, on collision, continues the in-flight work via
/// `Merge::With` carrying a non-default value.
#[derive(Serialize, Deserialize)]
struct Accumulate {
amount: u64,
}
impl Task for Accumulate {
const KIND: &'static str = "accumulate";
type Carry = u64;
fn dedup_key(&self) -> Option<DedupKey> {
Some(DedupKey::from("acc"))
}
fn merge(&self, _existing: &Pending<Self>) -> Merge<Self> {
Merge::With {
task: Accumulate {
amount: self.amount,
},
carry: 999,
}
}
}
/// When the dedup candidate is claimed in the window between the candidate
/// read and the merge, `apply_merge` falls back to a fresh enqueue rather than
/// losing the work or erroring — and the fresh job starts from default carry,
/// not the candidate's accumulated state.
#[tokio::test]
async fn merge_fallback_inserts_a_fresh_job_when_the_candidate_was_claimed() {
let store = FakeStore::new();
let queue = Queue::new(Arc::new(store.clone()));
let first = queue
.enqueue(Accumulate { amount: 1 })
.await
.expect("first enqueue");
// Reproduce the race: the merge misses as if the candidate had just been claimed.
store.force_next_merge_into_miss();
let second = queue
.enqueue(Accumulate { amount: 5 })
.await
.expect("second enqueue");
assert_ne!(
first, second,
"the fallback inserts a fresh job, not the candidate"
);
assert_eq!(store.count(Status::Pending), 2, "no work is lost");
let fresh = store.job(second).expect("fresh job exists");
assert_eq!(
fresh.carry,
serde_json::json!(0),
"the fallback job starts from default carry, not the merge's 999"
);
}
}