Skip to content

Commit e3483da

Browse files
committed
feat: add buffered JobQueue and apalis enqueue
1 parent bc2b9f3 commit e3483da

4 files changed

Lines changed: 294 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/event-sorcery/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ apalis = "1.0.0-rc.9"
1919
apalis-sqlite = "1.0.0-rc.8"
2020
apalis-core = "1.0.0-rc.9"
2121
apalis-codec = "0.1.0-rc.9"
22+
ulid = "1.2.1"
2223

2324
[features]
2425
test-support = []

crates/event-sorcery/src/job.rs

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ use apalis_sqlite::{CompactType, Config, SqlitePool, SqliteStorage};
2727
use serde::Serialize;
2828
use serde::de::DeserializeOwned;
2929
use std::future::Future;
30+
use std::marker::PhantomData;
3031
use std::sync::Arc;
3132
use std::time::Duration;
3233
use tracing::{debug, error};
3334

35+
use crate::dependency::{Cons, Nil};
36+
3437
/// A durable, retryable unit of side-effecting work.
3538
///
3639
/// One `Job` type per entity (use [`Never`](crate::Never) for
@@ -74,6 +77,144 @@ pub trait Job: Serialize + DeserializeOwned + Send + 'static {
7477
) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send;
7578
}
7679

80+
/// Type-level list of [`Job`] types an aggregate can dispatch.
81+
///
82+
/// Built from [`Cons`](crate::Cons) / [`Nil`](crate::Nil) -- the same
83+
/// cells [`deps!`](crate::deps) uses for reactor dependencies -- via
84+
/// the [`jobs!`](crate::jobs) macro: `jobs![SendEmail, ChargeCard]`.
85+
pub trait JobList {}
86+
87+
impl JobList for Nil {}
88+
89+
impl<Head: Job, Tail: JobList> JobList for Cons<Head, Tail> {}
90+
91+
/// Membership marker: `J` is one of the job types in this [`JobList`].
92+
///
93+
/// Bounds [`JobQueue::push`] so a handler can enqueue a job only when
94+
/// its type is in the aggregate's declared list. The per-member impls
95+
/// are generated by [`register_jobs!`](crate::register_jobs), which
96+
/// [`jobs!`](crate::jobs) invokes.
97+
pub trait HasJob<J: Job>: JobList {}
98+
99+
/// Single-element list: its one member is trivially present.
100+
impl<J: Job> HasJob<J> for Cons<J, Nil> {}
101+
102+
/// Handler-facing handle for enqueuing the [`Job`]s an aggregate declares.
103+
///
104+
/// Generic over the aggregate's [`JobList`].
105+
/// [`push`](Self::push) / [`push_with_delay`](Self::push_with_delay)
106+
/// are synchronous and merely *buffer* onto the handle, and accept only
107+
/// job types in the list (`Jobs: HasJob<J>`). The framework drains the
108+
/// buffer inside the event-commit transaction, so enqueue is atomic with
109+
/// the triggering events and the handler stays I/O-free.
110+
///
111+
/// Constructed fresh per command by the framework and dropped at the
112+
/// end of the call.
113+
pub struct JobQueue<Jobs: JobList> {
114+
buffered: Vec<PendingPush>,
115+
_jobs: PhantomData<Jobs>,
116+
}
117+
118+
impl<Jobs: JobList> JobQueue<Jobs> {
119+
/// Buffers a job for execution as soon as the events commit.
120+
///
121+
/// Compiles only when `J` is one of the aggregate's declared job
122+
/// types (`Jobs: HasJob<J>`).
123+
pub fn push<J: Job>(&mut self, job: J)
124+
where
125+
Jobs: HasJob<J>,
126+
{
127+
self.buffered.push(PendingPush {
128+
job: Box::new(job),
129+
delay: None,
130+
});
131+
}
132+
133+
/// Buffers a job to run no earlier than `delay` after commit.
134+
pub fn push_with_delay<J: Job>(&mut self, job: J, delay: Duration)
135+
where
136+
Jobs: HasJob<J>,
137+
{
138+
self.buffered.push(PendingPush {
139+
job: Box::new(job),
140+
delay: Some(delay),
141+
});
142+
}
143+
144+
fn into_pending(self) -> Vec<PendingPush> {
145+
self.buffered
146+
}
147+
}
148+
149+
impl<Jobs: JobList> Default for JobQueue<Jobs> {
150+
fn default() -> Self {
151+
Self {
152+
buffered: Vec::new(),
153+
_jobs: PhantomData,
154+
}
155+
}
156+
}
157+
158+
/// A buffered push, type-erased so one queue can hold heterogeneous job
159+
/// types. The concrete type is recovered at flush only to write the
160+
/// `job_type` and JSON payload.
161+
struct PendingPush {
162+
job: Box<dyn ErasedJob>,
163+
delay: Option<Duration>,
164+
}
165+
166+
/// Type-erased view of a buffered [`Job`].
167+
trait ErasedJob: Send {
168+
fn job_type(&self) -> &'static str;
169+
fn encode(&self) -> Result<Vec<u8>, serde_json::Error>;
170+
}
171+
172+
impl<J: Job> ErasedJob for J {
173+
fn job_type(&self) -> &'static str {
174+
std::any::type_name::<J>()
175+
}
176+
177+
fn encode(&self) -> Result<Vec<u8>, serde_json::Error> {
178+
serde_json::to_vec(self)
179+
}
180+
}
181+
182+
/// Builds a type-level [`JobList`] from job types: `jobs![A, B]`
183+
/// expands to `Cons<A, Cons<B, Nil>>`, and `jobs![]` to `Nil`.
184+
///
185+
/// In statement position, declare the list and generate its
186+
/// [`HasJob`] impls in one call with
187+
/// [`register_jobs!`](crate::register_jobs).
188+
#[macro_export]
189+
macro_rules! jobs {
190+
() => { $crate::Nil };
191+
($head:ty $(, $tail:ty)* $(,)?) => {
192+
$crate::Cons<$head, $crate::jobs![$($tail),*]>
193+
};
194+
}
195+
196+
/// Generates the [`HasJob`] impls for each member of a job list, so
197+
/// `JobQueue::push` accepts every declared job type.
198+
///
199+
/// A single-element list is covered by a blanket impl, so this is a
200+
/// no-op there; multi-element lists need the generated impls.
201+
#[macro_export]
202+
macro_rules! register_jobs {
203+
($single:ty) => {};
204+
205+
($($job:ty),+ $(,)?) => {
206+
$crate::register_jobs!(@impls [$($job),+] [$($job),+]);
207+
};
208+
209+
(@impls [] [$($all:ty),+]) => {};
210+
211+
(@impls [$current:ty $(, $rest:ty)*] [$($all:ty),+]) => {
212+
impl $crate::HasJob<$current> for $crate::jobs![$($all),+] {}
213+
214+
$crate::register_jobs!(@impls [$($rest),*] [$($all),+]);
215+
};
216+
}
217+
77218
/// Worker-side handle to apalis's SQLite storage for a single job
78219
/// type.
79220
///
@@ -130,6 +271,20 @@ pub enum JobError {
130271
Injected,
131272
}
132273

