1#![forbid(unsafe_code)]
13#![deny(missing_docs)]
14
15pub use nasaga_core::{SagaPayload, SagaPayloadContract, SagaPayloadError};
16mod latency;
17pub use latency::{
18 render_saga_latency_metrics, saga_latency_snapshot, SagaLatencySnapshot, SAGA_LATENCY_BUCKETS,
19};
20mod catalog;
21mod envelope;
22mod management;
23mod observability;
24mod orchestrator;
25mod participant;
26mod registry;
27mod scheduled;
28mod timers;
29mod transaction;
30#[cfg(feature = "kafka")]
31mod transport;
32mod transport_auth;
33#[cfg(feature = "grpc-transport")]
34mod transport_grpc;
35#[cfg(feature = "redis-stream")]
36mod transport_redis;
37#[cfg(any(
38 feature = "kafka",
39 feature = "redis-stream",
40 feature = "grpc-transport"
41))]
42mod transport_shared;
43
44pub use catalog::{
45 select_capability_route, select_capability_routes, validate_capability_route_contract,
46 validate_redis_stream_route, CapabilityDescriptor, CapabilityReceipt, DefinitionActivationGate,
47 DefinitionArtifact, DefinitionCancelMode, DefinitionCatalogError, DefinitionCompensation,
48 DefinitionLifecycle, DefinitionLifecycleOperation, DefinitionPublishDisposition,
49 DefinitionRecord, DefinitionResolution, DefinitionResolutionMode, DefinitionStepArtifact,
50 DefinitionTimeoutPolicy, DynamicCatalogSnapshot, RegisteredCapability,
51 CATALOG_OBJECT_KEY_MAX_LEN,
52};
53pub use envelope::{
54 canonical_bytes, derive_result_event_id, SagaCommandEnvelope, SagaResultEnvelope,
55 VerifiedIdentity, COMMAND_EVENT_TYPE, RESULT_EVENT_TYPE, SAGA_AGGREGATE_TYPE,
56};
57pub use management::{
58 SagaAuditPage, SagaAuditPageCursor, SagaAuditRecord, SagaAuditTrail, SagaManagementContext,
59 SagaManagementError, SagaManagementExpectation, SagaManagementPermission,
60};
61pub use observability::SagaOperationalMetrics;
62pub use orchestrator::{
63 HandleOutcome, Orchestrator, OrchestratorConfig, SagaConcurrencyError, StartOutcome,
64 StartSagaError, StartSagaRequest, TenantActionRate, TimerOutcome,
65};
66pub use participant::{
67 AuthenticatedParticipantRuntime, ParticipantCommandTrust, ParticipantHandled,
68 ParticipantRuntime, SagaCommandService,
69};
70pub use registry::DefinitionRegistry;
71pub use scheduled::{
72 derive_scheduled_business_key, ScheduledBatchReport, ScheduledBatchSpec, ScheduledItem,
73};
74pub use timers::{
75 derive_timer_id, KIND_CANCEL_TIMEOUT, KIND_COMPENSATE_TIMEOUT,
76 KIND_COMPENSATION_RESOLUTION_BUDGET, KIND_FORWARD_RESOLUTION_BUDGET, KIND_INSTANCE_DEADLINE,
77 KIND_RESOLUTION_BUDGET, KIND_RESOLVE_TIMEOUT, KIND_STEP_TIMEOUT,
78};
79pub use transaction::SagaTransactionError;
80#[cfg(feature = "kafka")]
81pub use transport::{
82 SagaCommandRoute, SagaKafkaCommandConsumer, SagaKafkaCommandConsumerConfig,
83 SagaKafkaResultConsumer, SagaKafkaResultConsumerConfig,
84};
85pub use transport_auth::{
86 render_saga_http_command_dlt_metric, SagaHttpCredentialSource, SagaHttpCredentials,
87 SagaHttpMessageAuthError, SagaHttpMessageAuthFailure, SagaHttpMessageAuthenticator,
88 SagaHttpReplayGuard, SagaHttpReplayMetricAggregate, SagaHttpReplayMetrics, SagaHttpReplayPlane,
89 SagaHttpSignedMessage,
90};
91#[cfg(feature = "grpc-transport")]
92pub use transport_grpc::{
93 orchestrator_proto, outbox_disposition_of, proto as grpc_proto, SagaGrpcBindingError,
94 SagaGrpcCommandServer, SagaGrpcCommandTransportService, SagaGrpcPeerBinding,
95 SagaGrpcPeerIdentity, SagaGrpcReceipt, SagaGrpcResultServer, SagaGrpcResultTransportService,
96};
97#[cfg(feature = "redis-stream")]
98pub use transport_redis::{
99 publisher_duplicate_hints_total, safe_trim_by_group_frontier, stream_group_backlog,
100 verify_stream_transport_ready, SagaRedisStreamCommandConsumer, SagaRedisStreamPublisher,
101 SagaRedisStreamResultConsumer, SagaStreamAuth, SagaStreamConsumerConfig, SagaStreamPoller,
102 SagaStreamVerificationKey, StreamPollReport,
103};
104#[cfg(any(
105 feature = "kafka",
106 feature = "redis-stream",
107 feature = "grpc-transport"
108))]
109pub use transport_shared::{ParticipantCommandHandler, SagaCommandHandler, SagaResultHandler};
110
111use nasaga_core::{CancelMode, Compensation, ResolutionMode};
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum SagaCommandProcessingError {
117 ProducerUnauthorized,
119 IdentityInvalid,
121 RouteUnauthorized,
123 ContractInvalid,
125}
126
127impl SagaCommandProcessingError {
128 pub const fn dead_letter_reason(self) -> &'static str {
134 match self {
135 Self::ProducerUnauthorized => "saga_command_producer_unauthorized",
136 Self::IdentityInvalid => "saga_command_identity_invalid",
137 Self::RouteUnauthorized => "saga_command_route_unauthorized",
138 Self::ContractInvalid => "saga_command_contract_invalid",
139 }
140 }
141
142 pub fn from_dead_letter_reason(reason: &str) -> Option<Self> {
148 match reason {
149 "saga_command_producer_unauthorized" => Some(Self::ProducerUnauthorized),
150 "saga_command_identity_invalid" => Some(Self::IdentityInvalid),
151 "saga_command_route_unauthorized" => Some(Self::RouteUnauthorized),
152 "saga_command_contract_invalid" => Some(Self::ContractInvalid),
153 _ => None,
154 }
155 }
156}
157
158impl std::fmt::Display for SagaCommandProcessingError {
159 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 formatter.write_str(match self {
166 Self::ProducerUnauthorized => "saga command producer is unauthorized",
167 Self::IdentityInvalid => "saga command identity is invalid",
168 Self::RouteUnauthorized => "saga command route is unauthorized",
169 Self::ContractInvalid => "saga command contract is invalid",
170 })
171 }
172}
173
174impl std::error::Error for SagaCommandProcessingError {}
175
176pub const SAGA_COMMAND_DEAD_LETTER_REASONS: &[&str] = &[
178 "saga_command_producer_unauthorized",
179 "saga_command_identity_invalid",
180 "saga_command_route_unauthorized",
181 "saga_command_contract_invalid",
182];
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum SagaResultProcessingError {
188 Paused,
190 AuthorityUnavailable,
192 ProducerUnauthorized,
194 IdentityInvalid,
196 ContractInvalid,
198}
199
200impl SagaResultProcessingError {
201 pub const fn dead_letter_reason(self) -> Option<&'static str> {
207 match self {
208 Self::Paused | Self::AuthorityUnavailable => None,
209 Self::ProducerUnauthorized => Some("saga_result_producer_unauthorized"),
210 Self::IdentityInvalid => Some("saga_result_identity_invalid"),
211 Self::ContractInvalid => Some("saga_result_contract_invalid"),
212 }
213 }
214}
215
216impl std::fmt::Display for SagaResultProcessingError {
217 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 formatter.write_str(match self {
224 Self::Paused => "saga result processing deferred while instance is paused",
225 Self::AuthorityUnavailable => "saga result processing authority is unavailable",
226 Self::ProducerUnauthorized => "saga result producer is unauthorized",
227 Self::IdentityInvalid => "saga result identity is invalid",
228 Self::ContractInvalid => "saga result contract is invalid",
229 })
230 }
231}
232
233impl std::error::Error for SagaResultProcessingError {}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum CommandDeliveryDisposition {
238 Retry,
240 Defer,
242 DeadLetter,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub struct CommandDeliveryPolicy {
249 pub max_retries: u32,
251}
252
253impl Default for CommandDeliveryPolicy {
254 fn default() -> Self {
260 Self { max_retries: 16 }
261 }
262}
263
264impl CommandDeliveryPolicy {
265 pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> CommandDeliveryDisposition {
271 let disposition = classify_command_delivery_error(error);
272 if disposition == CommandDeliveryDisposition::Retry && retry_attempt > self.max_retries {
273 CommandDeliveryDisposition::DeadLetter
274 } else {
275 disposition
276 }
277 }
278}
279
280pub fn command_dead_letter_reason(error: &anyhow::Error) -> Option<&'static str> {
286 error.chain().find_map(|cause| {
287 cause
288 .downcast_ref::<SagaCommandProcessingError>()
289 .map(|error| error.dead_letter_reason())
290 })
291}
292
293pub fn classify_command_delivery_error(error: &anyhow::Error) -> CommandDeliveryDisposition {
299 if command_dead_letter_reason(error).is_some() {
300 CommandDeliveryDisposition::DeadLetter
301 } else if error.chain().any(|cause| {
302 cause
303 .downcast_ref::<SagaTransactionError>()
304 .is_some_and(|error| error.requires_unbounded_redelivery())
305 }) {
306 CommandDeliveryDisposition::Defer
307 } else {
308 CommandDeliveryDisposition::Retry
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ResultDeliveryDisposition {
315 Retry,
317 Defer,
319 DeadLetter,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub struct ResultDeliveryPolicy {
326 pub max_retries: u32,
328}
329
330impl Default for ResultDeliveryPolicy {
331 fn default() -> Self {
337 Self { max_retries: 16 }
338 }
339}
340
341impl ResultDeliveryPolicy {
342 pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> ResultDeliveryDisposition {
348 let disposition = classify_result_delivery_error(error);
349 if disposition == ResultDeliveryDisposition::Retry && retry_attempt > self.max_retries {
350 ResultDeliveryDisposition::DeadLetter
351 } else {
352 disposition
353 }
354 }
355}
356
357pub fn classify_result_delivery_error(error: &anyhow::Error) -> ResultDeliveryDisposition {
363 if error.chain().any(|cause| {
364 cause
365 .downcast_ref::<SagaTransactionError>()
366 .is_some_and(|error| error.requires_unbounded_redelivery())
367 }) {
368 return ResultDeliveryDisposition::Defer;
369 }
370 let classified = error
371 .chain()
372 .find_map(|cause| cause.downcast_ref::<SagaResultProcessingError>().copied());
373 if matches!(
374 classified,
375 Some(SagaResultProcessingError::Paused | SagaResultProcessingError::AuthorityUnavailable)
376 ) {
377 ResultDeliveryDisposition::Defer
378 } else if classified.is_some_and(|error| error.dead_letter_reason().is_some()) {
379 ResultDeliveryDisposition::DeadLetter
380 } else {
381 ResultDeliveryDisposition::Retry
382 }
383}
384
385#[derive(Debug, Clone, Copy)]
387pub struct SagaStepDescriptor {
388 pub payload_content_type: &'static str,
390 pub payload_schema_id: &'static str,
392
393 pub workflow: &'static str,
395 pub definition_version: u32,
397 pub step: &'static str,
399 pub binding: Option<&'static str>,
401 pub service_type: &'static str,
403 pub compensation: Compensation,
405 pub cancel_mode: CancelMode,
407 pub allow_unknown: bool,
409 pub resolution_mode: Option<ResolutionMode>,
411 pub source: &'static str,
413}
414
415#[linkme::distributed_slice]
417pub static COLLECTED_SAGA_STEPS: [SagaStepDescriptor];
418
419#[derive(Clone, Copy)]
424pub struct SagaWorkflowDescriptor {
425 pub factory: fn() -> anyhow::Result<nasaga_core::WorkflowDefinition>,
427 pub source: &'static str,
429}
430
431#[linkme::distributed_slice]
433pub static COLLECTED_SAGA_WORKFLOWS: [SagaWorkflowDescriptor];
434
435pub fn collect_workflow_definitions() -> anyhow::Result<DefinitionRegistry> {
444 let mut registry = DefinitionRegistry::new();
445 for descriptor in COLLECTED_SAGA_WORKFLOWS {
446 let definition = (descriptor.factory)().map_err(|error| {
447 anyhow::anyhow!(
448 "saga workflow definition factory failed at {}: {}",
449 descriptor.source,
450 error
451 )
452 })?;
453 registry.register(definition).map_err(|error| {
454 anyhow::anyhow!(
455 "saga workflow definition is invalid at {}: {}",
456 descriptor.source,
457 error
458 )
459 })?;
460 }
461 Ok(registry)
462}
463
464pub fn verify_descriptors(registry: &DefinitionRegistry) -> anyhow::Result<()> {
470 let mut seen = std::collections::BTreeSet::new();
471 for descriptor in COLLECTED_SAGA_STEPS {
472 nasaga_core::SagaPayloadContract {
473 content_type: descriptor.payload_content_type.to_owned(),
474 schema_id: descriptor.payload_schema_id.to_owned(),
475 }
476 .validate()?;
477
478 let key = (
479 descriptor.workflow,
480 descriptor.definition_version,
481 descriptor.step,
482 );
483 if !seen.insert(key) {
485 anyhow::bail!(
486 "duplicate Saga step `{}` v{} `{}` ({})",
487 descriptor.workflow,
488 descriptor.definition_version,
489 descriptor.step,
490 descriptor.source
491 );
492 }
493 let workflow = nasaga_core::WorkflowName::new(descriptor.workflow)
494 .map_err(|value| anyhow::anyhow!("bad descriptor workflow: {}", value.code()))?;
495 let version = nasaga_core::DefinitionVersion::new(descriptor.definition_version)
496 .map_err(|value| anyhow::anyhow!("bad descriptor version: {}", value.code()))?;
497 let Some(definition) = registry.get(&workflow, version) else {
498 continue;
499 };
500 let step = nasaga_core::StepName::new(descriptor.step)
501 .map_err(|value| anyhow::anyhow!("bad descriptor step: {}", value.code()))?;
502 let Some(step_definition) = definition.step(&step) else {
503 anyhow::bail!(
504 "descriptor step `{}` is absent from workflow `{}` v{} ({})",
505 descriptor.step,
506 descriptor.workflow,
507 descriptor.definition_version,
508 descriptor.source
509 );
510 };
511 if step_definition.payload_contract().content_type != descriptor.payload_content_type
513 || step_definition.payload_contract().schema_id != descriptor.payload_schema_id
514 || step_definition.compensation() != descriptor.compensation
515 || step_definition.cancel_mode() != descriptor.cancel_mode
516 || step_definition.resolution().allow_unknown() != descriptor.allow_unknown
517 || step_definition.resolution().mode() != descriptor.resolution_mode
518 {
519 anyhow::bail!(
520 "descriptor contract drift on step `{}` of `{}` v{} ({})",
521 descriptor.step,
522 descriptor.workflow,
523 descriptor.definition_version,
524 descriptor.source
525 );
526 }
527 }
528 Ok(())
529}
530
531#[doc(hidden)]
533pub mod __private {
534 pub use anyhow;
535 pub use linkme;
536 pub use nasaga_core as core;
537}
538
539use std::sync::atomic::{AtomicU64, Ordering};
540use std::time::Duration;
541
542static KAFKA_RESULT_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
543static QUOTA_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
544static ACTION_RATE_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
545static KAFKA_RESULT_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
546static KAFKA_RESULT_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
547static KAFKA_RESULT_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
548static KAFKA_RESULT_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
549static KAFKA_RESULT_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
550static KAFKA_COMMAND_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
551static KAFKA_COMMAND_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
552static KAFKA_COMMAND_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
553static KAFKA_COMMAND_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
554static KAFKA_COMMAND_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
555static KAFKA_COMMAND_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
559pub struct SagaProcessMetrics {
560 pub quota_rejections_total: u64,
562 pub action_rate_rejections_total: u64,
564 pub kafka_result_processing_total: u64,
566 pub kafka_result_processing_micros_sum: u64,
568 pub kafka_result_retry_total: u64,
570 pub kafka_result_dlt_requested_total: u64,
572 pub kafka_result_ack_total: u64,
574 pub kafka_result_duplicate_total: u64,
576 pub kafka_command_processing_total: u64,
578 pub kafka_command_processing_micros_sum: u64,
580 pub kafka_command_retry_total: u64,
582 pub kafka_command_dlt_requested_total: u64,
584 pub kafka_command_ack_total: u64,
586 pub kafka_command_duplicate_total: u64,
588}
589
590pub fn process_metrics_snapshot() -> SagaProcessMetrics {
596 SagaProcessMetrics {
597 quota_rejections_total: QUOTA_REJECTIONS_TOTAL.load(Ordering::Relaxed),
598 action_rate_rejections_total: ACTION_RATE_REJECTIONS_TOTAL.load(Ordering::Relaxed),
599 kafka_result_processing_total: KAFKA_RESULT_PROCESSING_TOTAL.load(Ordering::Relaxed),
600 kafka_result_processing_micros_sum: KAFKA_RESULT_PROCESSING_MICROS.load(Ordering::Relaxed),
601 kafka_result_retry_total: KAFKA_RESULT_RETRY_TOTAL.load(Ordering::Relaxed),
602 kafka_result_dlt_requested_total: KAFKA_RESULT_DLT_REQUESTED_TOTAL.load(Ordering::Relaxed),
603 kafka_result_ack_total: KAFKA_RESULT_ACK_TOTAL.load(Ordering::Relaxed),
604 kafka_result_duplicate_total: KAFKA_RESULT_DUPLICATE_TOTAL.load(Ordering::Relaxed),
605 kafka_command_processing_total: KAFKA_COMMAND_PROCESSING_TOTAL.load(Ordering::Relaxed),
606 kafka_command_processing_micros_sum: KAFKA_COMMAND_PROCESSING_MICROS
607 .load(Ordering::Relaxed),
608 kafka_command_retry_total: KAFKA_COMMAND_RETRY_TOTAL.load(Ordering::Relaxed),
609 kafka_command_dlt_requested_total: KAFKA_COMMAND_DLT_REQUESTED_TOTAL
610 .load(Ordering::Relaxed),
611 kafka_command_ack_total: KAFKA_COMMAND_ACK_TOTAL.load(Ordering::Relaxed),
612 kafka_command_duplicate_total: KAFKA_COMMAND_DUPLICATE_TOTAL.load(Ordering::Relaxed),
613 }
614}
615
616pub fn record_quota_rejection() {
622 QUOTA_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
623}
624
625pub fn record_action_rate_rejection() {
631 ACTION_RATE_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
632}
633
634pub fn record_kafka_processing(elapsed: Duration) {
640 KAFKA_RESULT_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
641 saturating_add_duration(&KAFKA_RESULT_PROCESSING_MICROS, elapsed);
642}
643
644pub fn record_kafka_retry() {
650 KAFKA_RESULT_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
651}
652
653pub fn record_kafka_dlt_requested() {
659 KAFKA_RESULT_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
660}
661
662pub fn record_kafka_ack(duplicate: bool) {
668 KAFKA_RESULT_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
669 if duplicate {
670 KAFKA_RESULT_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
671 }
672}
673
674pub fn record_kafka_command_processing(elapsed: Duration) {
680 KAFKA_COMMAND_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
681 saturating_add_duration(&KAFKA_COMMAND_PROCESSING_MICROS, elapsed);
682}
683
684pub fn record_kafka_command_retry() {
690 KAFKA_COMMAND_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
691}
692
693pub fn record_kafka_command_dlt_requested() {
699 KAFKA_COMMAND_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
700}
701
702pub fn record_kafka_command_ack(duplicate: bool) {
708 KAFKA_COMMAND_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
709 if duplicate {
710 KAFKA_COMMAND_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
711 }
712}
713
714fn saturating_add_duration(target: &AtomicU64, elapsed: Duration) {
720 let micros = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
721 let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
722 Some(current.saturating_add(micros))
723 });
724}