Skip to content

Commit 23d66f7

Browse files
committed
feat: replace EventSourced::Services with Jobs and wire atomic enqueue
1 parent 5e6527b commit 23d66f7

9 files changed

Lines changed: 390 additions & 171 deletions

File tree

crates/event-sorcery/src/dependency.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,11 @@ macro_rules! register_entities {
382382

383383
#[cfg(test)]
384384
mod tests {
385-
use async_trait::async_trait;
386385
use cqrs_es::DomainEvent;
387386
use serde::{Deserialize, Serialize};
388387

389388
use super::*;
389+
use crate::JobQueue;
390390

391391
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392392
struct Alpha {
@@ -411,13 +411,12 @@ mod tests {
411411
#[error("alpha error")]
412412
struct AlphaError;
413413

414-
#[async_trait]
415414
impl EventSourced for Alpha {
416415
type Id = String;
417416
type Event = AlphaEvent;
418417
type Command = ();
419418
type Error = AlphaError;
420-
type Services = ();
419+
type Jobs = Nil;
421420
type Materialized = Nil;
422421

423422
const AGGREGATE_TYPE: &'static str = "Alpha";
@@ -436,11 +435,18 @@ mod tests {
436435
Ok(None)
437436
}
438437

439-
async fn initialize((): (), (): &()) -> Result<Vec<AlphaEvent>, AlphaError> {
438+
fn initialize(
439+
(): (),
440+
_jobs: &mut JobQueue<Self::Jobs>,
441+
) -> Result<Vec<AlphaEvent>, AlphaError> {
440442
Ok(vec![AlphaEvent::Born])
441443
}
442444

443-
async fn transition(&self, (): (), (): &()) -> Result<Vec<AlphaEvent>, AlphaError> {
445+
fn transition(
446+
&self,
447+
(): (),
448+
_jobs: &mut JobQueue<Self::Jobs>,
449+
) -> Result<Vec<AlphaEvent>, AlphaError> {
444450
Ok(vec![])
445451
}
446452
}
@@ -468,13 +474,12 @@ mod tests {
468474
#[error("beta error")]
469475
struct BetaError;
470476

471-
#[async_trait]
472477
impl EventSourced for Beta {
473478
type Id = String;
474479
type Event = BetaEvent;
475480
type Command = ();
476481
type Error = BetaError;
477-
type Services = ();
482+
type Jobs = Nil;
478483
type Materialized = Nil;
479484

480485
const AGGREGATE_TYPE: &'static str = "Beta";
@@ -491,11 +496,18 @@ mod tests {
491496
Ok(None)
492497
}
493498

494-
async fn initialize((): (), (): &()) -> Result<Vec<BetaEvent>, BetaError> {
499+
fn initialize(
500+
(): (),
501+
_jobs: &mut JobQueue<Self::Jobs>,
502+
) -> Result<Vec<BetaEvent>, BetaError> {
495503
Ok(vec![BetaEvent::Spawned])
496504
}
497505

498-
async fn transition(&self, (): (), (): &()) -> Result<Vec<BetaEvent>, BetaError> {
506+
fn transition(
507+
&self,
508+
(): (),
509+
_jobs: &mut JobQueue<Self::Jobs>,
510+
) -> Result<Vec<BetaEvent>, BetaError> {
499511
Ok(vec![])
500512
}
501513
}

crates/event-sorcery/src/job.rs

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use std::future::Future;
3030
use std::marker::PhantomData;
3131
use std::sync::Arc;
3232
use std::time::Duration;
33-
use tracing::{debug, error};
33+
use tracing::{debug, error, warn};
3434

3535
use crate::dependency::{Cons, Nil};
3636

@@ -82,7 +82,10 @@ pub trait Job: Serialize + DeserializeOwned + Send + 'static {
8282
/// Built from [`Cons`](crate::Cons) / [`Nil`](crate::Nil) -- the same
8383
/// cells [`deps!`](crate::deps) uses for reactor dependencies -- via
8484
/// the [`jobs!`](crate::jobs) macro: `jobs![SendEmail, ChargeCard]`.
85-
pub trait JobList {}
85+
///
86+
/// `Send` so the [`JobQueue`] threads through the framework's `Send`
87+
/// command future.
88+
pub trait JobList: Send {}
8689

8790
impl JobList for Nil {}
8891

@@ -455,22 +458,19 @@ macro_rules! build_supervised_worker {
455458
}};
456459
}
457460

458-
/// Drains a [`JobQueue`] into apalis's `Jobs` table through `connection`.
461+
/// Writes buffered pushes into apalis's `Jobs` table through `connection`.
459462
///
460-
/// Called by the framework inside the event-commit transaction so a
461-
/// job becomes visible to the worker iff its triggering events commit.
462463
/// Rows are written in apalis's storage format -- JSON-encoded payload,
463464
/// `Pending` status, the job type as the queue name -- so the worker's
464465
/// fetcher picks them up.
465-
#[doc(hidden)]
466-
pub async fn flush_jobs<Jobs: JobList>(
466+
async fn insert_pending(
467467
connection: &mut sqlx::SqliteConnection,
468-
queue: JobQueue<Jobs>,
468+
pending: Vec<PendingPush>,
469469
) -> 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 {
470+
for push in pending {
471+
let job_type = push.job.job_type();
472+
let payload = push.job.encode()?;
473+
let delay_secs = match push.delay {
474474
None => 0_i64,
475475
Some(delay) => i64::try_from(delay.as_secs())?,
476476
};
@@ -491,6 +491,72 @@ pub async fn flush_jobs<Jobs: JobList>(
491491
Ok(())
492492
}
493493

494+
/// Drains a [`JobQueue`] into apalis's `Jobs` table through `connection`.
495+
///
496+
/// Called by the framework inside the event-commit transaction so a job
497+
/// becomes visible to the worker iff its triggering events commit.
498+
#[doc(hidden)]
499+
pub async fn flush_jobs<Jobs: JobList>(
500+
connection: &mut sqlx::SqliteConnection,
501+
queue: JobQueue<Jobs>,
502+
) -> Result<(), QueuePushError> {
503+
insert_pending(connection, queue.into_pending()).await
504+
}
505+
506+
tokio::task_local! {
507+
/// Per-command buffer of jobs awaiting flush. Scoped by
508+
/// [`with_pending_jobs`] around command execution, populated by the
509+
/// [`Lifecycle`](crate::Lifecycle) handler, and drained by the event
510+
/// repository inside its commit transaction.
511+
static PENDING_JOBS: std::cell::RefCell<Vec<PendingPush>>;
512+
}
513+
514+
/// Runs `future` with a fresh per-command pending-jobs buffer in scope, so
515+
/// the jobs a handler enqueues are flushed in the same transaction that
516+
/// commits its events.
517+
pub(crate) async fn with_pending_jobs<F>(future: F) -> F::Output
518+
where
519+
F: Future,
520+
{
521+
PENDING_JOBS
522+
.scope(std::cell::RefCell::new(Vec::new()), future)
523+
.await
524+
}
525+
526+
/// Buffers a handler's [`JobQueue`] for the framework to flush at commit.
527+
///
528+
/// A no-op when the queue is empty. If jobs are present but no
529+
/// [`with_pending_jobs`] scope is active (a programming error -- handlers
530+
/// run inside [`Store::send`](crate::Store::send)), they are dropped with a
531+
/// warning rather than silently lost.
532+
pub(crate) fn buffer_pending<Jobs: JobList>(queue: JobQueue<Jobs>) {
533+
let pending = queue.into_pending();
534+
if pending.is_empty() {
535+
return;
536+
}
537+
538+
let count = pending.len();
539+
if PENDING_JOBS
540+
.try_with(move |cell| cell.borrow_mut().extend(pending))
541+
.is_err()
542+
{
543+
warn!(target: "job", count, "jobs enqueued outside a command scope were dropped");
544+
}
545+
}
546+
547+
/// Flushes the current command's buffered jobs through `connection`.
548+
///
549+
/// Called by the event repository inside its commit transaction; a no-op
550+
/// when no scope is active or nothing was buffered.
551+
pub(crate) async fn flush_pending_jobs(
552+
connection: &mut sqlx::SqliteConnection,
553+
) -> Result<(), QueuePushError> {
554+
let pending = PENDING_JOBS
555+
.try_with(|cell| std::mem::take(&mut *cell.borrow_mut()))
556+
.unwrap_or_default();
557+
insert_pending(connection, pending).await
558+
}
559+
494560
fn build_poll_config<J: 'static>() -> Config {
495561
let strategy = StrategyBuilder::new()
496562
.apply(

0 commit comments

Comments
 (0)