Skip to content

Commit a7d1b09

Browse files
0xglebcursoragent
andcommitted
docs: migrate jobs model docs
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2e99de9 commit a7d1b09

6 files changed

Lines changed: 181 additions & 79 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ the limit:
2828
**Read when relevant** to your task:
2929

3030
- [docs/cqrs.md](docs/cqrs.md) - Event sourcing patterns: the `EventSourced`
31-
trait, `Lifecycle` adapter, projections, services, schema registry.
31+
trait, `Lifecycle` adapter, projections, jobs, schema registry.
3232
- [docs/sqlx.md](docs/sqlx.md) - `SQLX_OFFLINE`, `query!` vs runtime queries,
3333
regenerating the `.sqlx/` cache, common pitfalls.
3434
- [docs/ttdd.md](docs/ttdd.md) - Type-driven TDD workflow: scientific method
@@ -219,7 +219,7 @@ workspace has two crates and no application binaries:
219219
repository traits. Standalone; usable wherever a `cqrs-es` backend is needed.
220220
- **`crates/event-sorcery`** — higher-level ergonomics on top of `sqlite-es`:
221221
the `EventSourced` trait, `Lifecycle` adapter, typed `Store`, projections,
222-
schema registry, reactor.
222+
schema registry, reactor, and apalis-backed jobs.
223223

224224
The `migrations/` directory at the workspace root holds the canonical SQLite
225225
schema (events + snapshots tables) that `sqlite_es::testing::create_test_pool`

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ explaining what it covers. Run any of them with
3535
- [`docs/domain.md`](docs/domain.md) — domain terminology and naming
3636
conventions.
3737
- [`docs/cqrs.md`](docs/cqrs.md) — event-sourcing patterns, the `EventSourced`
38-
trait, projections, services, schema registry.
38+
trait, projections, jobs, schema registry.
3939
- [`docs/sqlx.md`](docs/sqlx.md)`SQLX_OFFLINE`, `query!` vs runtime queries,
4040
regenerating the query cache.
4141
- [`docs/ttdd.md`](docs/ttdd.md) — type-driven TDD methodology used in this

SPEC.md

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ everything needed to event-source the type:
8686
- `Event` — domain event type (must implement `DomainEvent`)
8787
- `Command` — input that drives state transitions
8888
- `Error` — domain failure type (`Never` if everything is infallible)
89-
- `Services` — external dependencies passed into command handlers
89+
- `Jobs` — type-level list (`JobList`) of durable job types this entity's
90+
handlers may enqueue; `Nil` when none
9091
- `Materialized``Table` if the entity has a SQLite-backed projection, `Nil`
9192
otherwise
9293
- `AGGREGATE_TYPE` — stable string identifier used in the event store
@@ -98,7 +99,11 @@ It splits behavior across two pairs:
9899
derives new state from subsequent events. Both are fallible.
99100
- Command-side: `initialize` handles a command when no state exists yet;
100101
`transition` handles a command against existing state. The split prevents
101-
accidentally reading "current state" while bootstrapping.
102+
accidentally reading "current state" while bootstrapping. Both are synchronous
103+
and pure — they compute `Vec<Event>` and may enqueue durable jobs through a
104+
`&mut JobQueue<Self::Jobs>`, but perform no I/O. External reads a handler once
105+
did through `Services` (clock, config, idempotency keys) are now supplied
106+
through the `Command`.
102107

103108
### `Lifecycle` adapter
104109

