Implement Effect Pattern for Core APIs (ADR-033) #299

Closed
opened 2026-02-21 17:33:57 -08:00 by jwilger · 1 comment
jwilger commented 2026-02-21 17:33:57 -08:00 (Migrated from github.com)

Summary

Implement the Effect pattern described in ADR-033 (accepted 2026-02-17, PR #298). This refactors the core execute() and projection runner internals into pure state machines that yield effects, with thin shell loops that dispatch effects to backend traits.

Caller-Driven Effects (User-Facing)

Beyond the internal refactor, execute() should support user-defined effects that surface back to the caller. This enables commands that need external I/O (LLM calls, API requests, tool invocations) during execution while preserving atomicity.

Design Direction

execute() returns a three-variant enum:

enum Execution<Eff, EffResult> {
    Success(ExecutionResponse),
    Error(CommandError),
    Effect(EffectRequest<Eff, EffResult>),
}

EffectRequest bundles the effect description with a continuation:

impl<Eff, EffResult> EffectRequest<Eff, EffResult> {
    fn effect(&self) -> &Eff;
    async fn resume(self, result: EffResult) -> Execution<Eff, EffResult>;
}

The caller drives a loop, fulfilling effects and resuming execution:

let mut result = eventcore::execute(&store, command, policy).await;
loop {
    match result {
        Execution::Success(r) => break Ok(r),
        Execution::Error(e) => break Err(e),
        Execution::Effect(request) => {
            let effect_result = fulfill(request.effect()).await;
            result = request.resume(effect_result).await;
        }
    }
}

Commands declare effect types via new associated types on CommandLogic:

impl CommandLogic for MyCommand {
    type Effect = MyEffect;         // what the command can ask for
    type EffectResult = MyResult;   // what it gets back
    // ...
    fn handle(&self, state: Self::State) -> HandleStep<Self>;
    fn resume(&self, state: Self::State, result: Self::EffectResult) -> HandleStep<Self>;
}

Effect-free commands use Effect = () and never trigger the Effect variant — backward compatible, no loop needed.

Key Properties

  • Command stays pure — describes what it needs, never does I/O
  • Caller owns async — timeouts, cancellation, error handling around effects
  • Retry works — version conflict restarts the full cycle (re-read, re-handle, re-perform effects)
  • Effect types are app-defined — eventcore knows nothing about LLMs, HTTP, etc.

Internal Refactor Sub-Issues

  • Define effect types -- StoreEffect enum (ReadStream, AppendEvents) and ProjectionEffect enum (TryAcquireLock, LoadCheckpoint, SaveCheckpoint, ReadEvents, Sleep), plus result types. All pub(crate).
  • Extract ExecutePipeline state machine -- pure state machine from execute() internals (stream resolution, retry loop, append). Yields StoreEffect, accepts results via resume().
  • Create execute shell loop -- drives ExecutePipeline, dispatches effects to EventStore trait.
  • Extract ProjectionLoop state machine -- pure state machine from ProjectionRunner::run() internals (checkpoint, polling, event application, retry/skip/fatal).
  • Create run_projection shell loop -- dispatches ProjectionEffect to backend traits.
  • Add state machine unit tests -- canned effect responses, no trait implementations needed.
  • Verify existing integration tests pass unchanged.

Key Files

  • eventcore/src/lib.rs
  • eventcore/src/projection.rs
  • New eventcore/src/effects.rs
  • eventcore-types/src/command.rs (CommandLogic trait changes)
## Summary Implement the Effect pattern described in [ADR-033](docs/adr/033-effect-pattern-for-core-apis.md) (accepted 2026-02-17, PR #298). This refactors the core `execute()` and projection runner internals into pure state machines that yield effects, with thin shell loops that dispatch effects to backend traits. ## Caller-Driven Effects (User-Facing) Beyond the internal refactor, `execute()` should support **user-defined effects** that surface back to the caller. This enables commands that need external I/O (LLM calls, API requests, tool invocations) during execution while preserving atomicity. ### Design Direction `execute()` returns a three-variant enum: ```rust enum Execution<Eff, EffResult> { Success(ExecutionResponse), Error(CommandError), Effect(EffectRequest<Eff, EffResult>), } ``` `EffectRequest` bundles the effect description with a continuation: ```rust impl<Eff, EffResult> EffectRequest<Eff, EffResult> { fn effect(&self) -> &Eff; async fn resume(self, result: EffResult) -> Execution<Eff, EffResult>; } ``` The caller drives a loop, fulfilling effects and resuming execution: ```rust let mut result = eventcore::execute(&store, command, policy).await; loop { match result { Execution::Success(r) => break Ok(r), Execution::Error(e) => break Err(e), Execution::Effect(request) => { let effect_result = fulfill(request.effect()).await; result = request.resume(effect_result).await; } } } ``` Commands declare effect types via new associated types on `CommandLogic`: ```rust impl CommandLogic for MyCommand { type Effect = MyEffect; // what the command can ask for type EffectResult = MyResult; // what it gets back // ... fn handle(&self, state: Self::State) -> HandleStep<Self>; fn resume(&self, state: Self::State, result: Self::EffectResult) -> HandleStep<Self>; } ``` Effect-free commands use `Effect = ()` and never trigger the `Effect` variant — backward compatible, no loop needed. ### Key Properties - **Command stays pure** — describes what it needs, never does I/O - **Caller owns async** — timeouts, cancellation, error handling around effects - **Retry works** — version conflict restarts the full cycle (re-read, re-handle, re-perform effects) - **Effect types are app-defined** — eventcore knows nothing about LLMs, HTTP, etc. ## Internal Refactor Sub-Issues - [ ] Define effect types -- `StoreEffect` enum (ReadStream, AppendEvents) and `ProjectionEffect` enum (TryAcquireLock, LoadCheckpoint, SaveCheckpoint, ReadEvents, Sleep), plus result types. All `pub(crate)`. - [ ] Extract `ExecutePipeline` state machine -- pure state machine from `execute()` internals (stream resolution, retry loop, append). Yields `StoreEffect`, accepts results via `resume()`. - [ ] Create `execute` shell loop -- drives `ExecutePipeline`, dispatches effects to `EventStore` trait. - [ ] Extract `ProjectionLoop` state machine -- pure state machine from `ProjectionRunner::run()` internals (checkpoint, polling, event application, retry/skip/fatal). - [ ] Create `run_projection` shell loop -- dispatches `ProjectionEffect` to backend traits. - [ ] Add state machine unit tests -- canned effect responses, no trait implementations needed. - [ ] Verify existing integration tests pass unchanged. ## Related Issues - #272 - #276 - #237 ## Key Files - `eventcore/src/lib.rs` - `eventcore/src/projection.rs` - New `eventcore/src/effects.rs` - `eventcore-types/src/command.rs` (CommandLogic trait changes)
jwilger-ai-bot commented 2026-04-11 16:04:26 -07:00 (Migrated from github.com)

Closing this issue. The internal state machine refactor shipped in #349. The caller-driven effects feature was intentionally dropped after review (#348) — commands will receive all non-system-state inputs pre-calculated, and TOCTOU concerns with external systems are inherent regardless of framework-level mechanisms.

Closing this issue. The internal state machine refactor shipped in #349. The caller-driven effects feature was intentionally dropped after review (#348) — commands will receive all non-system-state inputs pre-calculated, and TOCTOU concerns with external systems are inherent regardless of framework-level mechanisms.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jwilger/eventcore#299
No description provided.