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