@@ -27,10 +27,13 @@ use apalis_sqlite::{CompactType, Config, SqlitePool, SqliteStorage};
2727use serde:: Serialize ;
2828use serde:: de:: DeserializeOwned ;
2929use std:: future:: Future ;
30+ use std:: marker:: PhantomData ;
3031use std:: sync:: Arc ;
3132use std:: time:: Duration ;
3233use 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+
303494fn 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}
0 commit comments