1#![forbid(unsafe_code)]
8#![deny(missing_docs)]
9
10pub use nasaga_core::{SagaPayload, SagaPayloadContract, SagaPayloadError};
11mod latency;
12pub use latency::{
13 render_saga_latency_metrics, saga_latency_snapshot, SagaLatencySnapshot, SAGA_LATENCY_BUCKETS,
14};
15mod catalog;
16mod envelope;
17mod management;
18mod observability;
19mod orchestrator;
20mod participant;
21mod registry;
22mod scheduled;
23mod timers;
24mod transaction;
25#[cfg(feature = "kafka")]
26mod transport;
27mod transport_auth;
28#[cfg(feature = "grpc-transport")]
29mod transport_grpc;
30#[cfg(feature = "redis-stream")]
31mod transport_redis;
32#[cfg(any(
33 feature = "kafka",
34 feature = "redis-stream",
35 feature = "grpc-transport"
36))]
37mod transport_shared;
38
39pub use catalog::{
40 select_capability_route, select_capability_routes, validate_capability_route_contract,
41 validate_redis_stream_route, CapabilityDescriptor, CapabilityReceipt, DefinitionActivationGate,
42 DefinitionArtifact, DefinitionCancelMode, DefinitionCatalogError, DefinitionCompensation,
43 DefinitionLifecycle, DefinitionLifecycleOperation, DefinitionPublishDisposition,
44 DefinitionRecord, DefinitionResolution, DefinitionResolutionMode, DefinitionStepArtifact,
45 DefinitionTimeoutPolicy, DynamicCatalogSnapshot, RegisteredCapability,
46 CATALOG_OBJECT_KEY_MAX_LEN,
47};
48pub use envelope::{
49 canonical_bytes, derive_result_event_id, SagaCommandEnvelope, SagaResultEnvelope,
50 VerifiedIdentity, COMMAND_EVENT_TYPE, RESULT_EVENT_TYPE, SAGA_AGGREGATE_TYPE,
51};
52pub use management::{
53 SagaAuditPage, SagaAuditPageCursor, SagaAuditRecord, SagaAuditTrail, SagaManagementContext,
54 SagaManagementError, SagaManagementExpectation, SagaManagementPermission,
55};
56pub use observability::SagaOperationalMetrics;
57pub use orchestrator::{
58 HandleOutcome, Orchestrator, OrchestratorConfig, SagaConcurrencyError, StartOutcome,
59 StartSagaError, StartSagaRequest, TenantActionRate, TimerOutcome,
60};
61pub use participant::{
62 AuthenticatedParticipantRuntime, ParticipantCommandTrust, ParticipantHandled,
63 ParticipantRuntime, SagaCommandService,
64};
65pub use registry::DefinitionRegistry;
66pub use scheduled::{
67 derive_scheduled_business_key, ScheduledBatchReport, ScheduledBatchSpec, ScheduledItem,
68};
69pub use timers::{
70 derive_timer_id, KIND_CANCEL_TIMEOUT, KIND_COMPENSATE_TIMEOUT,
71 KIND_COMPENSATION_RESOLUTION_BUDGET, KIND_FORWARD_RESOLUTION_BUDGET, KIND_INSTANCE_DEADLINE,
72 KIND_RESOLUTION_BUDGET, KIND_RESOLVE_TIMEOUT, KIND_STEP_TIMEOUT,
73};
74pub use transaction::SagaTransactionError;
75#[cfg(feature = "kafka")]
76pub use transport::{
77 SagaCommandRoute, SagaKafkaCommandConsumer, SagaKafkaCommandConsumerConfig,
78 SagaKafkaResultConsumer, SagaKafkaResultConsumerConfig,
79};
80pub use transport_auth::{
81 render_saga_http_command_dlt_metric, SagaHttpCredentialSource, SagaHttpCredentials,
82 SagaHttpMessageAuthError, SagaHttpMessageAuthFailure, SagaHttpMessageAuthenticator,
83 SagaHttpReplayGuard, SagaHttpReplayMetricAggregate, SagaHttpReplayMetrics, SagaHttpReplayPlane,
84 SagaHttpSignedMessage,
85};
86#[cfg(feature = "grpc-transport")]
87pub use transport_grpc::{
88 orchestrator_proto, outbox_disposition_of, proto as grpc_proto, SagaGrpcBindingError,
89 SagaGrpcCommandServer, SagaGrpcCommandTransportService, SagaGrpcPeerBinding,
90 SagaGrpcPeerIdentity, SagaGrpcReceipt, SagaGrpcResultServer, SagaGrpcResultTransportService,
91};
92#[cfg(feature = "redis-stream")]
93pub use transport_redis::{
94 publisher_duplicate_hints_total, safe_trim_by_group_frontier, stream_group_backlog,
95 verify_stream_transport_ready, SagaRedisStreamCommandConsumer, SagaRedisStreamPublisher,
96 SagaRedisStreamResultConsumer, SagaStreamAuth, SagaStreamConsumerConfig, SagaStreamPoller,
97 SagaStreamVerificationKey, StreamPollReport,
98};
99#[cfg(any(
100 feature = "kafka",
101 feature = "redis-stream",
102 feature = "grpc-transport"
103))]
104pub use transport_shared::{ParticipantCommandHandler, SagaCommandHandler, SagaResultHandler};
105
106use nasaga_core::{CancelMode, Compensation, ResolutionMode};
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110#[non_exhaustive]
111pub enum SagaCommandProcessingError {
112 ProducerUnauthorized,
114 IdentityInvalid,
116 RouteUnauthorized,
118 ContractInvalid,
120}
121
122impl SagaCommandProcessingError {
123 pub const fn dead_letter_reason(self) -> &'static str {
129 match self {
130 Self::ProducerUnauthorized => "saga_command_producer_unauthorized",
131 Self::IdentityInvalid => "saga_command_identity_invalid",
132 Self::RouteUnauthorized => "saga_command_route_unauthorized",
133 Self::ContractInvalid => "saga_command_contract_invalid",
134 }
135 }
136
137 pub fn from_dead_letter_reason(reason: &str) -> Option<Self> {
143 match reason {
144 "saga_command_producer_unauthorized" => Some(Self::ProducerUnauthorized),
145 "saga_command_identity_invalid" => Some(Self::IdentityInvalid),
146 "saga_command_route_unauthorized" => Some(Self::RouteUnauthorized),
147 "saga_command_contract_invalid" => Some(Self::ContractInvalid),
148 _ => None,
149 }
150 }
151}
152
153impl std::fmt::Display for SagaCommandProcessingError {
154 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 formatter.write_str(match self {
161 Self::ProducerUnauthorized => "saga command producer is unauthorized",
162 Self::IdentityInvalid => "saga command identity is invalid",
163 Self::RouteUnauthorized => "saga command route is unauthorized",
164 Self::ContractInvalid => "saga command contract is invalid",
165 })
166 }
167}
168
169impl std::error::Error for SagaCommandProcessingError {}
170
171pub const SAGA_COMMAND_DEAD_LETTER_REASONS: &[&str] = &[
173 "saga_command_producer_unauthorized",
174 "saga_command_identity_invalid",
175 "saga_command_route_unauthorized",
176 "saga_command_contract_invalid",
177];
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181#[non_exhaustive]
182pub enum SagaResultProcessingError {
183 Paused,
185 ProducerUnauthorized,
187 IdentityInvalid,
189 ContractInvalid,
191}
192
193impl SagaResultProcessingError {
194 pub const fn dead_letter_reason(self) -> Option<&'static str> {
200 match self {
201 Self::Paused => None,
202 Self::ProducerUnauthorized => Some("saga_result_producer_unauthorized"),
203 Self::IdentityInvalid => Some("saga_result_identity_invalid"),
204 Self::ContractInvalid => Some("saga_result_contract_invalid"),
205 }
206 }
207}
208
209impl std::fmt::Display for SagaResultProcessingError {
210 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 formatter.write_str(match self {
217 Self::Paused => "saga result processing deferred while instance is paused",
218 Self::ProducerUnauthorized => "saga result producer is unauthorized",
219 Self::IdentityInvalid => "saga result identity is invalid",
220 Self::ContractInvalid => "saga result contract is invalid",
221 })
222 }
223}
224
225impl std::error::Error for SagaResultProcessingError {}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum CommandDeliveryDisposition {
230 Retry,
232 Defer,
234 DeadLetter,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct CommandDeliveryPolicy {
241 pub max_retries: u32,
243}
244
245impl Default for CommandDeliveryPolicy {
246 fn default() -> Self {
252 Self { max_retries: 16 }
253 }
254}
255
256impl CommandDeliveryPolicy {
257 pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> CommandDeliveryDisposition {
263 let disposition = classify_command_delivery_error(error);
264 if disposition == CommandDeliveryDisposition::Retry && retry_attempt > self.max_retries {
265 CommandDeliveryDisposition::DeadLetter
266 } else {
267 disposition
268 }
269 }
270}
271
272pub fn command_dead_letter_reason(error: &anyhow::Error) -> Option<&'static str> {
278 error.chain().find_map(|cause| {
279 cause
280 .downcast_ref::<SagaCommandProcessingError>()
281 .map(|error| error.dead_letter_reason())
282 })
283}
284
285pub fn classify_command_delivery_error(error: &anyhow::Error) -> CommandDeliveryDisposition {
291 if command_dead_letter_reason(error).is_some() {
292 CommandDeliveryDisposition::DeadLetter
293 } else if error.chain().any(|cause| {
294 cause
295 .downcast_ref::<SagaTransactionError>()
296 .is_some_and(|error| error.requires_unbounded_redelivery())
297 }) {
298 CommandDeliveryDisposition::Defer
299 } else {
300 CommandDeliveryDisposition::Retry
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum ResultDeliveryDisposition {
307 Retry,
309 Defer,
311 DeadLetter,
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub struct ResultDeliveryPolicy {
318 pub max_retries: u32,
320}
321
322impl Default for ResultDeliveryPolicy {
323 fn default() -> Self {
329 Self { max_retries: 16 }
330 }
331}
332
333impl ResultDeliveryPolicy {
334 pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> ResultDeliveryDisposition {
340 let disposition = classify_result_delivery_error(error);
341 if disposition == ResultDeliveryDisposition::Retry && retry_attempt > self.max_retries {
342 ResultDeliveryDisposition::DeadLetter
343 } else {
344 disposition
345 }
346 }
347}
348
349pub fn classify_result_delivery_error(error: &anyhow::Error) -> ResultDeliveryDisposition {
355 if error.chain().any(|cause| {
356 cause
357 .downcast_ref::<SagaTransactionError>()
358 .is_some_and(|error| error.requires_unbounded_redelivery())
359 }) {
360 return ResultDeliveryDisposition::Defer;
361 }
362 let classified = error
363 .chain()
364 .find_map(|cause| cause.downcast_ref::<SagaResultProcessingError>().copied());
365 if classified == Some(SagaResultProcessingError::Paused) {
366 ResultDeliveryDisposition::Defer
367 } else if classified.is_some_and(|error| error.dead_letter_reason().is_some()) {
368 ResultDeliveryDisposition::DeadLetter
369 } else {
370 ResultDeliveryDisposition::Retry
371 }
372}
373
374#[derive(Debug, Clone, Copy)]
376pub struct SagaStepDescriptor {
377 pub payload_content_type: &'static str,
379 pub payload_schema_id: &'static str,
381
382 pub workflow: &'static str,
384 pub definition_version: u32,
386 pub step: &'static str,
388 pub binding: Option<&'static str>,
390 pub service_type: &'static str,
392 pub compensation: Compensation,
394 pub cancel_mode: CancelMode,
396 pub allow_unknown: bool,
398 pub resolution_mode: Option<ResolutionMode>,
400 pub source: &'static str,
402}
403
404#[linkme::distributed_slice]
406pub static COLLECTED_SAGA_STEPS: [SagaStepDescriptor];
407
408#[derive(Clone, Copy)]
413pub struct SagaWorkflowDescriptor {
414 pub factory: fn() -> anyhow::Result<nasaga_core::WorkflowDefinition>,
416 pub source: &'static str,
418}
419
420#[linkme::distributed_slice]
422pub static COLLECTED_SAGA_WORKFLOWS: [SagaWorkflowDescriptor];
423
424pub fn collect_workflow_definitions() -> anyhow::Result<DefinitionRegistry> {
433 let mut registry = DefinitionRegistry::new();
434 for descriptor in COLLECTED_SAGA_WORKFLOWS {
435 let definition = (descriptor.factory)().map_err(|error| {
436 anyhow::anyhow!(
437 "saga workflow definition factory failed at {}: {}",
438 descriptor.source,
439 error
440 )
441 })?;
442 registry.register(definition).map_err(|error| {
443 anyhow::anyhow!(
444 "saga workflow definition is invalid at {}: {}",
445 descriptor.source,
446 error
447 )
448 })?;
449 }
450 Ok(registry)
451}
452
453pub fn verify_descriptors(registry: &DefinitionRegistry) -> anyhow::Result<()> {
459 let mut seen = std::collections::BTreeSet::new();
460 for descriptor in COLLECTED_SAGA_STEPS {
461 nasaga_core::SagaPayloadContract {
462 content_type: descriptor.payload_content_type.to_owned(),
463 schema_id: descriptor.payload_schema_id.to_owned(),
464 }
465 .validate()?;
466
467 let key = (
468 descriptor.workflow,
469 descriptor.definition_version,
470 descriptor.step,
471 );
472 if !seen.insert(key) {
474 anyhow::bail!(
475 "duplicate Saga step `{}` v{} `{}` ({})",
476 descriptor.workflow,
477 descriptor.definition_version,
478 descriptor.step,
479 descriptor.source
480 );
481 }
482 let workflow = nasaga_core::WorkflowName::new(descriptor.workflow)
483 .map_err(|value| anyhow::anyhow!("bad descriptor workflow: {}", value.code()))?;
484 let version = nasaga_core::DefinitionVersion::new(descriptor.definition_version)
485 .map_err(|value| anyhow::anyhow!("bad descriptor version: {}", value.code()))?;
486 let Some(definition) = registry.get(&workflow, version) else {
487 continue;
488 };
489 let step = nasaga_core::StepName::new(descriptor.step)
490 .map_err(|value| anyhow::anyhow!("bad descriptor step: {}", value.code()))?;
491 let Some(step_definition) = definition.step(&step) else {
492 anyhow::bail!(
493 "descriptor step `{}` is absent from workflow `{}` v{} ({})",
494 descriptor.step,
495 descriptor.workflow,
496 descriptor.definition_version,
497 descriptor.source
498 );
499 };
500 if step_definition.payload_contract().content_type != descriptor.payload_content_type
502 || step_definition.payload_contract().schema_id != descriptor.payload_schema_id
503 || step_definition.compensation() != descriptor.compensation
504 || step_definition.cancel_mode() != descriptor.cancel_mode
505 || step_definition.resolution().allow_unknown() != descriptor.allow_unknown
506 || step_definition.resolution().mode() != descriptor.resolution_mode
507 {
508 anyhow::bail!(
509 "descriptor contract drift on step `{}` of `{}` v{} ({})",
510 descriptor.step,
511 descriptor.workflow,
512 descriptor.definition_version,
513 descriptor.source
514 );
515 }
516 }
517 Ok(())
518}
519
520#[doc(hidden)]
522pub mod __private {
523 pub use anyhow;
524 pub use linkme;
525 pub use nasaga_core as core;
526}
527
528use std::sync::atomic::{AtomicU64, Ordering};
529use std::time::Duration;
530
531static KAFKA_RESULT_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
532static QUOTA_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
533static ACTION_RATE_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
534static KAFKA_RESULT_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
535static KAFKA_RESULT_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
536static KAFKA_RESULT_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
537static KAFKA_RESULT_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
538static KAFKA_RESULT_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
539static KAFKA_COMMAND_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
540static KAFKA_COMMAND_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
541static KAFKA_COMMAND_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
542static KAFKA_COMMAND_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
543static KAFKA_COMMAND_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
544static KAFKA_COMMAND_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
548pub struct SagaProcessMetrics {
549 pub quota_rejections_total: u64,
551 pub action_rate_rejections_total: u64,
553 pub kafka_result_processing_total: u64,
555 pub kafka_result_processing_micros_sum: u64,
557 pub kafka_result_retry_total: u64,
559 pub kafka_result_dlt_requested_total: u64,
561 pub kafka_result_ack_total: u64,
563 pub kafka_result_duplicate_total: u64,
565 pub kafka_command_processing_total: u64,
567 pub kafka_command_processing_micros_sum: u64,
569 pub kafka_command_retry_total: u64,
571 pub kafka_command_dlt_requested_total: u64,
573 pub kafka_command_ack_total: u64,
575 pub kafka_command_duplicate_total: u64,
577}
578
579pub fn process_metrics_snapshot() -> SagaProcessMetrics {
585 SagaProcessMetrics {
586 quota_rejections_total: QUOTA_REJECTIONS_TOTAL.load(Ordering::Relaxed),
587 action_rate_rejections_total: ACTION_RATE_REJECTIONS_TOTAL.load(Ordering::Relaxed),
588 kafka_result_processing_total: KAFKA_RESULT_PROCESSING_TOTAL.load(Ordering::Relaxed),
589 kafka_result_processing_micros_sum: KAFKA_RESULT_PROCESSING_MICROS.load(Ordering::Relaxed),
590 kafka_result_retry_total: KAFKA_RESULT_RETRY_TOTAL.load(Ordering::Relaxed),
591 kafka_result_dlt_requested_total: KAFKA_RESULT_DLT_REQUESTED_TOTAL.load(Ordering::Relaxed),
592 kafka_result_ack_total: KAFKA_RESULT_ACK_TOTAL.load(Ordering::Relaxed),
593 kafka_result_duplicate_total: KAFKA_RESULT_DUPLICATE_TOTAL.load(Ordering::Relaxed),
594 kafka_command_processing_total: KAFKA_COMMAND_PROCESSING_TOTAL.load(Ordering::Relaxed),
595 kafka_command_processing_micros_sum: KAFKA_COMMAND_PROCESSING_MICROS
596 .load(Ordering::Relaxed),
597 kafka_command_retry_total: KAFKA_COMMAND_RETRY_TOTAL.load(Ordering::Relaxed),
598 kafka_command_dlt_requested_total: KAFKA_COMMAND_DLT_REQUESTED_TOTAL
599 .load(Ordering::Relaxed),
600 kafka_command_ack_total: KAFKA_COMMAND_ACK_TOTAL.load(Ordering::Relaxed),
601 kafka_command_duplicate_total: KAFKA_COMMAND_DUPLICATE_TOTAL.load(Ordering::Relaxed),
602 }
603}
604
605pub fn record_quota_rejection() {
611 QUOTA_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
612}
613
614pub fn record_action_rate_rejection() {
620 ACTION_RATE_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
621}
622
623pub fn record_kafka_processing(elapsed: Duration) {
629 KAFKA_RESULT_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
630 saturating_add_duration(&KAFKA_RESULT_PROCESSING_MICROS, elapsed);
631}
632
633pub fn record_kafka_retry() {
639 KAFKA_RESULT_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
640}
641
642pub fn record_kafka_dlt_requested() {
648 KAFKA_RESULT_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
649}
650
651pub fn record_kafka_ack(duplicate: bool) {
657 KAFKA_RESULT_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
658 if duplicate {
659 KAFKA_RESULT_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
660 }
661}
662
663pub fn record_kafka_command_processing(elapsed: Duration) {
669 KAFKA_COMMAND_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
670 saturating_add_duration(&KAFKA_COMMAND_PROCESSING_MICROS, elapsed);
671}
672
673pub fn record_kafka_command_retry() {
679 KAFKA_COMMAND_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
680}
681
682pub fn record_kafka_command_dlt_requested() {
688 KAFKA_COMMAND_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
689}
690
691pub fn record_kafka_command_ack(duplicate: bool) {
697 KAFKA_COMMAND_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
698 if duplicate {
699 KAFKA_COMMAND_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
700 }
701}
702
703fn saturating_add_duration(target: &AtomicU64, elapsed: Duration) {
709 let micros = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
710 let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
711 Some(current.saturating_add(micros))
712 });
713}