Skip to main content

runifold_effect/
executor.rs

1use std::sync::Arc;
2
3use futures_util::future::{Either, select};
4use runifold_core::{
5    EffectClass, EffectEvent, EffectId, EffectRequest, Instant, RunContext, RunError, RunEventKind,
6};
7use serde_json::Value;
8
9use crate::{
10    EffectExecutionContext, EffectExecutorError, EffectExecutorErrorKind, EffectHandler,
11    EffectRecord, EffectStatus, EffectStore,
12};
13
14/// Recovery behavior for a record whose handler may have executed.
15#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16#[non_exhaustive]
17pub enum EffectRecoveryPolicy {
18    /// Never retry an ambiguous started effect automatically.
19    #[default]
20    RejectAmbiguous,
21    /// Retry only effects whose class and idempotency contract make it safe.
22    RetrySafe,
23}
24
25/// Controls whether effect outputs are copied into Journal events.
26#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
27#[non_exhaustive]
28pub enum EffectEventPayloadPolicy {
29    /// Keep durable output only in the `EffectStore`.
30    #[default]
31    Redacted,
32    /// Copy the complete output into `EffectEvent::Completed`.
33    Full,
34}
35
36/// Successful coordinated effect result.
37#[derive(Clone, Debug, PartialEq)]
38pub struct EffectOutcome {
39    /// Canonical logical effect identity.
40    pub effect_id: EffectId,
41    /// Canonical output.
42    pub output: Value,
43    /// Whether the output came from a completed durable record.
44    pub replayed: bool,
45    /// Durable record revision containing the output.
46    pub revision: u64,
47}
48
49/// Capability-gated write-ahead external-effect coordinator.
50#[derive(Clone)]
51pub struct EffectExecutor {
52    store: Arc<dyn EffectStore>,
53    event_payload_policy: EffectEventPayloadPolicy,
54}
55
56impl EffectExecutor {
57    /// Creates an executor using the given durable-state boundary.
58    pub fn new(store: Arc<dyn EffectStore>) -> Self {
59        Self {
60            store,
61            event_payload_policy: EffectEventPayloadPolicy::Redacted,
62        }
63    }
64
65    /// Sets explicit Journal output-capture behavior.
66    #[must_use]
67    pub const fn with_event_payload_policy(mut self, policy: EffectEventPayloadPolicy) -> Self {
68        self.event_payload_policy = policy;
69        self
70    }
71
72    /// Executes or recovers one logical effect.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`EffectExecutorError`] for authority, persistence, lifecycle,
77    /// ambiguity, handler, or observability failures.
78    pub async fn execute(
79        &self,
80        request: EffectRequest,
81        run: &RunContext,
82        handler: &dyn EffectHandler,
83        recovery: EffectRecoveryPolicy,
84    ) -> Result<EffectOutcome, EffectExecutorError> {
85        preflight(&request, run)?;
86        let (record, created) = self.resolve(request)?;
87        if created {
88            record_event(
89                run,
90                RunEventKind::Effect(EffectEvent::Requested {
91                    effect_id: record.request.effect_id,
92                }),
93            )?;
94        }
95        match &record.status {
96            EffectStatus::Completed { output } => Ok(EffectOutcome {
97                effect_id: record.request.effect_id,
98                output: output.clone(),
99                replayed: true,
100                revision: record.revision,
101            }),
102            EffectStatus::Failed { error } => Err(EffectExecutorError::handler(error.clone())),
103            EffectStatus::Prepared => self.start(record, run, handler).await,
104            EffectStatus::Started => {
105                if recovery != EffectRecoveryPolicy::RetrySafe || !retry_safe(&record.request) {
106                    return Err(EffectExecutorError::new(
107                        EffectExecutorErrorKind::Ambiguous,
108                        format!(
109                            "effect `{}` may already have executed",
110                            record.request.effect_id
111                        ),
112                    ));
113                }
114                self.start(record, run, handler).await
115            }
116        }
117    }
118
119    fn resolve(&self, request: EffectRequest) -> Result<(EffectRecord, bool), EffectExecutorError> {
120        if let Some(record) = self.store.load(request.effect_id)? {
121            validate_same_effect(&record.request, &request)?;
122            return Ok((record, false));
123        }
124        if let Some(key) = &request.idempotency_key
125            && let Some(record) = self.store.find_by_idempotency(request.capability_id, key)?
126        {
127            validate_same_effect(&record.request, &request)?;
128            return Ok((record, false));
129        }
130        let record = EffectRecord::prepared(request);
131        self.store.compare_and_swap(&record, None)?;
132        Ok((record, true))
133    }
134
135    async fn start(
136        &self,
137        record: EffectRecord,
138        run: &RunContext,
139        handler: &dyn EffectHandler,
140    ) -> Result<EffectOutcome, EffectExecutorError> {
141        let started = record.next(EffectStatus::Started)?;
142        self.store
143            .compare_and_swap(&started, Some(record.revision))?;
144        record_event(
145            run,
146            RunEventKind::Effect(EffectEvent::Started {
147                effect_id: started.request.effect_id,
148            }),
149        )?;
150
151        let context = EffectExecutionContext::for_run(run);
152        let cancellation = context.cancellation().clone();
153        let execution = handler.execute(&started.request, context);
154        let result = match select(Box::pin(cancellation.cancelled()), Box::pin(execution)).await {
155            Either::Left(_) => {
156                return Err(EffectExecutorError::new(
157                    EffectExecutorErrorKind::Cancelled,
158                    "effect execution was cancelled and remains ambiguous",
159                ));
160            }
161            Either::Right((result, _)) => result,
162        };
163
164        match result {
165            Ok(output) => self.complete(&started, output, run),
166            Err(error) => self.fail(&started, error, run),
167        }
168    }
169
170    fn complete(
171        &self,
172        started: &EffectRecord,
173        output: Value,
174        run: &RunContext,
175    ) -> Result<EffectOutcome, EffectExecutorError> {
176        let completed = started.next(EffectStatus::Completed {
177            output: output.clone(),
178        })?;
179        self.store
180            .compare_and_swap(&completed, Some(started.revision))?;
181        record_event(
182            run,
183            RunEventKind::Effect(EffectEvent::Completed {
184                effect_id: completed.request.effect_id,
185                output: match self.event_payload_policy {
186                    EffectEventPayloadPolicy::Redacted => {
187                        serde_json::json!({"runifold": {"content_recorded": false}})
188                    }
189                    EffectEventPayloadPolicy::Full => output.clone(),
190                },
191            }),
192        )?;
193        Ok(EffectOutcome {
194            effect_id: completed.request.effect_id,
195            output,
196            replayed: false,
197            revision: completed.revision,
198        })
199    }
200
201    fn fail(
202        &self,
203        started: &EffectRecord,
204        error: RunError,
205        run: &RunContext,
206    ) -> Result<EffectOutcome, EffectExecutorError> {
207        let failed = started.next(EffectStatus::Failed {
208            error: error.clone(),
209        })?;
210        self.store
211            .compare_and_swap(&failed, Some(started.revision))?;
212        record_event(
213            run,
214            RunEventKind::Effect(EffectEvent::Failed {
215                effect_id: failed.request.effect_id,
216                error: error.clone(),
217            }),
218        )?;
219        Err(EffectExecutorError::handler(error))
220    }
221}
222
223impl std::fmt::Debug for EffectExecutor {
224    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        formatter.write_str("EffectExecutor(..)")
226    }
227}
228
229fn preflight(request: &EffectRequest, run: &RunContext) -> Result<(), EffectExecutorError> {
230    if !run.capabilities().contains(request.capability_id) {
231        return Err(EffectExecutorError::new(
232            EffectExecutorErrorKind::CapabilityDenied,
233            "Run is not granted the effect capability",
234        ));
235    }
236    if run.cancellation().is_cancelled() {
237        return Err(EffectExecutorError::new(
238            EffectExecutorErrorKind::Cancelled,
239            "effect was cancelled before preparation",
240        ));
241    }
242    if run
243        .deadline()
244        .is_some_and(|deadline| deadline <= Instant::now())
245    {
246        return Err(EffectExecutorError::new(
247            EffectExecutorErrorKind::DeadlineExceeded,
248            "effect deadline elapsed before preparation",
249        ));
250    }
251    Ok(())
252}
253
254fn validate_same_effect(
255    existing: &EffectRequest,
256    requested: &EffectRequest,
257) -> Result<(), EffectExecutorError> {
258    let same = existing.kind == requested.kind
259        && existing.capability_id == requested.capability_id
260        && existing.input == requested.input
261        && existing.effect_class == requested.effect_class
262        && existing.idempotency_key == requested.idempotency_key;
263    if !same {
264        return Err(EffectExecutorError::new(
265            EffectExecutorErrorKind::IdempotencyConflict,
266            "effect identity or idempotency key was reused for different work",
267        ));
268    }
269    Ok(())
270}
271
272fn retry_safe(request: &EffectRequest) -> bool {
273    matches!(
274        request.effect_class,
275        EffectClass::Pure | EffectClass::ReadOnly
276    ) || matches!(request.effect_class, EffectClass::IdempotentWrite)
277        && request.idempotency_key.is_some()
278}
279
280fn record_event(run: &RunContext, kind: RunEventKind) -> Result<(), EffectExecutorError> {
281    run.record(kind, None)?;
282    Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287    use std::{
288        collections::BTreeMap,
289        sync::{
290            Arc,
291            atomic::{AtomicUsize, Ordering},
292        },
293    };
294
295    use runifold_core::{
296        Budget, BudgetTracker, CapabilityDescriptor, CapabilityId, CapabilityKind, CapabilitySet,
297        EffectClass, EffectEvent, EffectId, EffectKind, EffectRequest, InMemoryJournal,
298        InvocationId, RiskLevel, RunContext, RunError, RunEventKind,
299    };
300    use serde_json::{Value, json};
301
302    use crate::{
303        EffectEventPayloadPolicy, EffectExecutionContext, EffectExecutor, EffectExecutorErrorKind,
304        EffectFuture, EffectHandler, EffectRecord, EffectRecoveryPolicy, EffectStatus, EffectStore,
305        InMemoryEffectStore,
306    };
307
308    struct CountingHandler {
309        calls: AtomicUsize,
310    }
311
312    impl CountingHandler {
313        fn new() -> Self {
314            Self {
315                calls: AtomicUsize::new(0),
316            }
317        }
318    }
319
320    impl EffectHandler for CountingHandler {
321        fn execute(
322            &self,
323            request: &EffectRequest,
324            _context: EffectExecutionContext,
325        ) -> EffectFuture<'_, Result<Value, RunError>> {
326            self.calls.fetch_add(1, Ordering::SeqCst);
327            let input = request.input.clone();
328            Box::pin(async move { Ok(json!({"echo": input})) })
329        }
330    }
331
332    #[test]
333    fn completed_idempotent_effect_is_replayed_without_handler_execution() {
334        let capability = capability(EffectClass::IdempotentWrite);
335        let journal = InMemoryJournal::new();
336        let run = run_with(&capability, Some(journal.clone()));
337        let store = Arc::new(InMemoryEffectStore::new());
338        let executor = EffectExecutor::new(store);
339        let handler = CountingHandler::new();
340        let first_request = request(&capability, Some("order-7"), json!({"value": 7}));
341
342        let first = futures_executor::block_on(executor.execute(
343            first_request.clone(),
344            &run,
345            &handler,
346            EffectRecoveryPolicy::RejectAmbiguous,
347        ))
348        .unwrap();
349        let mut duplicate = first_request;
350        duplicate.effect_id = EffectId::new();
351        duplicate.invocation_id = InvocationId::new();
352        let second = futures_executor::block_on(executor.execute(
353            duplicate,
354            &run,
355            &handler,
356            EffectRecoveryPolicy::RejectAmbiguous,
357        ))
358        .unwrap();
359
360        assert!(!first.replayed);
361        assert!(second.replayed);
362        assert_eq!(second.effect_id, first.effect_id);
363        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
364        let events = journal.events();
365        assert!(matches!(
366            events[0].kind,
367            RunEventKind::Effect(EffectEvent::Requested { .. })
368        ));
369        assert!(matches!(
370            events.last().unwrap().kind,
371            RunEventKind::Effect(EffectEvent::Completed { .. })
372        ));
373        assert!(matches!(
374            &events.last().unwrap().kind,
375            RunEventKind::Effect(EffectEvent::Completed { output, .. })
376                if output == &json!({"runifold": {"content_recorded": false}})
377        ));
378    }
379
380    #[test]
381    fn full_event_payload_capture_requires_explicit_opt_in() {
382        let capability = capability(EffectClass::Pure);
383        let journal = InMemoryJournal::new();
384        let run = run_with(&capability, Some(journal.clone()));
385        let executor = EffectExecutor::new(Arc::new(InMemoryEffectStore::new()))
386            .with_event_payload_policy(EffectEventPayloadPolicy::Full);
387        let handler = CountingHandler::new();
388
389        futures_executor::block_on(executor.execute(
390            request(&capability, None, json!("secret")),
391            &run,
392            &handler,
393            EffectRecoveryPolicy::RejectAmbiguous,
394        ))
395        .unwrap();
396
397        assert!(matches!(
398            &journal.events().last().unwrap().kind,
399            RunEventKind::Effect(EffectEvent::Completed { output, .. })
400                if output == &json!({"echo": "secret"})
401        ));
402    }
403
404    #[test]
405    fn idempotency_key_cannot_be_reused_for_different_input() {
406        let capability = capability(EffectClass::IdempotentWrite);
407        let run = run_with(&capability, None);
408        let executor = EffectExecutor::new(Arc::new(InMemoryEffectStore::new()));
409        let handler = CountingHandler::new();
410        let first = request(&capability, Some("same-key"), json!({"value": 1}));
411        futures_executor::block_on(executor.execute(
412            first,
413            &run,
414            &handler,
415            EffectRecoveryPolicy::RejectAmbiguous,
416        ))
417        .unwrap();
418        let second = request(&capability, Some("same-key"), json!({"value": 2}));
419
420        let error = futures_executor::block_on(executor.execute(
421            second,
422            &run,
423            &handler,
424            EffectRecoveryPolicy::RejectAmbiguous,
425        ))
426        .unwrap_err();
427
428        assert_eq!(error.kind, EffectExecutorErrorKind::IdempotencyConflict);
429        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
430    }
431
432    #[test]
433    fn ambiguous_non_idempotent_effect_is_never_retried() {
434        let capability = capability(EffectClass::NonIdempotentWrite);
435        let run = run_with(&capability, None);
436        let store = Arc::new(InMemoryEffectStore::new());
437        let request = request(&capability, None, json!({"charge": 10}));
438        let prepared = EffectRecord::prepared(request.clone());
439        store.compare_and_swap(&prepared, None).unwrap();
440        let started = prepared.next(EffectStatus::Started).unwrap();
441        store.compare_and_swap(&started, Some(0)).unwrap();
442        let executor = EffectExecutor::new(store);
443        let handler = CountingHandler::new();
444
445        let error = futures_executor::block_on(executor.execute(
446            request,
447            &run,
448            &handler,
449            EffectRecoveryPolicy::RetrySafe,
450        ))
451        .unwrap_err();
452
453        assert_eq!(error.kind, EffectExecutorErrorKind::Ambiguous);
454        assert_eq!(handler.calls.load(Ordering::SeqCst), 0);
455    }
456
457    #[test]
458    fn started_idempotent_effect_can_be_explicitly_reconciled_by_retry() {
459        let capability = capability(EffectClass::IdempotentWrite);
460        let run = run_with(&capability, None);
461        let store = Arc::new(InMemoryEffectStore::new());
462        let request = request(&capability, Some("safe-key"), json!({"write": 1}));
463        let prepared = EffectRecord::prepared(request.clone());
464        store.compare_and_swap(&prepared, None).unwrap();
465        let started = prepared.next(EffectStatus::Started).unwrap();
466        store.compare_and_swap(&started, Some(0)).unwrap();
467        let executor = EffectExecutor::new(store);
468        let handler = CountingHandler::new();
469
470        let outcome = futures_executor::block_on(executor.execute(
471            request,
472            &run,
473            &handler,
474            EffectRecoveryPolicy::RetrySafe,
475        ))
476        .unwrap();
477
478        assert_eq!(outcome.revision, 3);
479        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
480    }
481
482    #[test]
483    fn missing_capability_rejects_before_persistence_or_execution() {
484        let capability = capability(EffectClass::Pure);
485        let run = RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new());
486        let store = Arc::new(InMemoryEffectStore::new());
487        let effect = request(&capability, None, json!({}));
488        let executor = EffectExecutor::new(store.clone());
489        let handler = CountingHandler::new();
490
491        let error = futures_executor::block_on(executor.execute(
492            effect.clone(),
493            &run,
494            &handler,
495            EffectRecoveryPolicy::RejectAmbiguous,
496        ))
497        .unwrap_err();
498
499        assert_eq!(error.kind, EffectExecutorErrorKind::CapabilityDenied);
500        assert!(store.load(effect.effect_id).unwrap().is_none());
501        assert_eq!(handler.calls.load(Ordering::SeqCst), 0);
502    }
503
504    fn capability(effect: EffectClass) -> CapabilityDescriptor {
505        CapabilityDescriptor {
506            id: CapabilityId::new(),
507            name: "test-effect".into(),
508            version: "1".into(),
509            kind: CapabilityKind::Resource,
510            input_schema: json!({}),
511            output_schema: json!({}),
512            effect,
513            risk: RiskLevel::Low,
514            metadata: BTreeMap::new(),
515        }
516    }
517
518    fn request(
519        capability: &CapabilityDescriptor,
520        key: Option<&str>,
521        input: Value,
522    ) -> EffectRequest {
523        EffectRequest {
524            effect_id: EffectId::new(),
525            invocation_id: InvocationId::new(),
526            kind: EffectKind::Extension("test".into()),
527            capability_id: capability.id,
528            input,
529            effect_class: capability.effect,
530            idempotency_key: key.map(str::to_owned),
531        }
532    }
533
534    fn run_with(capability: &CapabilityDescriptor, journal: Option<InMemoryJournal>) -> RunContext {
535        let mut capabilities = CapabilitySet::new();
536        capabilities.grant(capability.clone());
537        let run = RunContext::root(BudgetTracker::new(Budget::default()), capabilities);
538        match journal {
539            Some(journal) => run.with_journal(Arc::new(journal)),
540            None => run,
541        }
542    }
543}