274+
/// Error raised while flushing buffered jobs into apalis's queue.
275+
#[derive(Debug, thiserror::Error)]
276+
pub enum QueuePushError {
277+
/// The job payload could not be JSON-encoded.
278+
#[error("failed to encode job payload")]
279+
Encode(#[from] serde_json::Error),
280+
/// The job row could not be written.
281+
#[error("failed to write job row")]
282+
Sql(#[from] sqlx::Error),
283+
/// The requested delay exceeds the representable range.
284+
#[error("job delay exceeds the representable range")]
285+
DelayOverflow(#[from] std::num::TryFromIntError),
286+
}
287+
133288
/// Human-readable identifier for a job instance, used in logs and
134289
/// failure-injection targeting.
135290
#[derive(Debug, Clone)]
@@ -300,6 +455,42 @@ macro_rules! build_supervised_worker {
300455
}};
301456
}
302457

458+
/// Drains a [`JobQueue`] into apalis's `Jobs` table through `connection`.
459+
///
460+
/// Called by the framework inside the event-commit transaction so a
461+
/// job becomes visible to the worker iff its triggering events commit.
462+
/// Rows are written in apalis's storage format -- JSON-encoded payload,
463+
/// `Pending` status, the job type as the queue name -- so the worker's
464+
/// fetcher picks them up.
465+
#[doc(hidden)]
466+
pub async fn flush_jobs<Jobs: JobList>(
467+
connection: &mut sqlx::SqliteConnection,
468+
queue: JobQueue<Jobs>,
469+
) -> Result<(), QueuePushError> {
470+
for pending in queue.into_pending() {
471+
let job_type = pending.job.job_type();
472+
let payload = pending.job.encode()?;
473+
let delay_secs = match pending.delay {
474+
None => 0_i64,
475+
Some(delay) => i64::try_from(delay.as_secs())?,
476+
};
477+
let id = ulid::Ulid::new().to_string();
478+
479+
sqlx::query(
480+
"INSERT INTO Jobs (job, id, job_type, run_at) \
481+
VALUES (?1, ?2, ?3, CAST(strftime('%s', 'now') AS INTEGER) + ?4)",
482+
)
483+
.bind(payload)
484+
.bind(id)
485+
.bind(job_type)
486+
.bind(delay_secs)
487+
.execute(&mut *connection)
488+
.await?;
489+
}
490+
491+
Ok(())
492+
}
493+
303494
fn build_poll_config<J: 'static>() -> Config {
304495
let strategy = StrategyBuilder::new()
305496
.apply(
@@ -397,6 +588,30 @@ mod tests {
397588

398589
use super::*;
399590

591+
async fn jobs_pool() -> sqlx::SqlitePool {
592+
const JOBS_SCHEMA: &str = "CREATE TABLE Jobs ( \
593+
job BLOB NOT NULL, \
594+
id TEXT NOT NULL UNIQUE, \
595+
job_type TEXT NOT NULL, \
596+
status TEXT NOT NULL DEFAULT 'Pending', \
597+
attempts INTEGER NOT NULL DEFAULT 0, \
598+
max_attempts INTEGER NOT NULL DEFAULT 25, \
599+
run_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), \
600+
last_result TEXT, \
601+
lock_at INTEGER, \
602+
lock_by TEXT, \
603+
done_at INTEGER, \
604+
priority INTEGER NOT NULL DEFAULT 0, \
605+
metadata TEXT, \
606+
idempotency_key TEXT, \
607+
PRIMARY KEY(id) \
608+
)";
609+
610+
let pool = sqlx::SqlitePool::connect(":memory:").await.unwrap();
611+
sqlx::query(JOBS_SCHEMA).execute(&pool).await.unwrap();
612+
pool
613+
}
614+
400615
#[derive(Debug, Serialize, Deserialize)]
401616
enum SendEmail {
402617
Welcome { address: String },
@@ -423,6 +638,30 @@ mod tests {
423638
}
424639
}
425640

641+
#[derive(Debug, Serialize, Deserialize)]
642+
struct ChargeCard {
643+
cents: u64,
644+
}
645+
646+
impl Job for ChargeCard {
647+
type Input = ();
648+
type Output = ();
649+
type Error = Infallible;
650+
651+
const WORKER_NAME: &'static str = "charge-card";
652+
const KIND: &'static str = "charge-card";
653+
654+
fn label(&self) -> Label {
655+
Label::new(format!("charge:{}", self.cents))
656+
}
657+
658+
async fn perform(&self, _input: &()) -> Result<(), Infallible> {
659+
Ok(())
660+
}
661+
}
662+
663+
crate::register_jobs!(SendEmail, ChargeCard);
664+
426665
#[test]
427666
fn label_reflects_variant_and_renders_via_display() {
428667
let welcome = SendEmail::Welcome {
@@ -483,4 +722,54 @@ mod tests {
483722
injector,
484723
);
485724
}
725+
726+
#[tokio::test]
727+
async fn flush_writes_an_apalis_compatible_pending_row() {
728+
let pool = jobs_pool().await;
729+
730+
let mut queue = JobQueue::<crate::jobs![SendEmail]>::default();
731+
queue.push(SendEmail::Welcome {
732+
address: "a@example.com".to_string(),
733+
});
734+
735+
let mut transaction = pool.begin().await.unwrap();
736+
flush_jobs(&mut transaction, queue).await.unwrap();
737+
transaction.commit().await.unwrap();
738+
739+
let (payload, job_type, status): (Vec<u8>, String, String) =
740+
sqlx::query_as("SELECT job, job_type, status FROM Jobs")
741+
.fetch_one(&pool)
742+
.await
743+
.unwrap();
744+
745+
assert_eq!(job_type, std::any::type_name::<SendEmail>());
746+
assert_eq!(status, "Pending");
747+
let decoded: SendEmail = serde_json::from_slice(&payload).unwrap();
748+
assert!(matches!(decoded, SendEmail::Welcome { address } if address == "a@example.com"));
749+
}
750+
751+
#[tokio::test]
752+
async fn flush_writes_distinct_rows_for_multiple_job_types() {
753+
let pool = jobs_pool().await;
754+
755+
let mut queue = JobQueue::<crate::jobs![SendEmail, ChargeCard]>::default();
756+
queue.push(SendEmail::Welcome {
757+
address: "a@example.com".to_string(),
758+
});
759+
queue.push(ChargeCard { cents: 4200 });
760+
761+
let mut transaction = pool.begin().await.unwrap();
762+
flush_jobs(&mut transaction, queue).await.unwrap();
763+
transaction.commit().await.unwrap();
764+
765+
let rows: Vec<(String,)> = sqlx::query_as("SELECT job_type FROM Jobs ORDER BY job_type")
766+
.fetch_all(&pool)
767+
.await
768+
.unwrap();
769+
770+
let job_types: Vec<String> = rows.into_iter().map(|(job_type,)| job_type).collect();
771+
assert_eq!(job_types.len(), 2);
772+
assert!(job_types.contains(&std::any::type_name::<ChargeCard>().to_string()));
773+
assert!(job_types.contains(&std::any::type_name::<SendEmail>().to_string()));
774+
}
486775
}

crates/event-sorcery/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ pub use dependency::{Dependent, EntityList, Fold, HasEntity, OneOf};
110110
pub use job::FailureInjector;
111111
#[doc(hidden)]
112112
pub use job::{
113-
ExponentialBackoff, FAIL_STOP_RECOVERY_TIMEOUT, RETRY_BACKOFF, Storage, on_terminal_failure,
114-
work,
113+
ExponentialBackoff, FAIL_STOP_RECOVERY_TIMEOUT, RETRY_BACKOFF, Storage, flush_jobs,
114+
on_terminal_failure, work,
115115
};
116-
pub use job::{Job, JobBackend, JobError, Label};
116+
pub use job::{HasJob, Job, JobBackend, JobError, JobList, JobQueue, Label, QueuePushError};
117117
use lifecycle::Lifecycle;
118118
pub use lifecycle::{LifecycleError, Never};
119119
pub use projection::{Column, Projection, ProjectionError, Table};

0 commit comments

Comments
 (0)