Skip to main content

stasis/application/runtime/
in_memory_runtime.rs

1use std::collections::HashMap;
2use std::sync::{Arc, RwLock};
3use std::time::Instant;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Duration, Utc};
7
8use crate::application::runtime::replay_report::ReplayReport;
9use crate::application::runtime::retention::{RetentionPolicy, RetentionPruneReport};
10use crate::application::runtime::runtime_diagnostics_helpers;
11use crate::application::runtime::runtime_job_identity_context::RuntimeJobIdentityContext;
12use crate::application::use_cases::investigate_runtime_lineage::{
13    InvestigateRuntimeLineage, RuntimeLineageQuery, RuntimeLineageReport,
14};
15use crate::domain::errors::{Result, StasisError};
16use crate::domain::runtime::job::{Job, JobState, NewJob};
17use crate::domain::runtime::job_attempt::{JobAttempt, JobAttemptOutcome};
18use crate::domain::runtime::outbox::{
19    OutboxEvent, OutboxPublishPolicy, OutboxStatus, RuntimeEvent, RuntimeEventType,
20};
21use crate::domain::runtime::recurring::RecurringDefinition;
22use crate::infrastructure::runtime::atomic_id_generator::AtomicIdGenerator;
23use crate::infrastructure::runtime::noop_runtime_metrics::NoopRuntimeMetrics;
24use crate::infrastructure::runtime::system_clock::SystemClock;
25use crate::ports::outbound::runtime::clock::Clock;
26use crate::ports::outbound::runtime::event_publisher::EventPublisher;
27use crate::ports::outbound::runtime::id_generator::IdGenerator;
28use crate::ports::outbound::runtime::job_attempt_store::JobAttemptStore;
29use crate::ports::outbound::runtime::job_store::JobStore;
30use crate::ports::outbound::runtime::outbox_store::OutboxStore;
31use crate::ports::outbound::runtime::recurring_store::RecurringStore;
32use crate::ports::outbound::runtime::runtime_metrics::RuntimeMetrics;
33
34const METRIC_JOB_SUCCEEDED_TOTAL: &str = "runtime.job.succeeded.total";
35const METRIC_JOB_RETRYABLE_FAILURE_TOTAL: &str = "runtime.job.retryable_failure.total";
36const METRIC_JOB_FATAL_FAILURE_TOTAL: &str = "runtime.job.fatal_failure.total";
37const METRIC_JOB_DEAD_LETTER_TOTAL: &str = "runtime.job.dead_letter.total";
38const METRIC_JOB_RETRY_SCHEDULED_TOTAL: &str = "runtime.job.retry_scheduled.total";
39const METRIC_JOB_PROCESS_DURATION_MS: &str = "runtime.job.process.duration_ms";
40const METRIC_OUTBOX_PUBLISH_SUCCESS_TOTAL: &str = "runtime.outbox.publish.success.total";
41const METRIC_OUTBOX_PUBLISH_FAILURE_TOTAL: &str = "runtime.outbox.publish.failure.total";
42const METRIC_GRAPHEME_GUARDRAIL_FAILURE_TOTAL: &str = "runtime.grapheme.guardrail_failure.total";
43
44#[derive(Clone, Debug)]
45pub enum JobExecutionOutcome {
46    Success {
47        sttp_output_node_id: String,
48        execution_id: Option<String>,
49        diagnostics: Option<String>,
50    },
51    RetryableFailure {
52        message: String,
53        execution_id: Option<String>,
54        diagnostics: Option<String>,
55    },
56    FatalFailure {
57        message: String,
58        execution_id: Option<String>,
59        diagnostics: Option<String>,
60    },
61}
62
63#[async_trait]
64pub trait JobHandler: Send + Sync {
65    fn job_type(&self) -> &'static str;
66    async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome>;
67}
68
69#[derive(Clone)]
70pub struct InMemoryRuntime {
71    pub job_store: InMemoryJobStore,
72    pub recurring_store: InMemoryRecurringStore,
73    pub outbox_store: InMemoryOutboxStore,
74    pub job_attempt_store: InMemoryJobAttemptStore,
75    handlers: Arc<RwLock<HashMap<String, Arc<dyn JobHandler>>>>,
76    publisher: Arc<RwLock<Option<Arc<dyn EventPublisher>>>>,
77    publish_policy: Arc<RwLock<OutboxPublishPolicy>>,
78    clock: Arc<dyn Clock>,
79    id_generator: Arc<dyn IdGenerator>,
80    metrics: Arc<dyn RuntimeMetrics>,
81    retention_policy: Arc<RwLock<RetentionPolicy>>,
82}
83
84impl Default for InMemoryRuntime {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl InMemoryRuntime {
91    pub fn new() -> Self {
92        Self::with_dependencies_and_metrics(
93            Arc::new(SystemClock),
94            Arc::new(AtomicIdGenerator::new(1)),
95            Arc::new(NoopRuntimeMetrics),
96        )
97    }
98
99    pub fn with_dependencies(clock: Arc<dyn Clock>, id_generator: Arc<dyn IdGenerator>) -> Self {
100        Self::with_dependencies_and_metrics(clock, id_generator, Arc::new(NoopRuntimeMetrics))
101    }
102
103    pub fn with_dependencies_and_metrics(
104        clock: Arc<dyn Clock>,
105        id_generator: Arc<dyn IdGenerator>,
106        metrics: Arc<dyn RuntimeMetrics>,
107    ) -> Self {
108        Self {
109            job_store: InMemoryJobStore::default(),
110            recurring_store: InMemoryRecurringStore::default(),
111            outbox_store: InMemoryOutboxStore::default(),
112            job_attempt_store: InMemoryJobAttemptStore::default(),
113            handlers: Arc::new(RwLock::new(HashMap::new())),
114            publisher: Arc::new(RwLock::new(None)),
115            publish_policy: Arc::new(RwLock::new(OutboxPublishPolicy::default())),
116            clock,
117            id_generator,
118            metrics,
119            retention_policy: Arc::new(RwLock::new(RetentionPolicy::default())),
120        }
121    }
122
123    pub fn configure_retention_policy(&self, policy: RetentionPolicy) -> Result<()> {
124        let mut state = self
125            .retention_policy
126            .write()
127            .map_err(|_| StasisError::PortFailure("retention policy lock poisoned".to_string()))?;
128        *state = policy;
129        Ok(())
130    }
131
132    pub fn register_handler<H: JobHandler + 'static>(&self, handler: H) -> Result<()> {
133        let mut handlers = self
134            .handlers
135            .write()
136            .map_err(|_| StasisError::PortFailure("handlers lock poisoned".to_string()))?;
137
138        handlers.insert(handler.job_type().to_string(), Arc::new(handler));
139        Ok(())
140    }
141
142    pub fn register_event_publisher<P: EventPublisher + 'static>(
143        &self,
144        publisher: P,
145    ) -> Result<()> {
146        let mut state = self
147            .publisher
148            .write()
149            .map_err(|_| StasisError::PortFailure("publisher lock poisoned".to_string()))?;
150
151        *state = Some(Arc::new(publisher));
152        Ok(())
153    }
154
155    pub fn configure_outbox_publish_policy(&self, policy: OutboxPublishPolicy) -> Result<()> {
156        let mut state = self
157            .publish_policy
158            .write()
159            .map_err(|_| StasisError::PortFailure("publish policy lock poisoned".to_string()))?;
160
161        *state = policy;
162        Ok(())
163    }
164
165    pub async fn enqueue(&self, job: NewJob) -> Result<()> {
166        self.job_store.insert(job.into_job()).await
167    }
168
169    pub async fn register_recurring(&self, definition: RecurringDefinition) -> Result<()> {
170        self.recurring_store.insert(definition).await
171    }
172
173    pub async fn list_job_attempts(&self, job_id: &str) -> Result<Vec<JobAttempt>> {
174        self.job_attempt_store.list_by_job_id(job_id).await
175    }
176
177    pub async fn list_attempts_by_guardrail_code(
178        &self,
179        guardrail_code: &str,
180    ) -> Result<Vec<JobAttempt>> {
181        self.job_attempt_store
182            .list_by_guardrail_code(guardrail_code)
183            .await
184    }
185
186    pub async fn list_attempts_by_execution_id(
187        &self,
188        execution_id: &str,
189    ) -> Result<Vec<JobAttempt>> {
190        self.job_attempt_store
191            .list_by_execution_id(execution_id)
192            .await
193    }
194
195    pub async fn list_lineage_events(&self, job_id: &str) -> Result<Vec<OutboxEvent>> {
196        self.outbox_store.list_by_job_id(job_id).await
197    }
198
199    pub async fn list_lineage_events_by_execution_id(
200        &self,
201        execution_id: &str,
202    ) -> Result<Vec<OutboxEvent>> {
203        self.outbox_store.list_by_execution_id(execution_id).await
204    }
205
206    pub async fn list_lineage_events_by_thread_id(
207        &self,
208        thread_id: &str,
209    ) -> Result<Vec<OutboxEvent>> {
210        self.outbox_store.list_by_thread_id(thread_id).await
211    }
212
213    pub async fn investigate_lineage(
214        &self,
215        query: RuntimeLineageQuery,
216    ) -> Result<RuntimeLineageReport> {
217        InvestigateRuntimeLineage::new(self.job_attempt_store.clone(), self.outbox_store.clone())
218            .execute(query)
219            .await
220    }
221
222    pub async fn get_replay_report(&self, job_id: &str) -> Result<ReplayReport> {
223        Ok(ReplayReport {
224            job_id: job_id.to_string(),
225            attempts: self.list_job_attempts(job_id).await?,
226            lineage_events: self.list_lineage_events(job_id).await?,
227        })
228    }
229
230    pub async fn process_once_now(&self, queue: &str, worker_id: &str) -> Result<Option<String>> {
231        self.process_once(queue, worker_id, self.clock.now()).await
232    }
233
234    pub async fn replay_dead_letter_now(&self, job_id: &str) -> Result<bool> {
235        self.replay_dead_letter(job_id, self.clock.now()).await
236    }
237
238    pub async fn publish_pending_events_now(&self, limit: usize) -> Result<usize> {
239        self.publish_pending_events(limit, self.clock.now()).await
240    }
241
242    pub async fn materialize_recurring_now(&self, scheduler_id: &str) -> Result<usize> {
243        self.materialize_recurring(self.clock.now(), scheduler_id)
244            .await
245    }
246
247    pub async fn prune_terminal_records(
248        &self,
249        cutoff: DateTime<Utc>,
250    ) -> Result<RetentionPruneReport> {
251        Ok(RetentionPruneReport {
252            jobs_pruned: self.job_store.prune_terminal_before(cutoff).await?,
253            attempts_pruned: self.job_attempt_store.prune_finished_before(cutoff).await?,
254            outbox_events_pruned: self.outbox_store.prune_non_pending_before(cutoff).await?,
255        })
256    }
257
258    pub async fn enforce_retention(&self, now: DateTime<Utc>) -> Result<RetentionPruneReport> {
259        let policy = self
260            .retention_policy
261            .read()
262            .map_err(|_| StasisError::PortFailure("retention policy lock poisoned".to_string()))?
263            .clone();
264        let cutoff = now - Duration::days(policy.terminal_ttl_days.max(0));
265        self.prune_terminal_records(cutoff).await
266    }
267
268    pub async fn enforce_retention_now(&self) -> Result<RetentionPruneReport> {
269        self.enforce_retention(self.clock.now()).await
270    }
271
272    pub async fn materialize_recurring(
273        &self,
274        now: DateTime<Utc>,
275        scheduler_id: &str,
276    ) -> Result<usize> {
277        let due = self
278            .recurring_store
279            .lease_due(now, scheduler_id, 30)
280            .await?;
281
282        let mut produced = 0usize;
283
284        for mut definition in due {
285            if !definition.enabled {
286                continue;
287            }
288
289            let id = self.id_generator.next_id(&definition.id).to_string();
290
291            let scheduled_at = now + Duration::seconds(definition.jitter_seconds.max(0));
292
293            let job = NewJob {
294                id,
295                queue: definition.queue.clone(),
296                job_type: definition.job_type.clone(),
297                payload_ref: definition.payload_template_ref.clone(),
298                priority: 100,
299                max_attempts: definition.max_attempts,
300                idempotency_key: format!("recurring:{}:{}", definition.id, now.timestamp()),
301                correlation_id: definition.id.clone(),
302                causation_id: definition.id.clone(),
303                trace_id: definition.id.clone(),
304                sttp_input_node_id: definition.payload_template_ref.clone(),
305                scheduled_at,
306                backoff_policy: Default::default(),
307            };
308
309            self.enqueue(job).await?;
310
311            definition.last_run_at = Some(now);
312            definition.next_run_at = definition.compute_next_run_at(now)?;
313            definition.lease_owner = None;
314            definition.lease_expires_at = None;
315            self.recurring_store.save(definition).await?;
316            produced += 1;
317        }
318
319        Ok(produced)
320    }
321
322    pub async fn process_once(
323        &self,
324        queue: &str,
325        worker_id: &str,
326        now: DateTime<Utc>,
327    ) -> Result<Option<String>> {
328        let Some(mut job) = self.job_store.lease_due(queue, worker_id, now, 30).await? else {
329            return Ok(None);
330        };
331
332        job.state = JobState::Running;
333        job.started_at = job.started_at.or(Some(now));
334        job.heartbeat_at = Some(now);
335        let job_identity = RuntimeJobIdentityContext::from(&job);
336        self.job_store.save(job.clone()).await?;
337        let processing_started = Instant::now();
338
339        let handler = {
340            let handlers = self
341                .handlers
342                .read()
343                .map_err(|_| StasisError::PortFailure("handlers lock poisoned".to_string()))?;
344            handlers.get(&job.job_type).cloned()
345        };
346
347        let outcome = if let Some(handler) = handler {
348            handler.execute(&job).await?
349        } else {
350            JobExecutionOutcome::FatalFailure {
351                message: format!("no handler registered for job_type={}", job.job_type),
352                execution_id: None,
353                diagnostics: None,
354            }
355        };
356
357        let attempt_number = job.attempts + 1;
358        let attempt_started_at = now;
359
360        match outcome {
361            JobExecutionOutcome::Success {
362                sttp_output_node_id,
363                execution_id,
364                diagnostics,
365            } => {
366                let diagnostics_envelope =
367                    Self::extract_diagnostics_envelope(diagnostics.as_deref());
368                job.state = JobState::Succeeded;
369                job.sttp_output_node_id = Some(sttp_output_node_id.clone());
370                job.finished_at = Some(now);
371                job.lease_owner = None;
372                job.lease_expires_at = None;
373                job.heartbeat_at = None;
374                self.job_store.save(job).await?;
375
376                self.append_outbox(
377                    RuntimeEventType::JobSucceeded,
378                    &job_identity,
379                    Some(sttp_output_node_id.clone()),
380                    None,
381                    now,
382                    execution_id.clone(),
383                    &diagnostics_envelope,
384                )
385                .await?;
386
387                self.append_job_attempt(
388                    &job_identity.job_id,
389                    worker_id,
390                    attempt_number,
391                    attempt_started_at,
392                    now,
393                    JobAttemptOutcome::Succeeded,
394                    None,
395                    Some(sttp_output_node_id),
396                    execution_id,
397                    &diagnostics_envelope,
398                    diagnostics,
399                )
400                .await?;
401
402                self.metrics.incr_counter(METRIC_JOB_SUCCEEDED_TOTAL, 1);
403                self.metrics.observe_duration_ms(
404                    METRIC_JOB_PROCESS_DURATION_MS,
405                    processing_started.elapsed().as_millis() as u64,
406                );
407            }
408            JobExecutionOutcome::RetryableFailure {
409                message,
410                execution_id,
411                diagnostics,
412            } => {
413                let diagnostics_envelope =
414                    Self::extract_diagnostics_envelope(diagnostics.as_deref());
415                let guardrail_failure = diagnostics
416                    .as_deref()
417                    .map(|v| v.contains("\"guardrail_code\""))
418                    .unwrap_or(false);
419                job.attempts += 1;
420                job.last_error = Some(message.clone());
421                job.lease_owner = None;
422                job.lease_expires_at = None;
423                job.heartbeat_at = None;
424
425                if job.attempts >= job.max_attempts {
426                    job.state = JobState::DeadLetter;
427                    job.finished_at = Some(now);
428                    self.append_outbox(
429                        RuntimeEventType::JobDeadLettered,
430                        &job_identity,
431                        None,
432                        Some(message.clone()),
433                        now,
434                        execution_id.clone(),
435                        &diagnostics_envelope,
436                    )
437                    .await?;
438
439                    self.metrics.incr_counter(METRIC_JOB_DEAD_LETTER_TOTAL, 1);
440                } else {
441                    job.state = JobState::Enqueued;
442                    let exponent = job.attempts - 1;
443                    let mut delay = job
444                        .backoff_policy
445                        .base_delay_seconds
446                        .saturating_mul(2_i64.saturating_pow(exponent));
447                    delay = delay.min(job.backoff_policy.max_delay_seconds);
448                    job.scheduled_at = now + Duration::seconds(delay.max(0));
449
450                    self.append_outbox(
451                        RuntimeEventType::JobRetryScheduled,
452                        &job_identity,
453                        None,
454                        Some(message.clone()),
455                        now,
456                        execution_id.clone(),
457                        &diagnostics_envelope,
458                    )
459                    .await?;
460
461                    self.metrics
462                        .incr_counter(METRIC_JOB_RETRY_SCHEDULED_TOTAL, 1);
463                }
464
465                self.job_store.save(job).await?;
466
467                self.append_job_attempt(
468                    &job_identity.job_id,
469                    worker_id,
470                    attempt_number,
471                    attempt_started_at,
472                    now,
473                    JobAttemptOutcome::RetryableFailure,
474                    Some(message),
475                    None,
476                    execution_id,
477                    &diagnostics_envelope,
478                    diagnostics,
479                )
480                .await?;
481
482                self.metrics
483                    .incr_counter(METRIC_JOB_RETRYABLE_FAILURE_TOTAL, 1);
484                self.metrics.observe_duration_ms(
485                    METRIC_JOB_PROCESS_DURATION_MS,
486                    processing_started.elapsed().as_millis() as u64,
487                );
488                if guardrail_failure {
489                    self.metrics
490                        .incr_counter(METRIC_GRAPHEME_GUARDRAIL_FAILURE_TOTAL, 1);
491                }
492            }
493            JobExecutionOutcome::FatalFailure {
494                message,
495                execution_id,
496                diagnostics,
497            } => {
498                let diagnostics_envelope =
499                    Self::extract_diagnostics_envelope(diagnostics.as_deref());
500                let guardrail_failure = diagnostics
501                    .as_deref()
502                    .map(|v| v.contains("\"guardrail_code\""))
503                    .unwrap_or(false);
504                job.attempts += 1;
505                job.state = JobState::DeadLetter;
506                job.last_error = Some(message.clone());
507                job.finished_at = Some(now);
508                job.lease_owner = None;
509                job.lease_expires_at = None;
510                job.heartbeat_at = None;
511                self.job_store.save(job).await?;
512
513                self.append_outbox(
514                    RuntimeEventType::JobDeadLettered,
515                    &job_identity,
516                    None,
517                    Some(message.clone()),
518                    now,
519                    execution_id.clone(),
520                    &diagnostics_envelope,
521                )
522                .await?;
523
524                self.append_job_attempt(
525                    &job_identity.job_id,
526                    worker_id,
527                    attempt_number,
528                    attempt_started_at,
529                    now,
530                    JobAttemptOutcome::FatalFailure,
531                    Some(message),
532                    None,
533                    execution_id,
534                    &diagnostics_envelope,
535                    diagnostics,
536                )
537                .await?;
538
539                self.metrics.incr_counter(METRIC_JOB_FATAL_FAILURE_TOTAL, 1);
540                self.metrics.incr_counter(METRIC_JOB_DEAD_LETTER_TOTAL, 1);
541                self.metrics.observe_duration_ms(
542                    METRIC_JOB_PROCESS_DURATION_MS,
543                    processing_started.elapsed().as_millis() as u64,
544                );
545                if guardrail_failure {
546                    self.metrics
547                        .incr_counter(METRIC_GRAPHEME_GUARDRAIL_FAILURE_TOTAL, 1);
548                }
549            }
550        }
551
552        Ok(Some(job_identity.job_id))
553    }
554
555    pub async fn replay_dead_letter(&self, job_id: &str, now: DateTime<Utc>) -> Result<bool> {
556        let Some(mut job) = self.job_store.get(job_id).await? else {
557            return Ok(false);
558        };
559
560        if job.state != JobState::DeadLetter {
561            return Ok(false);
562        }
563
564        job.state = JobState::Enqueued;
565        job.attempts = 0;
566        job.last_error = None;
567        job.scheduled_at = now;
568        job.lease_owner = None;
569        job.lease_expires_at = None;
570        job.heartbeat_at = None;
571        job.finished_at = None;
572
573        self.job_store.save(job).await?;
574        Ok(true)
575    }
576
577    pub async fn publish_pending_events(&self, limit: usize, now: DateTime<Utc>) -> Result<usize> {
578        let publisher = {
579            let state = self
580                .publisher
581                .read()
582                .map_err(|_| StasisError::PortFailure("publisher lock poisoned".to_string()))?;
583            state.clone()
584        };
585
586        let Some(publisher) = publisher else {
587            return Ok(0);
588        };
589
590        let policy = self
591            .publish_policy
592            .read()
593            .map_err(|_| StasisError::PortFailure("publish policy lock poisoned".to_string()))?
594            .clone();
595
596        let pending = self.outbox_store.list_pending(limit).await?;
597        let mut published = 0usize;
598
599        for mut event in pending {
600            if event
601                .next_attempt_at
602                .map(|next| next > now)
603                .unwrap_or(false)
604            {
605                continue;
606            }
607
608            match publisher.publish(&event).await {
609                Ok(()) => {
610                    event.status = OutboxStatus::Published;
611                    event.publish_attempts = event.publish_attempts.saturating_add(1);
612                    event.published_at = Some(now);
613                    event.next_attempt_at = None;
614                    event.last_publish_error = None;
615                    self.outbox_store.save(event).await?;
616                    published += 1;
617                    self.metrics
618                        .incr_counter(METRIC_OUTBOX_PUBLISH_SUCCESS_TOTAL, 1);
619                }
620                Err(err) => {
621                    event.publish_attempts = event.publish_attempts.saturating_add(1);
622                    event.published_at = None;
623                    event.last_publish_error = Some(err.to_string());
624
625                    if event.publish_attempts >= policy.max_attempts {
626                        event.status = OutboxStatus::Failed;
627                        event.next_attempt_at = None;
628                    } else {
629                        let exponent = event.publish_attempts - 1;
630                        let mut delay = policy
631                            .base_delay_seconds
632                            .saturating_mul(2_i64.saturating_pow(exponent));
633                        delay = delay.min(policy.max_delay_seconds);
634                        event.status = OutboxStatus::Pending;
635                        event.next_attempt_at = Some(now + Duration::seconds(delay.max(0)));
636                    }
637
638                    self.outbox_store.save(event).await?;
639                    self.metrics
640                        .incr_counter(METRIC_OUTBOX_PUBLISH_FAILURE_TOTAL, 1);
641                }
642            }
643        }
644
645        Ok(published)
646    }
647
648    #[allow(clippy::too_many_arguments)]
649    async fn append_outbox(
650        &self,
651        event_type: RuntimeEventType,
652        job_identity: &RuntimeJobIdentityContext,
653        sttp_output_node_id: Option<String>,
654        message: Option<String>,
655        now: DateTime<Utc>,
656        execution_id: Option<String>,
657        diagnostics: &runtime_diagnostics_helpers::RuntimeDiagnosticsEnvelope,
658    ) -> Result<()> {
659        let event = OutboxEvent {
660            event_id: self
661                .id_generator
662                .next_id(&format!("evt-{}", job_identity.job_id)),
663            status: OutboxStatus::Pending,
664            publish_attempts: 0,
665            published_at: None,
666            next_attempt_at: None,
667            last_publish_error: None,
668            event: RuntimeEvent {
669                event_type,
670                job_id: job_identity.job_id.clone(),
671                thread_id: diagnostics.thread_id.clone(),
672                correlation_id: job_identity.correlation_id.clone(),
673                causation_id: job_identity.causation_id.clone(),
674                trace_id: job_identity.trace_id.clone(),
675                sttp_input_node_id: job_identity.sttp_input_node_id.clone(),
676                sttp_output_node_id,
677                execution_id,
678                input_memory_query_id: diagnostics.input_memory_query_id.clone(),
679                input_memory_query_fingerprint: diagnostics
680                    .input_memory_query_fingerprint
681                    .clone(),
682                output_memory_node_id: diagnostics.output_memory_node_id.clone(),
683                retrieval_path: diagnostics.retrieval_path.clone(),
684                occurred_at: now,
685                message,
686            },
687        };
688
689        self.outbox_store.insert(event).await
690    }
691
692    #[allow(clippy::too_many_arguments)]
693    async fn append_job_attempt(
694        &self,
695        job_id: &str,
696        worker_id: &str,
697        attempt_number: u32,
698        started_at: DateTime<Utc>,
699        finished_at: DateTime<Utc>,
700        outcome: JobAttemptOutcome,
701        error_message: Option<String>,
702        sttp_output_node_id: Option<String>,
703        execution_id: Option<String>,
704        diagnostics_envelope: &runtime_diagnostics_helpers::RuntimeDiagnosticsEnvelope,
705        diagnostics: Option<String>,
706    ) -> Result<()> {
707        let attempt = JobAttempt {
708            attempt_id: self.id_generator.next_id(&format!("attempt-{job_id}")),
709            job_id: job_id.to_string(),
710            attempt_number,
711            worker_id: worker_id.to_string(),
712            started_at,
713            finished_at,
714            outcome,
715            error_message,
716            sttp_output_node_id,
717            execution_id,
718            guardrail_code: diagnostics_envelope.guardrail_code.clone(),
719            policy_reason: diagnostics_envelope.policy_reason.clone(),
720            duration_ms: diagnostics_envelope.duration_ms,
721            diagnostics,
722        };
723
724        self.job_attempt_store.insert(attempt).await
725    }
726
727    fn extract_diagnostics_envelope(
728        diagnostics: Option<&str>,
729    ) -> runtime_diagnostics_helpers::RuntimeDiagnosticsEnvelope {
730        runtime_diagnostics_helpers::extract_runtime_diagnostics_envelope(diagnostics)
731    }
732
733}
734
735#[derive(Clone, Default)]
736pub struct InMemoryJobStore {
737    jobs: Arc<RwLock<HashMap<String, Job>>>,
738}
739
740#[async_trait]
741impl JobStore for InMemoryJobStore {
742    async fn insert(&self, job: Job) -> Result<()> {
743        let mut state = self
744            .jobs
745            .write()
746            .map_err(|_| StasisError::PortFailure("job store lock poisoned".to_string()))?;
747
748        state.insert(job.id.clone(), job);
749        Ok(())
750    }
751
752    async fn save(&self, job: Job) -> Result<()> {
753        let mut state = self
754            .jobs
755            .write()
756            .map_err(|_| StasisError::PortFailure("job store lock poisoned".to_string()))?;
757
758        state.insert(job.id.clone(), job);
759        Ok(())
760    }
761
762    async fn get(&self, id: &str) -> Result<Option<Job>> {
763        let state = self
764            .jobs
765            .read()
766            .map_err(|_| StasisError::PortFailure("job store lock poisoned".to_string()))?;
767
768        Ok(state.get(id).cloned())
769    }
770
771    async fn lease_due(
772        &self,
773        queue: &str,
774        worker_id: &str,
775        now: DateTime<Utc>,
776        lease_seconds: i64,
777    ) -> Result<Option<Job>> {
778        let mut state = self
779            .jobs
780            .write()
781            .map_err(|_| StasisError::PortFailure("job store lock poisoned".to_string()))?;
782
783        let selected_id = state
784            .iter()
785            .filter(|(_, job)| {
786                let lease_expired = job
787                    .lease_expires_at
788                    .map(|expiry| expiry <= now)
789                    .unwrap_or(true);
790
791                job.queue == queue
792                    && job.state == JobState::Enqueued
793                    && job.scheduled_at <= now
794                    && lease_expired
795            })
796            .min_by_key(|(_, job)| (job.scheduled_at, job.priority))
797            .map(|(id, _)| id.clone());
798
799        let Some(job_id) = selected_id else {
800            return Ok(None);
801        };
802
803        let Some(job) = state.get_mut(&job_id) else {
804            return Ok(None);
805        };
806
807        job.state = JobState::Leased;
808        job.lease_owner = Some(worker_id.to_string());
809        job.lease_expires_at = Some(now + Duration::seconds(lease_seconds));
810        job.heartbeat_at = Some(now);
811
812        Ok(Some(job.clone()))
813    }
814
815    async fn heartbeat(&self, job_id: &str, worker_id: &str, now: DateTime<Utc>) -> Result<()> {
816        let mut state = self
817            .jobs
818            .write()
819            .map_err(|_| StasisError::PortFailure("job store lock poisoned".to_string()))?;
820
821        let Some(job) = state.get_mut(job_id) else {
822            return Ok(());
823        };
824
825        if job.lease_owner.as_deref() == Some(worker_id) {
826            job.heartbeat_at = Some(now);
827        }
828
829        Ok(())
830    }
831
832    async fn list_by_state(&self, state_filter: JobState) -> Result<Vec<Job>> {
833        let state = self
834            .jobs
835            .read()
836            .map_err(|_| StasisError::PortFailure("job store lock poisoned".to_string()))?;
837
838        Ok(state
839            .values()
840            .filter(|job| job.state == state_filter)
841            .cloned()
842            .collect())
843    }
844
845    async fn prune_terminal_before(&self, cutoff: DateTime<Utc>) -> Result<usize> {
846        let mut state = self
847            .jobs
848            .write()
849            .map_err(|_| StasisError::PortFailure("job store lock poisoned".to_string()))?;
850
851        let before = state.len();
852        state.retain(|_, job| {
853            let terminal = matches!(
854                job.state,
855                JobState::Succeeded | JobState::Failed | JobState::DeadLetter | JobState::Canceled
856            );
857            let old_enough = job.finished_at.map(|t| t <= cutoff).unwrap_or(false);
858            !(terminal && old_enough)
859        });
860
861        Ok(before.saturating_sub(state.len()))
862    }
863}
864
865#[derive(Clone, Default)]
866pub struct InMemoryRecurringStore {
867    defs: Arc<RwLock<HashMap<String, RecurringDefinition>>>,
868}
869
870#[derive(Clone, Default)]
871pub struct InMemoryOutboxStore {
872    events: Arc<RwLock<HashMap<String, OutboxEvent>>>,
873}
874
875#[derive(Clone, Default)]
876pub struct InMemoryJobAttemptStore {
877    attempts: Arc<RwLock<HashMap<String, Vec<JobAttempt>>>>,
878}
879
880impl InMemoryJobAttemptStore {
881    fn list_filtered_attempts<F>(&self, predicate: F) -> Result<Vec<JobAttempt>>
882    where
883        F: Fn(&JobAttempt) -> bool,
884    {
885        let state = self
886            .attempts
887            .read()
888            .map_err(|_| StasisError::PortFailure("job attempt store lock poisoned".to_string()))?;
889
890        let mut attempts: Vec<JobAttempt> = state
891            .values()
892            .flat_map(|attempts| attempts.iter())
893            .filter(|attempt| predicate(attempt))
894            .cloned()
895            .collect();
896        attempts.sort_by_key(|attempt| attempt.attempt_number);
897        Ok(attempts)
898    }
899}
900
901#[async_trait]
902impl JobAttemptStore for InMemoryJobAttemptStore {
903    async fn insert(&self, attempt: JobAttempt) -> Result<()> {
904        let mut state = self
905            .attempts
906            .write()
907            .map_err(|_| StasisError::PortFailure("job attempt store lock poisoned".to_string()))?;
908
909        state
910            .entry(attempt.job_id.clone())
911            .or_insert_with(Vec::new)
912            .push(attempt);
913        Ok(())
914    }
915
916    async fn list_by_job_id(&self, job_id: &str) -> Result<Vec<JobAttempt>> {
917        let state = self
918            .attempts
919            .read()
920            .map_err(|_| StasisError::PortFailure("job attempt store lock poisoned".to_string()))?;
921
922        let mut attempts = state.get(job_id).cloned().unwrap_or_default();
923        attempts.sort_by_key(|attempt| attempt.attempt_number);
924        Ok(attempts)
925    }
926
927    async fn list_by_guardrail_code(&self, guardrail_code: &str) -> Result<Vec<JobAttempt>> {
928        self.list_filtered_attempts(|attempt| {
929            attempt.guardrail_code.as_deref() == Some(guardrail_code)
930        })
931    }
932
933    async fn list_by_execution_id(&self, execution_id: &str) -> Result<Vec<JobAttempt>> {
934        self.list_filtered_attempts(|attempt| {
935            attempt.execution_id.as_deref() == Some(execution_id)
936        })
937    }
938
939    async fn prune_finished_before(&self, cutoff: DateTime<Utc>) -> Result<usize> {
940        let mut state = self
941            .attempts
942            .write()
943            .map_err(|_| StasisError::PortFailure("job attempt store lock poisoned".to_string()))?;
944
945        let mut removed = 0usize;
946        for attempts in state.values_mut() {
947            let before = attempts.len();
948            attempts.retain(|attempt| attempt.finished_at > cutoff);
949            removed += before.saturating_sub(attempts.len());
950        }
951        state.retain(|_, attempts| !attempts.is_empty());
952
953        Ok(removed)
954    }
955}
956
957#[async_trait]
958impl OutboxStore for InMemoryOutboxStore {
959    async fn insert(&self, event: OutboxEvent) -> Result<()> {
960        let mut state = self
961            .events
962            .write()
963            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
964
965        state.insert(event.event_id.clone(), event);
966        Ok(())
967    }
968
969    async fn save(&self, event: OutboxEvent) -> Result<()> {
970        let mut state = self
971            .events
972            .write()
973            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
974
975        state.insert(event.event_id.clone(), event);
976        Ok(())
977    }
978
979    async fn get(&self, event_id: &str) -> Result<Option<OutboxEvent>> {
980        let state = self
981            .events
982            .read()
983            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
984
985        Ok(state.get(event_id).cloned())
986    }
987
988    async fn list_pending(&self, limit: usize) -> Result<Vec<OutboxEvent>> {
989        let state = self
990            .events
991            .read()
992            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
993
994        let mut pending: Vec<OutboxEvent> = state
995            .values()
996            .filter(|evt| evt.status == OutboxStatus::Pending)
997            .cloned()
998            .collect();
999
1000        pending.sort_by_key(|evt| evt.next_attempt_at.unwrap_or(evt.event.occurred_at));
1001        pending.truncate(limit);
1002        Ok(pending)
1003    }
1004
1005    async fn list_by_job_id(&self, job_id: &str) -> Result<Vec<OutboxEvent>> {
1006        let state = self
1007            .events
1008            .read()
1009            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
1010
1011        let mut events: Vec<OutboxEvent> = state
1012            .values()
1013            .filter(|evt| evt.event.job_id == job_id)
1014            .cloned()
1015            .collect();
1016
1017        events.sort_by_key(|evt| evt.event.occurred_at);
1018        Ok(events)
1019    }
1020
1021    async fn list_by_thread_id(&self, thread_id: &str) -> Result<Vec<OutboxEvent>> {
1022        let state = self
1023            .events
1024            .read()
1025            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
1026
1027        let mut events: Vec<OutboxEvent> = state
1028            .values()
1029            .filter(|evt| evt.event.thread_id.as_deref() == Some(thread_id))
1030            .cloned()
1031            .collect();
1032
1033        events.sort_by_key(|evt| evt.event.occurred_at);
1034        Ok(events)
1035    }
1036
1037    async fn list_by_thread_prefix(&self, thread_prefix: &str) -> Result<Vec<OutboxEvent>> {
1038        let state = self
1039            .events
1040            .read()
1041            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
1042
1043        let mut events: Vec<OutboxEvent> = state
1044            .values()
1045            .filter(|evt| {
1046                evt.event
1047                    .thread_id
1048                    .as_deref()
1049                    .map(|thread_id| thread_id.starts_with(thread_prefix))
1050                    .unwrap_or(false)
1051            })
1052            .cloned()
1053            .collect();
1054
1055        events.sort_by_key(|evt| evt.event.occurred_at);
1056        Ok(events)
1057    }
1058
1059    async fn list_by_execution_id(&self, execution_id: &str) -> Result<Vec<OutboxEvent>> {
1060        let state = self
1061            .events
1062            .read()
1063            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
1064
1065        let mut events: Vec<OutboxEvent> = state
1066            .values()
1067            .filter(|evt| evt.event.execution_id.as_deref() == Some(execution_id))
1068            .cloned()
1069            .collect();
1070
1071        events.sort_by_key(|evt| evt.event.occurred_at);
1072        Ok(events)
1073    }
1074
1075    async fn prune_non_pending_before(&self, cutoff: DateTime<Utc>) -> Result<usize> {
1076        let mut state = self
1077            .events
1078            .write()
1079            .map_err(|_| StasisError::PortFailure("outbox store lock poisoned".to_string()))?;
1080
1081        let before = state.len();
1082        state.retain(|_, evt| {
1083            let terminal = evt.status != OutboxStatus::Pending;
1084            let old_enough = evt.event.occurred_at <= cutoff;
1085            !(terminal && old_enough)
1086        });
1087
1088        Ok(before.saturating_sub(state.len()))
1089    }
1090}
1091
1092#[async_trait]
1093impl RecurringStore for InMemoryRecurringStore {
1094    async fn insert(&self, definition: RecurringDefinition) -> Result<()> {
1095        let mut state = self
1096            .defs
1097            .write()
1098            .map_err(|_| StasisError::PortFailure("recurring store lock poisoned".to_string()))?;
1099
1100        state.insert(definition.id.clone(), definition);
1101        Ok(())
1102    }
1103
1104    async fn save(&self, definition: RecurringDefinition) -> Result<()> {
1105        let mut state = self
1106            .defs
1107            .write()
1108            .map_err(|_| StasisError::PortFailure("recurring store lock poisoned".to_string()))?;
1109
1110        state.insert(definition.id.clone(), definition);
1111        Ok(())
1112    }
1113
1114    async fn lease_due(
1115        &self,
1116        now: DateTime<Utc>,
1117        scheduler_id: &str,
1118        lease_seconds: i64,
1119    ) -> Result<Vec<RecurringDefinition>> {
1120        let mut state = self
1121            .defs
1122            .write()
1123            .map_err(|_| StasisError::PortFailure("recurring store lock poisoned".to_string()))?;
1124
1125        let mut leased = Vec::new();
1126
1127        for definition in state.values_mut() {
1128            let lease_expired = definition
1129                .lease_expires_at
1130                .map(|expiry| expiry <= now)
1131                .unwrap_or(true);
1132
1133            if definition.enabled && definition.next_run_at <= now && lease_expired {
1134                definition.lease_owner = Some(scheduler_id.to_string());
1135                definition.lease_expires_at = Some(now + Duration::seconds(lease_seconds));
1136                leased.push(definition.clone());
1137            }
1138        }
1139
1140        Ok(leased)
1141    }
1142
1143    async fn list(&self) -> Result<Vec<RecurringDefinition>> {
1144        let state = self
1145            .defs
1146            .read()
1147            .map_err(|_| StasisError::PortFailure("recurring store lock poisoned".to_string()))?;
1148
1149        Ok(state.values().cloned().collect())
1150    }
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155    use std::sync::Arc;
1156    use std::sync::atomic::{AtomicUsize, Ordering};
1157
1158    use chrono::{Duration, Utc};
1159
1160    use super::*;
1161
1162    struct AlwaysSuccessHandler;
1163
1164    #[async_trait]
1165    impl JobHandler for AlwaysSuccessHandler {
1166        fn job_type(&self) -> &'static str {
1167            "test.success"
1168        }
1169
1170        async fn execute(&self, _job: &Job) -> Result<JobExecutionOutcome> {
1171            Ok(JobExecutionOutcome::Success {
1172                sttp_output_node_id: "sttp:out:1".to_string(),
1173                execution_id: None,
1174                diagnostics: None,
1175            })
1176        }
1177    }
1178
1179    struct FlakyHandler {
1180        failures_before_success: usize,
1181        calls: AtomicUsize,
1182    }
1183
1184    impl FlakyHandler {
1185        fn new(failures_before_success: usize) -> Self {
1186            Self {
1187                failures_before_success,
1188                calls: AtomicUsize::new(0),
1189            }
1190        }
1191    }
1192
1193    #[async_trait]
1194    impl JobHandler for FlakyHandler {
1195        fn job_type(&self) -> &'static str {
1196            "test.flaky"
1197        }
1198
1199        async fn execute(&self, _job: &Job) -> Result<JobExecutionOutcome> {
1200            let calls = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
1201
1202            if calls <= self.failures_before_success {
1203                Ok(JobExecutionOutcome::RetryableFailure {
1204                    message: "transient failure".to_string(),
1205                    execution_id: None,
1206                    diagnostics: None,
1207                })
1208            } else {
1209                Ok(JobExecutionOutcome::Success {
1210                    sttp_output_node_id: "sttp:out:flaky".to_string(),
1211                    execution_id: None,
1212                    diagnostics: None,
1213                })
1214            }
1215        }
1216    }
1217
1218    struct AlwaysFatalHandler;
1219
1220    #[async_trait]
1221    impl JobHandler for AlwaysFatalHandler {
1222        fn job_type(&self) -> &'static str {
1223            "test.fatal"
1224        }
1225
1226        async fn execute(&self, _job: &Job) -> Result<JobExecutionOutcome> {
1227            Ok(JobExecutionOutcome::FatalFailure {
1228                message: "non retryable".to_string(),
1229                execution_id: None,
1230                diagnostics: None,
1231            })
1232        }
1233    }
1234
1235    #[derive(Clone)]
1236    struct FlakyPublisher {
1237        failures_before_success: usize,
1238        calls: Arc<AtomicUsize>,
1239    }
1240
1241    #[async_trait]
1242    impl EventPublisher for FlakyPublisher {
1243        async fn publish(&self, _event: &OutboxEvent) -> Result<()> {
1244            let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
1245            if call <= self.failures_before_success {
1246                return Err(StasisError::PortFailure(
1247                    "synthetic publish failure".to_string(),
1248                ));
1249            }
1250
1251            Ok(())
1252        }
1253    }
1254
1255    fn build_new_job(job_type: &str, now: chrono::DateTime<Utc>) -> NewJob {
1256        NewJob {
1257            id: format!("job-{job_type}"),
1258            queue: "default".to_string(),
1259            job_type: job_type.to_string(),
1260            payload_ref: "payload:ref".to_string(),
1261            priority: 100,
1262            max_attempts: 3,
1263            idempotency_key: format!("idem-{job_type}"),
1264            correlation_id: "corr-1".to_string(),
1265            causation_id: "cause-1".to_string(),
1266            trace_id: "trace-1".to_string(),
1267            sttp_input_node_id: "sttp:in:1".to_string(),
1268            scheduled_at: now,
1269            backoff_policy: crate::domain::runtime::job::BackoffPolicy {
1270                base_delay_seconds: 1,
1271                max_delay_seconds: 8,
1272            },
1273        }
1274    }
1275
1276    #[tokio::test]
1277    async fn lease_and_successful_processing_works() {
1278        let runtime = InMemoryRuntime::new();
1279        runtime
1280            .register_handler(AlwaysSuccessHandler)
1281            .expect("handler should register");
1282
1283        let now = Utc::now();
1284        runtime
1285            .enqueue(build_new_job("test.success", now))
1286            .await
1287            .expect("job should enqueue");
1288
1289        let processed = runtime
1290            .process_once("default", "worker-1", now)
1291            .await
1292            .expect("processing should succeed");
1293
1294        assert_eq!(processed, Some("job-test.success".to_string()));
1295
1296        let job = runtime
1297            .job_store
1298            .get("job-test.success")
1299            .await
1300            .expect("job get should succeed")
1301            .expect("job should exist");
1302
1303        assert_eq!(job.state, JobState::Succeeded);
1304        assert_eq!(job.sttp_output_node_id, Some("sttp:out:1".to_string()));
1305    }
1306
1307    #[tokio::test]
1308    async fn retry_path_reenqueues_then_succeeds() {
1309        let runtime = InMemoryRuntime::new();
1310        runtime
1311            .register_handler(FlakyHandler::new(1))
1312            .expect("handler should register");
1313
1314        let now = Utc::now();
1315        runtime
1316            .enqueue(build_new_job("test.flaky", now))
1317            .await
1318            .expect("job should enqueue");
1319
1320        runtime
1321            .process_once("default", "worker-1", now)
1322            .await
1323            .expect("first run should complete");
1324
1325        let retry_job = runtime
1326            .job_store
1327            .get("job-test.flaky")
1328            .await
1329            .expect("job get should succeed")
1330            .expect("job should exist");
1331
1332        assert_eq!(retry_job.state, JobState::Enqueued);
1333        assert_eq!(retry_job.attempts, 1);
1334        assert!(retry_job.scheduled_at > now);
1335
1336        runtime
1337            .process_once("default", "worker-2", now + Duration::seconds(2))
1338            .await
1339            .expect("second run should complete");
1340
1341        let final_job = runtime
1342            .job_store
1343            .get("job-test.flaky")
1344            .await
1345            .expect("job get should succeed")
1346            .expect("job should exist");
1347
1348        assert_eq!(final_job.state, JobState::Succeeded);
1349        assert_eq!(final_job.attempts, 1);
1350    }
1351
1352    #[tokio::test]
1353    async fn dead_letter_path_works_for_fatal_error() {
1354        let runtime = InMemoryRuntime::new();
1355        runtime
1356            .register_handler(AlwaysFatalHandler)
1357            .expect("handler should register");
1358
1359        let now = Utc::now();
1360        runtime
1361            .enqueue(build_new_job("test.fatal", now))
1362            .await
1363            .expect("job should enqueue");
1364
1365        runtime
1366            .process_once("default", "worker-1", now)
1367            .await
1368            .expect("processing should complete");
1369
1370        let job = runtime
1371            .job_store
1372            .get("job-test.fatal")
1373            .await
1374            .expect("job get should succeed")
1375            .expect("job should exist");
1376
1377        assert_eq!(job.state, JobState::DeadLetter);
1378        assert_eq!(job.attempts, 1);
1379        assert_eq!(job.last_error, Some("non retryable".to_string()));
1380    }
1381
1382    #[tokio::test]
1383    async fn dead_letter_jobs_can_be_replayed() {
1384        let runtime = InMemoryRuntime::new();
1385        runtime
1386            .register_handler(AlwaysFatalHandler)
1387            .expect("handler should register");
1388
1389        let now = Utc::now();
1390        runtime
1391            .enqueue(build_new_job("test.fatal", now))
1392            .await
1393            .expect("job should enqueue");
1394
1395        runtime
1396            .process_once("default", "worker-1", now)
1397            .await
1398            .expect("processing should complete");
1399
1400        let replayed = runtime
1401            .replay_dead_letter("job-test.fatal", now + Duration::seconds(5))
1402            .await
1403            .expect("replay should succeed");
1404        assert!(replayed);
1405
1406        let replayed_job = runtime
1407            .job_store
1408            .get("job-test.fatal")
1409            .await
1410            .expect("job get should succeed")
1411            .expect("job should exist");
1412
1413        assert_eq!(replayed_job.state, JobState::Enqueued);
1414        assert_eq!(replayed_job.attempts, 0);
1415        assert_eq!(replayed_job.last_error, None);
1416    }
1417
1418    #[tokio::test]
1419    async fn outbox_publish_failures_are_retried_with_backoff() {
1420        let runtime = InMemoryRuntime::new();
1421        runtime
1422            .register_handler(AlwaysSuccessHandler)
1423            .expect("handler should register");
1424        runtime
1425            .configure_outbox_publish_policy(OutboxPublishPolicy {
1426                max_attempts: 3,
1427                base_delay_seconds: 1,
1428                max_delay_seconds: 8,
1429            })
1430            .expect("policy should configure");
1431
1432        let calls = Arc::new(AtomicUsize::new(0));
1433        runtime
1434            .register_event_publisher(FlakyPublisher {
1435                failures_before_success: 1,
1436                calls: calls.clone(),
1437            })
1438            .expect("publisher should register");
1439
1440        let now = Utc::now();
1441        runtime
1442            .enqueue(build_new_job("test.success", now))
1443            .await
1444            .expect("job should enqueue");
1445
1446        runtime
1447            .process_once("default", "worker-1", now)
1448            .await
1449            .expect("processing should succeed");
1450
1451        let first_publish = runtime
1452            .publish_pending_events(10, now)
1453            .await
1454            .expect("first publish attempt should complete");
1455        assert_eq!(first_publish, 0);
1456
1457        let pending = runtime
1458            .outbox_store
1459            .list_pending(10)
1460            .await
1461            .expect("pending list should succeed");
1462        assert_eq!(pending.len(), 1);
1463        assert_eq!(pending[0].publish_attempts, 1);
1464        assert_eq!(pending[0].status, OutboxStatus::Pending);
1465        assert_eq!(
1466            pending[0].last_publish_error,
1467            Some("port failure: synthetic publish failure".to_string())
1468        );
1469        assert_eq!(pending[0].next_attempt_at, Some(now + Duration::seconds(1)));
1470
1471        let premature = runtime
1472            .publish_pending_events(10, now)
1473            .await
1474            .expect("premature publish attempt should complete");
1475        assert_eq!(premature, 0);
1476
1477        let second_publish = runtime
1478            .publish_pending_events(10, now + Duration::seconds(1))
1479            .await
1480            .expect("second publish attempt should complete");
1481        assert_eq!(second_publish, 1);
1482        assert_eq!(calls.load(Ordering::SeqCst), 2);
1483
1484        let pending_after = runtime
1485            .outbox_store
1486            .list_pending(10)
1487            .await
1488            .expect("pending list should succeed");
1489        assert!(pending_after.is_empty());
1490    }
1491
1492    #[tokio::test]
1493    async fn due_outbox_event_is_not_starved_by_future_retry_when_limit_is_low() {
1494        let runtime = InMemoryRuntime::new();
1495        runtime
1496            .register_handler(AlwaysSuccessHandler)
1497            .expect("handler should register");
1498        runtime
1499            .configure_outbox_publish_policy(OutboxPublishPolicy {
1500                max_attempts: 3,
1501                base_delay_seconds: 1,
1502                max_delay_seconds: 8,
1503            })
1504            .expect("policy should configure");
1505
1506        let calls = Arc::new(AtomicUsize::new(0));
1507        runtime
1508            .register_event_publisher(FlakyPublisher {
1509                failures_before_success: 1,
1510                calls: Arc::clone(&calls),
1511            })
1512            .expect("publisher should register");
1513
1514        let now = Utc::now();
1515        runtime
1516            .enqueue(NewJob {
1517                id: "job-fairness-1".to_string(),
1518                queue: "default".to_string(),
1519                job_type: "test.success".to_string(),
1520                payload_ref: "payload:fairness-1".to_string(),
1521                priority: 100,
1522                max_attempts: 1,
1523                idempotency_key: "idem-fairness-1".to_string(),
1524                correlation_id: "corr-fairness-1".to_string(),
1525                causation_id: "cause-fairness-1".to_string(),
1526                trace_id: "trace-fairness-1".to_string(),
1527                sttp_input_node_id: "sttp:in:fairness-1".to_string(),
1528                scheduled_at: now,
1529                backoff_policy: crate::domain::runtime::job::BackoffPolicy::default(),
1530            })
1531            .await
1532            .expect("first job should enqueue");
1533        runtime
1534            .enqueue(NewJob {
1535                id: "job-fairness-2".to_string(),
1536                queue: "default".to_string(),
1537                job_type: "test.success".to_string(),
1538                payload_ref: "payload:fairness-2".to_string(),
1539                priority: 100,
1540                max_attempts: 1,
1541                idempotency_key: "idem-fairness-2".to_string(),
1542                correlation_id: "corr-fairness-2".to_string(),
1543                causation_id: "cause-fairness-2".to_string(),
1544                trace_id: "trace-fairness-2".to_string(),
1545                sttp_input_node_id: "sttp:in:fairness-2".to_string(),
1546                scheduled_at: now,
1547                backoff_policy: crate::domain::runtime::job::BackoffPolicy::default(),
1548            })
1549            .await
1550            .expect("second job should enqueue");
1551
1552        runtime
1553            .process_once("default", "worker-1", now)
1554            .await
1555            .expect("first processing should succeed");
1556        runtime
1557            .process_once("default", "worker-1", now + Duration::milliseconds(1))
1558            .await
1559            .expect("second processing should succeed");
1560
1561        let first_attempt = runtime
1562            .publish_pending_events(1, now + Duration::milliseconds(2))
1563            .await
1564            .expect("first publish attempt should complete");
1565        assert_eq!(first_attempt, 0);
1566
1567        let second_attempt = runtime
1568            .publish_pending_events(1, now + Duration::milliseconds(2))
1569            .await
1570            .expect("second publish attempt should complete");
1571        assert_eq!(second_attempt, 1);
1572
1573        let pending_after_second = runtime
1574            .outbox_store
1575            .list_pending(10)
1576            .await
1577            .expect("pending list should succeed");
1578        assert_eq!(pending_after_second.len(), 1);
1579        assert_eq!(pending_after_second[0].publish_attempts, 1);
1580        assert_eq!(
1581            pending_after_second[0].next_attempt_at,
1582            Some(now + Duration::milliseconds(2) + Duration::seconds(1))
1583        );
1584
1585        let third_attempt = runtime
1586            .publish_pending_events(1, now + Duration::seconds(1) + Duration::milliseconds(2))
1587            .await
1588            .expect("third publish attempt should complete");
1589        assert_eq!(third_attempt, 1);
1590        assert_eq!(calls.load(Ordering::SeqCst), 3);
1591
1592        let pending_final = runtime
1593            .outbox_store
1594            .list_pending(10)
1595            .await
1596            .expect("pending list should succeed");
1597        assert!(pending_final.is_empty());
1598    }
1599
1600    #[tokio::test]
1601    async fn outbox_backlog_completes_within_bounded_ticks_under_mixed_failures() {
1602        let runtime = InMemoryRuntime::new();
1603        runtime
1604            .register_handler(AlwaysSuccessHandler)
1605            .expect("handler should register");
1606        runtime
1607            .configure_outbox_publish_policy(OutboxPublishPolicy {
1608                max_attempts: 5,
1609                base_delay_seconds: 1,
1610                max_delay_seconds: 8,
1611            })
1612            .expect("policy should configure");
1613
1614        let calls = Arc::new(AtomicUsize::new(0));
1615        runtime
1616            .register_event_publisher(FlakyPublisher {
1617                failures_before_success: 3,
1618                calls: Arc::clone(&calls),
1619            })
1620            .expect("publisher should register");
1621
1622        let now = Utc::now();
1623        let mut job_ids = Vec::new();
1624        for idx in 0..12 {
1625            let job_id = format!("job-backlog-{idx}");
1626            runtime
1627                .enqueue(NewJob {
1628                    id: job_id.clone(),
1629                    queue: "default".to_string(),
1630                    job_type: "test.success".to_string(),
1631                    payload_ref: format!("payload:backlog-{idx}"),
1632                    priority: 100,
1633                    max_attempts: 1,
1634                    idempotency_key: format!("idem-backlog-{idx}"),
1635                    correlation_id: format!("corr-backlog-{idx}"),
1636                    causation_id: format!("cause-backlog-{idx}"),
1637                    trace_id: format!("trace-backlog-{idx}"),
1638                    sttp_input_node_id: format!("sttp:in:backlog-{idx}"),
1639                    scheduled_at: now,
1640                    backoff_policy: crate::domain::runtime::job::BackoffPolicy::default(),
1641                })
1642                .await
1643                .expect("job should enqueue");
1644            job_ids.push(job_id);
1645        }
1646
1647        for idx in 0..job_ids.len() {
1648            runtime
1649                .process_once("default", "worker-1", now + Duration::milliseconds(idx as i64))
1650                .await
1651                .expect("processing should succeed");
1652        }
1653
1654        let mut total_published = 0usize;
1655        for tick in 0..20 {
1656            total_published += runtime
1657                .publish_pending_events(3, now + Duration::seconds(tick))
1658                .await
1659                .expect("publish sweep should succeed");
1660
1661            let pending = runtime
1662                .outbox_store
1663                .list_pending(50)
1664                .await
1665                .expect("pending list should succeed");
1666            if pending.is_empty() {
1667                break;
1668            }
1669        }
1670
1671        let pending_final = runtime
1672            .outbox_store
1673            .list_pending(50)
1674            .await
1675            .expect("pending list should succeed");
1676        assert!(
1677            pending_final.is_empty(),
1678            "expected backlog to drain within bounded ticks"
1679        );
1680        assert_eq!(total_published, job_ids.len());
1681
1682        for job_id in job_ids {
1683            let events = runtime
1684                .outbox_store
1685                .list_by_job_id(&job_id)
1686                .await
1687                .expect("outbox list by job should succeed");
1688            assert_eq!(events.len(), 1);
1689            assert_eq!(events[0].status, OutboxStatus::Published);
1690            assert!(events[0].publish_attempts >= 1);
1691        }
1692
1693        assert_eq!(calls.load(Ordering::SeqCst), 15);
1694    }
1695
1696    #[tokio::test]
1697    async fn recurring_materialization_creates_due_jobs() {
1698        let runtime = InMemoryRuntime::new();
1699
1700        let now = Utc::now();
1701        runtime
1702            .register_recurring(RecurringDefinition {
1703                id: "recur.scrape".to_string(),
1704                queue: "default".to_string(),
1705                job_type: "test.success".to_string(),
1706                payload_template_ref: "sttp:in:recurring".to_string(),
1707                cron_expr: "0/1 * * * * * *".to_string(),
1708                timezone: "UTC".to_string(),
1709                jitter_seconds: 0,
1710                enabled: true,
1711                max_attempts: 4,
1712                next_run_at: now,
1713                last_run_at: None,
1714                lease_owner: None,
1715                lease_expires_at: None,
1716            })
1717            .await
1718            .expect("recurring should register");
1719
1720        let created = runtime
1721            .materialize_recurring(now, "scheduler-1")
1722            .await
1723            .expect("materialization should succeed");
1724
1725        assert_eq!(created, 1);
1726
1727        let enqueued = runtime
1728            .job_store
1729            .list_by_state(JobState::Enqueued)
1730            .await
1731            .expect("list should succeed");
1732
1733        assert_eq!(enqueued.len(), 1);
1734        assert!(enqueued[0].id.starts_with("recur.scrape-"));
1735
1736        let defs = runtime
1737            .recurring_store
1738            .list()
1739            .await
1740            .expect("list recurring should succeed");
1741
1742        assert_eq!(defs.len(), 1);
1743        assert_eq!(defs[0].last_run_at, Some(now));
1744        assert!(defs[0].next_run_at > now);
1745    }
1746}