1use std::collections::BTreeMap;
2
3use async_trait::async_trait;
4use runtime_api_contract::{
5 EventPayload, ExecutionEvent, ExecutionFailure, ExecutionOutcome, ExecutionState,
6};
7use runtime_link_api::{
8 ExecutionJournal, JournalClaim, JournalDelegation, JournalError, JournalExecution,
9 JournalInputReceipt, JournalInteraction, JournalMutation, JournalReservation,
10 NewJournalExecution,
11};
12use runtime_types::{DelegationLeaseRef, ExecutionId, OperationId};
13use tokio::sync::Mutex;
14
15struct State {
16 executions: BTreeMap<ExecutionId, JournalExecution>,
17 idempotency: BTreeMap<String, ExecutionId>,
18 events: BTreeMap<ExecutionId, Vec<ExecutionEvent>>,
19}
20
21pub struct MemoryExecutionJournal {
23 state: Mutex<State>,
24 max_events_per_execution: usize,
25}
26
27impl MemoryExecutionJournal {
28 pub fn new(max_events_per_execution: usize) -> Self {
29 Self {
30 state: Mutex::new(State {
31 executions: BTreeMap::new(),
32 idempotency: BTreeMap::new(),
33 events: BTreeMap::new(),
34 }),
35 max_events_per_execution,
36 }
37 }
38}
39
40#[async_trait]
41impl ExecutionJournal for MemoryExecutionJournal {
42 async fn reserve(
43 &self,
44 execution: NewJournalExecution,
45 ) -> Result<JournalReservation, JournalError> {
46 let mut state = self.state.lock().await;
47 if let Some(id) = state.idempotency.get(&execution.idempotency_key) {
48 let existing = state
49 .executions
50 .get(id)
51 .ok_or_else(|| JournalError::Unavailable("idempotency index is corrupt".into()))?;
52 if existing.request != execution.request {
53 return Err(JournalError::Conflict);
54 }
55 return Ok(JournalReservation::Existing(existing.clone()));
56 }
57 if state.executions.contains_key(&execution.view.id) {
58 return Err(JournalError::Conflict);
59 }
60 let stored = JournalExecution {
61 idempotency_key: execution.idempotency_key.clone(),
62 caller: execution.caller,
63 request: execution.request,
64 view: execution.view,
65 version: 1,
66 pending_interaction: None,
67 input_receipts: BTreeMap::new(),
68 delegation: None,
69 claim: None,
70 };
71 state
72 .idempotency
73 .insert(execution.idempotency_key, stored.view.id.clone());
74 state.events.insert(stored.view.id.clone(), Vec::new());
75 state
76 .executions
77 .insert(stored.view.id.clone(), stored.clone());
78 Ok(JournalReservation::Created(stored))
79 }
80
81 async fn get(&self, id: &ExecutionId) -> Result<JournalExecution, JournalError> {
82 self.state
83 .lock()
84 .await
85 .executions
86 .get(id)
87 .cloned()
88 .ok_or(JournalError::NotFound)
89 }
90
91 async fn claim(
92 &self,
93 id: &ExecutionId,
94 worker_id: &str,
95 now_ms: i64,
96 expires_at_ms: i64,
97 ) -> Result<JournalExecution, JournalError> {
98 if worker_id.is_empty() || expires_at_ms <= now_ms {
99 return Err(JournalError::Conflict);
100 }
101 let mut state = self.state.lock().await;
102 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
103 if execution
104 .claim
105 .as_ref()
106 .is_some_and(|claim| claim.expires_at_ms > now_ms && claim.worker_id != worker_id)
107 {
108 return Err(JournalError::Conflict);
109 }
110 execution.claim = Some(JournalClaim {
111 worker_id: worker_id.into(),
112 expires_at_ms,
113 });
114 execution.version = execution.version.saturating_add(1);
115 Ok(execution.clone())
116 }
117
118 async fn recoverable(
119 &self,
120 now_ms: i64,
121 limit: usize,
122 ) -> Result<Vec<JournalExecution>, JournalError> {
123 if limit == 0 || limit > 10_000 {
124 return Err(JournalError::Conflict);
125 }
126 Ok(self
127 .state
128 .lock()
129 .await
130 .executions
131 .values()
132 .filter(|record| {
133 let pending = !record.view.state.is_terminal()
134 || record
135 .delegation
136 .as_ref()
137 .is_some_and(|delegation| !delegation.cleanup_complete);
138 pending
139 && record
140 .claim
141 .as_ref()
142 .is_none_or(|claim| claim.expires_at_ms <= now_ms)
143 })
144 .take(limit)
145 .cloned()
146 .collect())
147 }
148
149 async fn transition(
150 &self,
151 id: &ExecutionId,
152 expected: &[ExecutionState],
153 next: ExecutionState,
154 ) -> Result<JournalExecution, JournalError> {
155 self.finish(id, expected, next, None, None).await
156 }
157
158 async fn attach_delegation(
159 &self,
160 id: &ExecutionId,
161 delegation: JournalDelegation,
162 ) -> Result<JournalExecution, JournalError> {
163 let mut state = self.state.lock().await;
164 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
165 if execution.view.state != ExecutionState::Queued {
166 return Err(JournalError::Conflict);
167 }
168 if let Some(existing) = &execution.delegation {
169 return if existing.lease_ref == delegation.lease_ref
170 && delegation.revision >= existing.revision
171 {
172 execution.delegation = Some(delegation);
173 execution.version = execution.version.saturating_add(1);
174 Ok(execution.clone())
175 } else {
176 Err(JournalError::Conflict)
177 };
178 }
179 execution.delegation = Some(delegation);
180 execution.version = execution.version.saturating_add(1);
181 Ok(execution.clone())
182 }
183
184 async fn complete_delegation_cleanup(
185 &self,
186 id: &ExecutionId,
187 lease_ref: &DelegationLeaseRef,
188 ) -> Result<JournalExecution, JournalError> {
189 let mut state = self.state.lock().await;
190 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
191 if execution.view.state != ExecutionState::Finalizing && !execution.view.state.is_terminal()
192 {
193 return Err(JournalError::Conflict);
194 }
195 let delegation = execution
196 .delegation
197 .as_mut()
198 .ok_or(JournalError::Conflict)?;
199 if &delegation.lease_ref != lease_ref {
200 return Err(JournalError::Conflict);
201 }
202 delegation.cleanup_complete = true;
203 execution.version = execution.version.saturating_add(1);
204 Ok(execution.clone())
205 }
206
207 async fn finish(
208 &self,
209 id: &ExecutionId,
210 expected: &[ExecutionState],
211 next: ExecutionState,
212 outcome: Option<ExecutionOutcome>,
213 failure: Option<ExecutionFailure>,
214 ) -> Result<JournalExecution, JournalError> {
215 let mut state = self.state.lock().await;
216 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
217 if !expected.contains(&execution.view.state) {
218 return Err(JournalError::Conflict);
219 }
220 validate_transition(execution.view.state, next)?;
221 execution.view.state = next;
222 execution.view.outcome = outcome;
223 execution.view.failure = failure;
224 execution.view.updated_at_ms = chrono::Utc::now().timestamp_millis();
225 execution.version = execution.version.saturating_add(1);
226 Ok(execution.clone())
227 }
228
229 async fn transition_with_event(
230 &self,
231 id: &ExecutionId,
232 expected: &[ExecutionState],
233 next: ExecutionState,
234 outcome: Option<ExecutionOutcome>,
235 failure: Option<ExecutionFailure>,
236 payload: EventPayload,
237 ) -> Result<JournalMutation, JournalError> {
238 let mut state = self.state.lock().await;
239 ensure_event_capacity(&state, id, self.max_events_per_execution)?;
240 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
241 if !expected.contains(&execution.view.state) {
242 return Err(JournalError::Conflict);
243 }
244 validate_transition(execution.view.state, next)?;
245 execution.view.state = next;
246 execution.view.outcome = outcome;
247 execution.view.failure = failure;
248 execution.view.updated_at_ms = chrono::Utc::now().timestamp_millis();
249 execution.version = execution.version.saturating_add(1);
250 let execution = execution.clone();
251 let event = push_event(&mut state, id, payload)?;
252 Ok(JournalMutation {
253 execution,
254 event,
255 replayed: false,
256 })
257 }
258
259 async fn append_event(
260 &self,
261 id: &ExecutionId,
262 payload: EventPayload,
263 ) -> Result<ExecutionEvent, JournalError> {
264 let mut state = self.state.lock().await;
265 if !state.executions.contains_key(id) {
266 return Err(JournalError::NotFound);
267 }
268 let events = state
269 .events
270 .get_mut(id)
271 .ok_or_else(|| JournalError::Unavailable("event partition is missing".into()))?;
272 if events.len() >= self.max_events_per_execution {
273 return Err(JournalError::Unavailable(
274 "event retention limit exceeded".into(),
275 ));
276 }
277 let event = ExecutionEvent {
278 execution_id: id.clone(),
279 sequence: events.len() as u64 + 1,
280 created_at_ms: chrono::Utc::now().timestamp_millis(),
281 payload,
282 };
283 events.push(event.clone());
284 Ok(event)
285 }
286
287 async fn begin_interaction(
288 &self,
289 id: &ExecutionId,
290 request_id: &str,
291 prompt: &str,
292 ) -> Result<JournalMutation, JournalError> {
293 let mut state = self.state.lock().await;
294 ensure_event_capacity(&state, id, self.max_events_per_execution)?;
295 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
296 if execution.view.state != ExecutionState::Running
297 || execution.pending_interaction.is_some()
298 {
299 return Err(JournalError::Conflict);
300 }
301 execution.view.state = ExecutionState::WaitingForInput;
302 execution.view.updated_at_ms = chrono::Utc::now().timestamp_millis();
303 execution.version = execution.version.saturating_add(1);
304 execution.pending_interaction = Some(JournalInteraction {
305 request_id: request_id.into(),
306 prompt: prompt.into(),
307 });
308 let execution = execution.clone();
309 let event = push_event(
310 &mut state,
311 id,
312 EventPayload::InteractionRequired {
313 request_id: request_id.into(),
314 prompt: prompt.into(),
315 },
316 )?;
317 Ok(JournalMutation {
318 execution,
319 event,
320 replayed: false,
321 })
322 }
323
324 async fn commit_interaction_input(
325 &self,
326 id: &ExecutionId,
327 operation_id: &OperationId,
328 request_id: &str,
329 response_text: &str,
330 ) -> Result<JournalMutation, JournalError> {
331 let mut state = self.state.lock().await;
332 let existing = state
333 .executions
334 .get(id)
335 .ok_or(JournalError::NotFound)?
336 .input_receipts
337 .get(operation_id)
338 .cloned();
339 if let Some(receipt) = existing {
340 if receipt.request_id != request_id || receipt.response_text != response_text {
341 return Err(JournalError::Conflict);
342 }
343 let event = state
344 .events
345 .get(id)
346 .and_then(|events| {
347 events
348 .iter()
349 .find(|event| event.sequence == receipt.event_sequence)
350 })
351 .cloned()
352 .ok_or_else(|| {
353 JournalError::Unavailable("input receipt event is missing".into())
354 })?;
355 return Ok(JournalMutation {
356 execution: state
357 .executions
358 .get(id)
359 .ok_or(JournalError::NotFound)?
360 .clone(),
361 event,
362 replayed: true,
363 });
364 }
365 ensure_event_capacity(&state, id, self.max_events_per_execution)?;
366 let event_sequence = state
367 .events
368 .get(id)
369 .map_or(1, |events| events.len() as u64 + 1);
370 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
371 let matches_pending = execution
372 .pending_interaction
373 .as_ref()
374 .is_some_and(|pending| pending.request_id == request_id);
375 if execution.view.state != ExecutionState::WaitingForInput || !matches_pending {
376 return Err(JournalError::Conflict);
377 }
378 execution.input_receipts.insert(
379 operation_id.clone(),
380 JournalInputReceipt {
381 request_id: request_id.into(),
382 response_text: response_text.into(),
383 event_sequence,
384 },
385 );
386 execution.view.updated_at_ms = chrono::Utc::now().timestamp_millis();
387 execution.version = execution.version.saturating_add(1);
388 let execution = execution.clone();
389 let event = push_event(
390 &mut state,
391 id,
392 EventPayload::InteractionReceived {
393 request_id: request_id.into(),
394 },
395 )?;
396 Ok(JournalMutation {
397 execution,
398 event,
399 replayed: false,
400 })
401 }
402
403 async fn complete_interaction(
404 &self,
405 id: &ExecutionId,
406 request_id: &str,
407 ) -> Result<JournalExecution, JournalError> {
408 let mut state = self.state.lock().await;
409 let execution = state.executions.get_mut(id).ok_or(JournalError::NotFound)?;
410 let matches_pending = execution
411 .pending_interaction
412 .as_ref()
413 .is_some_and(|pending| pending.request_id == request_id);
414 let has_response = execution
415 .input_receipts
416 .values()
417 .any(|receipt| receipt.request_id == request_id);
418 if execution.view.state != ExecutionState::WaitingForInput
419 || !matches_pending
420 || !has_response
421 {
422 return Err(JournalError::Conflict);
423 }
424 execution.pending_interaction = None;
425 execution.view.state = ExecutionState::Running;
426 execution.view.updated_at_ms = chrono::Utc::now().timestamp_millis();
427 execution.version = execution.version.saturating_add(1);
428 Ok(execution.clone())
429 }
430
431 async fn events(
432 &self,
433 id: &ExecutionId,
434 after: Option<u64>,
435 limit: usize,
436 ) -> Result<Vec<ExecutionEvent>, JournalError> {
437 let state = self.state.lock().await;
438 if !state.executions.contains_key(id) {
439 return Err(JournalError::NotFound);
440 }
441 let after = after.unwrap_or(0);
442 Ok(state
443 .events
444 .get(id)
445 .into_iter()
446 .flatten()
447 .filter(|event| event.sequence > after)
448 .take(limit.min(1000))
449 .cloned()
450 .collect())
451 }
452}
453
454fn ensure_event_capacity(
455 state: &State,
456 id: &ExecutionId,
457 limit: usize,
458) -> Result<(), JournalError> {
459 let events = state.events.get(id).ok_or(JournalError::NotFound)?;
460 if events.len() >= limit {
461 Err(JournalError::Unavailable(
462 "event retention limit exceeded".into(),
463 ))
464 } else {
465 Ok(())
466 }
467}
468
469fn push_event(
470 state: &mut State,
471 id: &ExecutionId,
472 payload: EventPayload,
473) -> Result<ExecutionEvent, JournalError> {
474 let events = state.events.get_mut(id).ok_or(JournalError::NotFound)?;
475 let event = ExecutionEvent {
476 execution_id: id.clone(),
477 sequence: events.len() as u64 + 1,
478 created_at_ms: chrono::Utc::now().timestamp_millis(),
479 payload,
480 };
481 events.push(event.clone());
482 Ok(event)
483}
484
485fn validate_transition(current: ExecutionState, next: ExecutionState) -> Result<(), JournalError> {
486 let valid = matches!(
487 (current, next),
488 (
489 ExecutionState::Queued,
490 ExecutionState::Running
491 | ExecutionState::Finalizing
492 | ExecutionState::Canceled
493 | ExecutionState::Failed
494 ) | (
495 ExecutionState::Running,
496 ExecutionState::WaitingForInput
497 | ExecutionState::Finalizing
498 | ExecutionState::Failed
499 | ExecutionState::Canceled
500 ) | (
501 ExecutionState::WaitingForInput,
502 ExecutionState::Running
503 | ExecutionState::Finalizing
504 | ExecutionState::Failed
505 | ExecutionState::Canceled
506 ) | (
507 ExecutionState::Finalizing,
508 ExecutionState::Completed | ExecutionState::Failed | ExecutionState::Canceled
509 )
510 );
511 if valid {
512 Ok(())
513 } else {
514 Err(JournalError::Conflict)
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use runtime_api_contract::{
522 CreateExecutionRequest, ExecutionOptions, ExecutionView, RuntimeInput,
523 };
524 use runtime_types::{CallerScope, RuntimeInstanceId};
525
526 fn new_execution(key: &str) -> NewJournalExecution {
527 let id = ExecutionId::random();
528 let request = CreateExecutionRequest {
529 runtime_instance_id: RuntimeInstanceId::new("runtime-1").unwrap(),
530 conversation_id: None,
531 input: RuntimeInput::UserMessage {
532 text: "hello".into(),
533 },
534 workspace_id: None,
535 model: None,
536 instructions: None,
537 metadata: Default::default(),
538 generation: Default::default(),
539 options: ExecutionOptions::default(),
540 };
541 NewJournalExecution {
542 idempotency_key: key.into(),
543 caller: CallerScope {
544 subject: "user-1".into(),
545 tenant_id: "tenant-1".into(),
546 project_id: "project-1".into(),
547 capabilities: Vec::new(),
548 },
549 request: request.clone(),
550 view: ExecutionView {
551 id,
552 runtime_instance_id: request.runtime_instance_id.clone(),
553 conversation_id: None,
554 workspace_id: None,
555 model: None,
556 metadata: Default::default(),
557 state: ExecutionState::Queued,
558 outcome: None,
559 failure: None,
560 created_at_ms: 1,
561 updated_at_ms: 1,
562 },
563 }
564 }
565
566 #[tokio::test]
567 async fn reservation_is_idempotent_and_transitions_are_guarded() {
568 let journal = MemoryExecutionJournal::new(10);
569 let created = journal.reserve(new_execution("key")).await.unwrap();
570 let id = match created {
571 JournalReservation::Created(value) => value.view.id,
572 _ => panic!("created"),
573 };
574 assert!(matches!(
575 journal.reserve(new_execution("key")).await,
576 Ok(JournalReservation::Existing(_))
577 ));
578 let mut conflicting = new_execution("key");
579 conflicting.request.input = RuntimeInput::UserMessage {
580 text: "different".into(),
581 };
582 assert!(matches!(
583 journal.reserve(conflicting).await,
584 Err(JournalError::Conflict)
585 ));
586 journal
587 .transition(&id, &[ExecutionState::Queued], ExecutionState::Running)
588 .await
589 .unwrap();
590 assert!(matches!(
591 journal
592 .transition(&id, &[ExecutionState::Queued], ExecutionState::Running)
593 .await,
594 Err(JournalError::Conflict)
595 ));
596 }
597
598 #[tokio::test]
599 async fn claim_expiry_allows_exactly_one_recovery_worker() {
600 let journal = MemoryExecutionJournal::new(10);
601 let id = match journal.reserve(new_execution("claim-key")).await.unwrap() {
602 JournalReservation::Created(value) => value.view.id,
603 _ => panic!("created"),
604 };
605 journal.claim(&id, "worker-a", 100, 200).await.unwrap();
606 assert!(matches!(
607 journal.claim(&id, "worker-b", 150, 250).await,
608 Err(JournalError::Conflict)
609 ));
610 assert!(journal.recoverable(199, 10).await.unwrap().is_empty());
611 assert_eq!(journal.recoverable(200, 10).await.unwrap().len(), 1);
612 let claimed = journal.claim(&id, "worker-b", 200, 300).await.unwrap();
613 assert_eq!(claimed.claim.unwrap().worker_id, "worker-b");
614 }
615
616 #[tokio::test]
617 async fn interaction_input_is_committed_before_resume_and_deduplicated() {
618 let journal = MemoryExecutionJournal::new(10);
619 let id = match journal.reserve(new_execution("interaction")).await.unwrap() {
620 JournalReservation::Created(value) => value.view.id,
621 _ => panic!("created"),
622 };
623 journal
624 .transition(&id, &[ExecutionState::Queued], ExecutionState::Running)
625 .await
626 .unwrap();
627 let required = journal
628 .begin_interaction(&id, "request-1", "approve?")
629 .await
630 .unwrap();
631 assert_eq!(
632 required.execution.view.state,
633 ExecutionState::WaitingForInput
634 );
635
636 let operation_id = OperationId::new("input-1").unwrap();
637 let committed = journal
638 .commit_interaction_input(&id, &operation_id, "request-1", "yes")
639 .await
640 .unwrap();
641 assert!(!committed.replayed);
642 assert_eq!(
643 committed.execution.view.state,
644 ExecutionState::WaitingForInput
645 );
646 let replay = journal
647 .commit_interaction_input(&id, &operation_id, "request-1", "yes")
648 .await
649 .unwrap();
650 assert!(replay.replayed);
651 assert_eq!(replay.event.sequence, committed.event.sequence);
652 assert!(matches!(
653 journal
654 .commit_interaction_input(&id, &operation_id, "request-1", "no")
655 .await,
656 Err(JournalError::Conflict)
657 ));
658 let resumed = journal
659 .complete_interaction(&id, "request-1")
660 .await
661 .unwrap();
662 assert_eq!(resumed.view.state, ExecutionState::Running);
663 }
664}