1use std::{sync::Arc, time::Instant};
2
3use futures_util::future::{Either, select};
4use runifold_core::{
5 EffectClass, EffectEvent, EffectId, EffectRequest, RunContext, RunError, RunEventKind,
6};
7use serde_json::Value;
8
9use crate::{
10 EffectExecutionContext, EffectExecutorError, EffectExecutorErrorKind, EffectHandler,
11 EffectRecord, EffectStatus, EffectStore,
12};
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
16#[non_exhaustive]
17pub enum EffectRecoveryPolicy {
18 #[default]
20 RejectAmbiguous,
21 RetrySafe,
23}
24
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
27#[non_exhaustive]
28pub enum EffectEventPayloadPolicy {
29 #[default]
31 Redacted,
32 Full,
34}
35
36#[derive(Clone, Debug, PartialEq)]
38pub struct EffectOutcome {
39 pub effect_id: EffectId,
41 pub output: Value,
43 pub replayed: bool,
45 pub revision: u64,
47}
48
49#[derive(Clone)]
51pub struct EffectExecutor {
52 store: Arc<dyn EffectStore>,
53 event_payload_policy: EffectEventPayloadPolicy,
54}
55
56impl EffectExecutor {
57 pub fn new(store: Arc<dyn EffectStore>) -> Self {
59 Self {
60 store,
61 event_payload_policy: EffectEventPayloadPolicy::Redacted,
62 }
63 }
64
65 #[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 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 if let Some(record) = self.store.find_by_idempotency(request.capability_id, key)? {
126 validate_same_effect(&record.request, &request)?;
127 return Ok((record, false));
128 }
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}