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> {
149 match reason {
150 "saga_command_producer_unauthorized" => Some(Self::ProducerUnauthorized),
151 "saga_command_identity_invalid" => Some(Self::IdentityInvalid),
152 "saga_command_route_unauthorized" => Some(Self::RouteUnauthorized),
153 "saga_command_contract_invalid" => Some(Self::ContractInvalid),
154 _ => None,
155 }
156 }
157}
158
159impl std::fmt::Display for SagaCommandProcessingError {
160 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 formatter.write_str(match self {
168 Self::ProducerUnauthorized => "saga command producer is unauthorized",
169 Self::IdentityInvalid => "saga command identity is invalid",
170 Self::RouteUnauthorized => "saga command route is unauthorized",
171 Self::ContractInvalid => "saga command contract is invalid",
172 })
173 }
174}
175
176impl std::error::Error for SagaCommandProcessingError {}
177
178pub const SAGA_COMMAND_DEAD_LETTER_REASONS: &[&str] = &[
180 "saga_command_producer_unauthorized",
181 "saga_command_identity_invalid",
182 "saga_command_route_unauthorized",
183 "saga_command_contract_invalid",
184];
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188#[non_exhaustive]
189pub enum SagaResultProcessingError {
190 Paused,
192 AuthorityUnavailable,
194 ProducerUnauthorized,
196 IdentityInvalid,
198 ContractInvalid,
200}
201
202impl SagaResultProcessingError {
203 pub const fn dead_letter_reason(self) -> Option<&'static str> {
209 match self {
210 Self::Paused | Self::AuthorityUnavailable => None,
211 Self::ProducerUnauthorized => Some("saga_result_producer_unauthorized"),
212 Self::IdentityInvalid => Some("saga_result_identity_invalid"),
213 Self::ContractInvalid => Some("saga_result_contract_invalid"),
214 }
215 }
216}
217
218impl std::fmt::Display for SagaResultProcessingError {
219 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 formatter.write_str(match self {
227 Self::Paused => "saga result processing deferred while instance is paused",
228 Self::AuthorityUnavailable => "saga result processing authority is unavailable",
229 Self::ProducerUnauthorized => "saga result producer is unauthorized",
230 Self::IdentityInvalid => "saga result identity is invalid",
231 Self::ContractInvalid => "saga result contract is invalid",
232 })
233 }
234}
235
236impl std::error::Error for SagaResultProcessingError {}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum CommandDeliveryDisposition {
241 Retry,
243 Defer,
245 DeadLetter,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub struct CommandDeliveryPolicy {
252 pub max_retries: u32,
254}
255
256impl Default for CommandDeliveryPolicy {
257 fn default() -> Self {
263 Self {
264 max_retries: 16,
265 }
266 }
267}
268
269impl CommandDeliveryPolicy {
270 pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> CommandDeliveryDisposition {
278 let disposition = classify_command_delivery_error(error);
279 if disposition == CommandDeliveryDisposition::Retry && retry_attempt > self.max_retries {
280 CommandDeliveryDisposition::DeadLetter
281 } else {
282 disposition
283 }
284 }
285}
286
287pub fn command_dead_letter_reason(error: &anyhow::Error) -> Option<&'static str> {
294 error.chain().find_map(|cause| {
295 cause
296 .downcast_ref::<SagaCommandProcessingError>()
297 .map(|error| error.dead_letter_reason())
298 })
299}
300
301pub fn classify_command_delivery_error(error: &anyhow::Error) -> CommandDeliveryDisposition {
308 if command_dead_letter_reason(error).is_some() {
309 CommandDeliveryDisposition::DeadLetter
310 } else if error.chain().any(|cause| {
311 cause
312 .downcast_ref::<SagaTransactionError>()
313 .is_some_and(|error| error.requires_unbounded_redelivery())
314 }) {
315 CommandDeliveryDisposition::Defer
316 } else {
317 CommandDeliveryDisposition::Retry
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum ResultDeliveryDisposition {
324 Retry,
326 Defer,
328 DeadLetter,
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub struct ResultDeliveryPolicy {
335 pub max_retries: u32,
337}
338
339impl Default for ResultDeliveryPolicy {
340 fn default() -> Self {
346 Self {
347 max_retries: 16,
348 }
349 }
350}
351
352impl ResultDeliveryPolicy {
353 pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> ResultDeliveryDisposition {
361 let disposition = classify_result_delivery_error(error);
362 if disposition == ResultDeliveryDisposition::Retry && retry_attempt > self.max_retries {
363 ResultDeliveryDisposition::DeadLetter
364 } else {
365 disposition
366 }
367 }
368}
369
370pub fn classify_result_delivery_error(error: &anyhow::Error) -> ResultDeliveryDisposition {
377 if error.chain().any(|cause| {
378 cause
379 .downcast_ref::<SagaTransactionError>()
380 .is_some_and(|error| error.requires_unbounded_redelivery())
381 }) {
382 return ResultDeliveryDisposition::Defer;
383 }
384 let classified = error
385 .chain()
386 .find_map(|cause| cause.downcast_ref::<SagaResultProcessingError>().copied());
387 if matches!(
388 classified,
389 Some(SagaResultProcessingError::Paused | SagaResultProcessingError::AuthorityUnavailable)
390 ) {
391 ResultDeliveryDisposition::Defer
392 } else if classified.is_some_and(|error| error.dead_letter_reason().is_some()) {
393 ResultDeliveryDisposition::DeadLetter
394 } else {
395 ResultDeliveryDisposition::Retry
396 }
397}
398
399#[derive(Debug, Clone, Copy)]
401pub struct SagaStepDescriptor {
402 pub payload_content_type: &'static str,
404 pub payload_schema_id: &'static str,
406
407 pub workflow: &'static str,
409 pub definition_version: u32,
411 pub step: &'static str,
413 pub binding: Option<&'static str>,
415 pub service_type: &'static str,
417 pub compensation: Compensation,
419 pub cancel_mode: CancelMode,
421 pub allow_unknown: bool,
423 pub resolution_mode: Option<ResolutionMode>,
425 pub source: &'static str,
427}
428
429#[linkme::distributed_slice]
431pub static COLLECTED_SAGA_STEPS: [SagaStepDescriptor];
432
433#[derive(Clone, Copy)]
438pub struct SagaWorkflowDescriptor {
439 pub factory: fn() -> anyhow::Result<nasaga_core::WorkflowDefinition>,
441 pub source: &'static str,
443}
444
445#[linkme::distributed_slice]
447pub static COLLECTED_SAGA_WORKFLOWS: [SagaWorkflowDescriptor];
448
449pub fn collect_workflow_definitions() -> anyhow::Result<DefinitionRegistry> {
458 let mut registry = DefinitionRegistry::new();
459 for descriptor in COLLECTED_SAGA_WORKFLOWS {
460 let definition = (descriptor.factory)().map_err(|error| {
461 anyhow::anyhow!(
462 "saga workflow definition factory failed at {}: {}",
463 descriptor.source,
464 error
465 )
466 })?;
467 registry.register(definition).map_err(|error| {
468 anyhow::anyhow!(
469 "saga workflow definition is invalid at {}: {}",
470 descriptor.source,
471 error
472 )
473 })?;
474 }
475 Ok(registry)
476}
477
478pub fn verify_descriptors(registry: &DefinitionRegistry) -> anyhow::Result<()> {
485 let mut seen = std::collections::BTreeSet::new();
486 for descriptor in COLLECTED_SAGA_STEPS {
487 nasaga_core::SagaPayloadContract {
488 content_type: descriptor.payload_content_type.to_owned(),
489 schema_id: descriptor.payload_schema_id.to_owned(),
490 }
491 .validate()?;
492
493 let key = (
494 descriptor.workflow,
495 descriptor.definition_version,
496 descriptor.step,
497 );
498 if !seen.insert(key) {
500 anyhow::bail!(
501 "duplicate Saga step `{}` v{} `{}` ({})",
502 descriptor.workflow,
503 descriptor.definition_version,
504 descriptor.step,
505 descriptor.source
506 );
507 }
508 let workflow = nasaga_core::WorkflowName::new(descriptor.workflow)
509 .map_err(|value| anyhow::anyhow!("bad descriptor workflow: {}", value.code()))?;
510 let version = nasaga_core::DefinitionVersion::new(descriptor.definition_version)
511 .map_err(|value| anyhow::anyhow!("bad descriptor version: {}", value.code()))?;
512 let Some(definition) = registry.get(&workflow, version) else {
513 continue;
514 };
515 let step = nasaga_core::StepName::new(descriptor.step)
516 .map_err(|value| anyhow::anyhow!("bad descriptor step: {}", value.code()))?;
517 let Some(step_definition) = definition.step(&step) else {
518 anyhow::bail!(
519 "descriptor step `{}` is absent from workflow `{}` v{} ({})",
520 descriptor.step,
521 descriptor.workflow,
522 descriptor.definition_version,
523 descriptor.source
524 );
525 };
526 if step_definition.payload_contract().content_type != descriptor.payload_content_type
528 || step_definition.payload_contract().schema_id != descriptor.payload_schema_id
529 || step_definition.compensation() != descriptor.compensation
530 || step_definition.cancel_mode() != descriptor.cancel_mode
531 || step_definition.resolution().allow_unknown() != descriptor.allow_unknown
532 || step_definition.resolution().mode() != descriptor.resolution_mode
533 {
534 anyhow::bail!(
535 "descriptor contract drift on step `{}` of `{}` v{} ({})",
536 descriptor.step,
537 descriptor.workflow,
538 descriptor.definition_version,
539 descriptor.source
540 );
541 }
542 }
543 Ok(())
544}
545
546#[doc(hidden)]
548pub mod __private {
549 pub use anyhow;
550 pub use linkme;
551 pub use nasaga_core as core;
552}
553
554use std::sync::atomic::{AtomicU64, Ordering};
555use std::time::Duration;
556
557static KAFKA_RESULT_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
558static QUOTA_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
559static ACTION_RATE_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
560static KAFKA_RESULT_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
561static KAFKA_RESULT_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
562static KAFKA_RESULT_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
563static KAFKA_RESULT_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
564static KAFKA_RESULT_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
565static KAFKA_COMMAND_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
566static KAFKA_COMMAND_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
567static KAFKA_COMMAND_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
568static KAFKA_COMMAND_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
569static KAFKA_COMMAND_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
570static KAFKA_COMMAND_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
574pub struct SagaProcessMetrics {
575 pub quota_rejections_total: u64,
577 pub action_rate_rejections_total: u64,
579 pub kafka_result_processing_total: u64,
581 pub kafka_result_processing_micros_sum: u64,
583 pub kafka_result_retry_total: u64,
585 pub kafka_result_dlt_requested_total: u64,
587 pub kafka_result_ack_total: u64,
589 pub kafka_result_duplicate_total: u64,
591 pub kafka_command_processing_total: u64,
593 pub kafka_command_processing_micros_sum: u64,
595 pub kafka_command_retry_total: u64,
597 pub kafka_command_dlt_requested_total: u64,
599 pub kafka_command_ack_total: u64,
601 pub kafka_command_duplicate_total: u64,
603}
604
605pub fn process_metrics_snapshot() -> SagaProcessMetrics {
611 SagaProcessMetrics {
612 quota_rejections_total: QUOTA_REJECTIONS_TOTAL.load(Ordering::Relaxed),
613 action_rate_rejections_total: ACTION_RATE_REJECTIONS_TOTAL.load(Ordering::Relaxed),
614 kafka_result_processing_total: KAFKA_RESULT_PROCESSING_TOTAL.load(Ordering::Relaxed),
615 kafka_result_processing_micros_sum: KAFKA_RESULT_PROCESSING_MICROS.load(Ordering::Relaxed),
616 kafka_result_retry_total: KAFKA_RESULT_RETRY_TOTAL.load(Ordering::Relaxed),
617 kafka_result_dlt_requested_total: KAFKA_RESULT_DLT_REQUESTED_TOTAL.load(Ordering::Relaxed),
618 kafka_result_ack_total: KAFKA_RESULT_ACK_TOTAL.load(Ordering::Relaxed),
619 kafka_result_duplicate_total: KAFKA_RESULT_DUPLICATE_TOTAL.load(Ordering::Relaxed),
620 kafka_command_processing_total: KAFKA_COMMAND_PROCESSING_TOTAL.load(Ordering::Relaxed),
621 kafka_command_processing_micros_sum: KAFKA_COMMAND_PROCESSING_MICROS
622 .load(Ordering::Relaxed),
623 kafka_command_retry_total: KAFKA_COMMAND_RETRY_TOTAL.load(Ordering::Relaxed),
624 kafka_command_dlt_requested_total: KAFKA_COMMAND_DLT_REQUESTED_TOTAL
625 .load(Ordering::Relaxed),
626 kafka_command_ack_total: KAFKA_COMMAND_ACK_TOTAL.load(Ordering::Relaxed),
627 kafka_command_duplicate_total: KAFKA_COMMAND_DUPLICATE_TOTAL.load(Ordering::Relaxed),
628 }
629}
630
631pub fn record_quota_rejection() {
637 QUOTA_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
638}
639
640pub fn record_action_rate_rejection() {
646 ACTION_RATE_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
647}
648
649pub fn record_kafka_processing(elapsed: Duration) {
656 KAFKA_RESULT_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
657 saturating_add_duration(&KAFKA_RESULT_PROCESSING_MICROS, elapsed);
658}
659
660pub fn record_kafka_retry() {
666 KAFKA_RESULT_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
667}
668
669pub fn record_kafka_dlt_requested() {
675 KAFKA_RESULT_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
676}
677
678pub fn record_kafka_ack(duplicate: bool) {
685 KAFKA_RESULT_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
686 if duplicate {
687 KAFKA_RESULT_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
688 }
689}
690
691pub fn record_kafka_command_processing(elapsed: Duration) {
698 KAFKA_COMMAND_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
699 saturating_add_duration(&KAFKA_COMMAND_PROCESSING_MICROS, elapsed);
700}
701
702pub fn record_kafka_command_retry() {
708 KAFKA_COMMAND_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
709}
710
711pub fn record_kafka_command_dlt_requested() {
717 KAFKA_COMMAND_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
718}
719
720pub fn record_kafka_command_ack(duplicate: bool) {
727 KAFKA_COMMAND_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
728 if duplicate {
729 KAFKA_COMMAND_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
730 }
731}
732
733fn saturating_add_duration(target: &AtomicU64, elapsed: Duration) {
741 let micros = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
742 let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
743 Some(current.saturating_add(micros))
744 });
745}