@@ -138,6 +143,32 @@ Side-effect handler keyed off events. Used for cross-aggregate orchestration
138143
(e.g., one aggregate's event triggers a command on another). Has automatic
139144
retry-with-backoff on optimistic-lock conflicts.
140145

146+
### Jobs
147+
148+
Durable, retryable side-effect work. Handlers stay pure by enqueuing jobs
149+
instead of calling external systems directly:
150+
151+
- `Job` — a serializable unit of work. Declares its dependency bundle (`Input`),
152+
`Output`, and `Error`, a `perform(&self, &Input)` body, and constants for
153+
worker naming, kind, and terminal-failure logging.
154+
- `JobList` / `HasJob<J>` — a type-level list (`Cons` / `Nil`) of the job types
155+
an entity may dispatch. `JobQueue::push::<J>` is bounded
156+
`where Jobs:
157+
HasJob<J>`, so a handler can enqueue only the jobs its entity
158+
declares — checked at compile time. `jobs![A, B]` is sugar for the list.
159+
- `JobQueue<Jobs>` — the handler-facing handle. `push` / `push_with_delay` are
160+
synchronous and buffer onto the handle; the framework drains the buffer inside
161+
the event-commit transaction (see "Job dispatch").
162+
- `JobBackend<J>` — apalis-backed `SqliteStorage` for one job type, built once
163+
at startup and owned by the worker side.
164+
- `build_supervised_worker!` — wires a worker with the shared retry policy,
165+
exponential backoff, circuit breaker, and terminal-failure notifier, so every
166+
consumer gets identical execution semantics.
167+
168+
apalis (`apalis`, `apalis-sqlite`, `apalis-core`, `apalis-codec`) is the
169+
non-negotiable queue backend; it is part of event-sorcery's public surface, not
170+
a feature flag.
171+
141172
### `SchemaRegistry`
142173

143174
Tracks `(aggregate_type, schema_version)` tuples in a `schema_registry` table.
@@ -166,12 +197,31 @@ projection becomes a type error, not silent data staleness.
166197
2. `Store` looks up the aggregate, loads its `Lifecycle`, applies any relevant
167198
snapshot, replays uncached events.
168199
3. `Lifecycle::handle` routes to `EventSourced::initialize` (no state) or
169-
`EventSourced::transition` (has state) and produces events.
170-
4. `cqrs-es::CqrsFramework` persists events with monotonic sequence numbers in
171-
the same SQL transaction as any side-effects implemented via cqrs-es
172-
`Service`s.
200+
`EventSourced::transition` (has state) and produces events. The handler also
201+
buffers any job pushes onto its `JobQueue` handle.
202+
4. `cqrs-es::CqrsFramework` persists events with monotonic sequence numbers, and
203+
the framework drains the buffered `JobQueue` into the apalis `Jobs` table —
204+
both in the same SQL transaction, so events and their jobs commit or roll
205+
back together.
173206
5. Reactors registered on this aggregate are notified.
174207

208+
### Job dispatch
209+
210+
Command handlers never touch external systems. They enqueue work onto the
211+
`JobQueue<Self::Jobs>` handle the framework hands them; `push` is synchronous
212+
and only buffers. The framework flushes the buffer with a transaction-bound
213+
`INSERT` into the apalis `Jobs` table inside the same transaction that persists
214+
the events (write-path step 4). A crash before commit loses both the events and
215+
the jobs; a crash after commit leaves a durable job row the worker will pick up.
216+
This closes the window where a side effect fires but no event records it.
217+
Because the push is buffered, a queued row is never observable mid-handler.
218+
219+
A separate apalis worker — wired with `build_supervised_worker!` — polls the
220+
table, runs each `Job::perform`, retries with exponential backoff, and trips a
221+
circuit breaker on sustained failure. Because the worker retries on failure and
222+
re-runs a job after a crash, jobs are delivered at-least-once: `Job::perform`
223+
must be idempotent, with any dedup key carried in the job payload or `Input`.
224+
175225
### Read path
176226

177227
Consumers query via `Projection::load(...)`, never by replaying events

docs/cqrs.md

Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,12 @@ pub enum MyEntityCommand {
6969
### 3. Implement EventSourced
7070

7171
```rust
72-
#[async_trait]
7372
impl EventSourced for MyEntity {
7473
type Id = MyEntityId; // strongly-typed, Display + FromStr
7574
type Event = MyEntityEvent;
7675
type Command = MyEntityCommand;
7776
type Error = Never; // or a thiserror type
78-
type Services = (); // or Arc<dyn SomeService>
77+
type Jobs = Nil; // or jobs![SendEmail, ChargeCard]
7978
type Materialized = Table; // Table for projected, Nil for non-projected
8079

8180
const AGGREGATE_TYPE: &'static str = "MyEntity";
@@ -87,10 +86,10 @@ impl EventSourced for MyEntity {
8786
fn evolve(entity: &Self, event: &Self::Event)
8887
-> Result<Option<Self>, Self::Error> { /* ... */ }
8988

90-
// Command-side: process commands to produce events
91-
async fn initialize(command: Self::Command, services: &Self::Services)
89+
// Command-side: sync, pure -- produce events, enqueue jobs
90+
fn initialize(command: Self::Command, jobs: &mut JobQueue<Self::Jobs>)
9291
-> Result<Vec<Self::Event>, Self::Error> { /* ... */ }
93-
async fn transition(&self, command: Self::Command, services: &Self::Services)
92+
fn transition(&self, command: Self::Command, jobs: &mut JobQueue<Self::Jobs>)
9493
-> Result<Vec<Self::Event>, Self::Error> { /* ... */ }
9594
}
9695
```
@@ -243,32 +242,73 @@ receives `(error, id, event)` and can reprocess the event from the errored state
243242

244243
Wire reactors via `Unwired` + `StoreBuilder::wire()`.
245244

246-
## Services Pattern
245+
## Jobs Pattern
246+
247+
Handlers are sync and pure: they must not call external systems. Side-effecting
248+
work is enqueued as a durable job and runs in a separate worker. The enqueue
249+
commits in the same SQLite transaction as the events that triggered it, so a job
250+
exists iff its events do.
251+
252+
The worker retries `perform` on failure and re-runs it after a crash, so jobs
253+
are delivered **at-least-once** -- `perform` must be idempotent. Carry any dedup
254+
key in the job payload or `Input`.
255+
256+
Declare a job and the entity's job list:
257+
258+
```rust
259+
#[derive(Serialize, Deserialize)]
260+
struct PlaceOrder { order_id: OrderId, quantity: u32 }
261+
262+
impl Job for PlaceOrder {
263+
type Input = Arc<dyn OrderPlacer>; // dependency bundle, injected by the worker
264+
type Output = ();
265+
type Error = PlaceOrderError;
247266

248-
Inject external dependencies into command handlers:
267+
const WORKER_NAME: &'static str = "place-order";
268+
const KIND: &'static str = "PlaceOrder";
269+
270+
fn label(&self) -> Label { Label(format!("place-order:{}", self.order_id)) }
271+
272+
async fn perform(&self, placer: &Self::Input) -> Result<(), Self::Error> {
273+
placer.place_order(self.order_id, self.quantity).await
274+
}
275+
}
276+
```
249277

250278
```rust
251-
type Services = Arc<dyn OrderPlacer>;
279+
type Jobs = jobs![PlaceOrder]; // Nil if the entity dispatches none
280+
```
252281

253-
async fn transition(
282+
Enqueue from a handler through the `jobs` handle. `push` is synchronous and
283+
buffers; the framework flushes the buffer at commit. `push::<J>` only compiles
284+
when `J` is in the entity's `Jobs` list (`HasJob<J>`):
285+
286+
```rust
287+
fn transition(
254288
&self,
255289
command: Self::Command,
256-
services: &Self::Services,
290+
jobs: &mut JobQueue<Self::Jobs>,
257291
) -> Result<Vec<Self::Event>, Self::Error> {
258-
let result = services.place_order(/* ... */).await?;
259-
Ok(vec![MyEvent::OrderPlaced { /* ... */ }])
292+
jobs.push(PlaceOrder { order_id: self.id, quantity: command.quantity });
293+
Ok(vec![MyEvent::OrderRequested { /* ... */ }])
260294
}
261295
```
262296

263-
Pass services when building the `Store`:
297+
A queued row is never observable mid-handler -- the push is buffered, not a live
298+
`INSERT`. Reads a handler once did via `Services` (clock, config, idempotency
299+
keys) move into the `Command`; the caller supplies them at dispatch time.
300+
301+
Run the jobs in a worker, wired with the shared retry/backoff/circuit-breaker
302+
policy. `Input` is the only context handed in:
264303

265304
```rust
266-
let store = StoreBuilder::<MyEntity>::new(pool)
267-
.build(services)
268-
.await?;
269-
```
305+
let placer: Arc<dyn OrderPlacer> = Arc::new(/* ... */);
306+
let backend = JobBackend::<PlaceOrder>::new(&pool);
270307

271-
For entities that don't need services, use `type Services = ()`.
308+
Monitor::new().register(build_supervised_worker!(
309+
::<PlaceOrder>, 0, backend.into_storage(), placer, fail_stop, failure_notify,
310+
));
311+
```
272312

273313
## Schema Versioning
274314

@@ -640,38 +680,31 @@ the same result.
640680
**Call replay at startup** to ensure views are up-to-date with any schema
641681
changes.
642682

643-
## Services Pattern
683+
## Jobs and Crash Safety
644684

645-
Domain types can depend on external services (APIs, blockchain, etc.) via the
646-
`Services` associated type on `EventSourced`:
685+
The reason handlers enqueue jobs instead of calling out directly: a job's
686+
enqueue and its triggering events commit in one SQLite transaction. There is no
687+
window where a side effect fires but no event records it, and none where an
688+
event commits but the follow-up work is lost.
647689

648690
```rust
649-
#[async_trait]
650-
impl EventSourced for MyEntity {
651-
type Services = Arc<dyn MyService>; // or () if none needed
652-
653-
async fn transition(
654-
&self,
655-
command: Self::Command,
656-
services: &Self::Services,
657-
) -> Result<Vec<Self::Event>, Self::Error> {
658-
let result = services.do_something().await?;
659-
Ok(vec![MyEvent::SomethingDone { result }])
660-
}
661-
// ...
691+
fn transition(
692+
&self,
693+
command: Self::Command,
694+
jobs: &mut JobQueue<Self::Jobs>,
695+
) -> Result<Vec<Self::Event>, Self::Error> {
696+
jobs.push(SettlePayment { id: self.id }); // buffered, not yet persisted
697+
Ok(vec![MyEvent::PaymentRequested { /* ... */ }])
662698
}
663699
```
664700

665-
Pass services when building the `Store`:
666-
667-
```rust
668-
let services: Arc<dyn MyService> = Arc::new(MyServiceImpl::new());
669-
let store = StoreBuilder::<MyEntity>::new(pool)
670-
.build(services)
671-
.await?;
672-
```
701+
`push` only buffers; the framework drains the buffer with a transaction-bound
702+
`INSERT` into the apalis `Jobs` table alongside the event write. On rollback,
703+
neither is visible. A worker (`build_supervised_worker!`) then polls the table
704+
and runs `Job::perform` with retries and a circuit breaker. See
705+
[Jobs Pattern](#jobs-pattern) for the full `Job` impl and worker wiring.
673706

674-
For entities that don't need services, use `type Services = ()`.
707+
For entities that dispatch no jobs, use `type Jobs = Nil`.
675708

676709
## Testing Aggregates
677710

docs/domain.md

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,14 @@ never mutate state directly.
5757

5858
`event-sorcery` splits command handling into two methods:
5959

60-
- `EventSourced::initialize(command, services)` — for aggregates that don't yet
60+
- `EventSourced::initialize(command, jobs)` — for aggregates that don't yet
6161
exist. No `&self`, so handlers can't accidentally read state during creation.
62-
- `EventSourced::transition(&self, command, services)` — for live aggregates.
62+
- `EventSourced::transition(&self, command, jobs)` — for live aggregates.
6363
Receives the domain type, never the wrapping `Lifecycle`.
6464

65+
Both handlers are synchronous and pure: they compute events and may enqueue jobs
66+
through the `jobs` handle, but perform no I/O.
67+
6568
### Event Application
6669

6770
The pure function that derives new aggregate state from old state plus an event.
@@ -124,11 +127,20 @@ of state, events, or projections changes. Compared against the persisted version
124127
in the `schema_registry` table on startup; mismatches clear stale snapshots and
125128
trigger view rebuilds.
126129

127-
### Service
130+
### Job
131+
132+
A durable, retryable unit of side-effect work (`trait Job`). Command handlers
133+
enqueue jobs instead of calling external systems directly, which keeps handlers
134+
pure. Each job declares its dependency bundle (`Input`), `Output`, and `Error`,
135+
plus a `perform` body. An entity lists the job types its handlers may dispatch
136+
via `EventSourced::Jobs` (a `JobList`; `Nil` when none).
137+
138+
### Job Queue
128139

129-
External dependency injected into command handlers (`EventSourced::Services`).
130-
Used so a handler can produce side-effects (e.g., enqueue work) atomically with
131-
event persistence. `()` when no services are needed.
140+
The handler-facing handle (`JobQueue<Jobs>`) passed to `initialize` /
141+
`transition`. `push` is synchronous and buffers the job; the framework flushes
142+
the buffer into the apalis `Jobs` table in the same transaction that persists
143+
the triggering events, so a job is enqueued iff its events commit.
132144

133145
### Lifecycle
134146

@@ -180,18 +192,19 @@ Error variants store domain types, not opaque strings. Prefer
180192
`InvalidSymbol(Symbol)` over `InvalidSymbol(String)`. The compiler then prevents
181193
the caller from accidentally formatting the symbol away too early.
182194

183-
### CQRS Aggregate Services
195+
### Job naming
184196

185-
When an aggregate needs to perform side-effects atomically with persistence, the
186-
cqrs-es Service pattern applies. Naming convention used across consumer code:
197+
When an aggregate needs a side-effect, it enqueues a job rather than calling out
198+
from the handler. Naming convention:
187199

188-
- **`{Action}er`** — the trait describing the capability (`OrderPlacer`).
189-
- **`{Domain}Service`** — the implementation (`OrderPlacementService`).
190-
- **`{Domain}Manager`** — the orchestration layer that drives commands through
191-
the framework (`OrderManager`).
200+
- **`{Action}`** — the job type, named for the work it performs (`SendWelcome`,
201+
`ChargeCard`). No generic `...Job` suffix.
202+
- **`{Job}Input`** — the dependency bundle the worker injects into `perform`
203+
(HTTP clients, config, connection handles), exposed as `Job::Input`.
192204

193-
This convention is library-imposed, not framework-enforced, but the `cqrs-es`
194-
`Service` parameter on aggregate `handle` is what makes it work.
205+
Jobs declared by an entity are listed in `EventSourced::Jobs` via `jobs![..]`.
206+
The framework enqueues them atomically with the triggering events; the
207+
consumer's worker wiring (`build_supervised_worker!`) runs them.
195208

196209
### Refactoring completeness
197210

0 commit comments

Comments
 (0)