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}