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