#![forbid(unsafe_code)]
#![deny(missing_docs)]
pub use nasaga_core::{SagaPayload, SagaPayloadContract, SagaPayloadError};
mod latency;
pub use latency::{
render_saga_latency_metrics, saga_latency_snapshot, SagaLatencySnapshot, SAGA_LATENCY_BUCKETS,
};
mod catalog;
mod envelope;
mod management;
mod observability;
mod orchestrator;
mod participant;
mod registry;
mod scheduled;
mod timers;
mod transaction;
#[cfg(feature = "kafka")]
mod transport;
mod transport_auth;
#[cfg(feature = "grpc-transport")]
mod transport_grpc;
#[cfg(feature = "redis-stream")]
mod transport_redis;
#[cfg(any(
feature = "kafka",
feature = "redis-stream",
feature = "grpc-transport"
))]
mod transport_shared;
pub use catalog::{
select_capability_route, select_capability_routes, validate_capability_route_contract,
validate_redis_stream_route, CapabilityDescriptor, CapabilityReceipt, DefinitionActivationGate,
DefinitionArtifact, DefinitionCancelMode, DefinitionCatalogError, DefinitionCompensation,
DefinitionLifecycle, DefinitionLifecycleOperation, DefinitionPublishDisposition,
DefinitionRecord, DefinitionResolution, DefinitionResolutionMode, DefinitionStepArtifact,
DefinitionTimeoutPolicy, DynamicCatalogSnapshot, RegisteredCapability,
CATALOG_OBJECT_KEY_MAX_LEN,
};
pub use envelope::{
canonical_bytes, derive_result_event_id, SagaCommandEnvelope, SagaResultEnvelope,
VerifiedIdentity, COMMAND_EVENT_TYPE, RESULT_EVENT_TYPE, SAGA_AGGREGATE_TYPE,
};
pub use management::{
SagaAuditPage, SagaAuditPageCursor, SagaAuditRecord, SagaAuditTrail, SagaManagementContext,
SagaManagementError, SagaManagementExpectation, SagaManagementPermission,
};
pub use observability::SagaOperationalMetrics;
pub use orchestrator::{
HandleOutcome, Orchestrator, OrchestratorConfig, SagaConcurrencyError, StartOutcome,
StartSagaError, StartSagaRequest, TenantActionRate, TimerOutcome,
};
pub use participant::{
AuthenticatedParticipantRuntime, ParticipantCommandTrust, ParticipantHandled,
ParticipantRuntime, SagaCommandService,
};
pub use registry::DefinitionRegistry;
pub use scheduled::{
derive_scheduled_business_key, ScheduledBatchReport, ScheduledBatchSpec, ScheduledItem,
};
pub use timers::{
derive_timer_id, KIND_CANCEL_TIMEOUT, KIND_COMPENSATE_TIMEOUT,
KIND_COMPENSATION_RESOLUTION_BUDGET, KIND_FORWARD_RESOLUTION_BUDGET, KIND_INSTANCE_DEADLINE,
KIND_RESOLUTION_BUDGET, KIND_RESOLVE_TIMEOUT, KIND_STEP_TIMEOUT,
};
pub use transaction::SagaTransactionError;
#[cfg(feature = "kafka")]
pub use transport::{
SagaCommandRoute, SagaKafkaCommandConsumer, SagaKafkaCommandConsumerConfig,
SagaKafkaResultConsumer, SagaKafkaResultConsumerConfig,
};
pub use transport_auth::{
render_saga_http_command_dlt_metric, SagaHttpCredentialSource, SagaHttpCredentials,
SagaHttpMessageAuthError, SagaHttpMessageAuthFailure, SagaHttpMessageAuthenticator,
SagaHttpReplayGuard, SagaHttpReplayMetricAggregate, SagaHttpReplayMetrics, SagaHttpReplayPlane,
SagaHttpSignedMessage,
};
#[cfg(feature = "grpc-transport")]
pub use transport_grpc::{
orchestrator_proto, outbox_disposition_of, proto as grpc_proto, SagaGrpcBindingError,
SagaGrpcCommandServer, SagaGrpcCommandTransportService, SagaGrpcPeerBinding,
SagaGrpcPeerIdentity, SagaGrpcReceipt, SagaGrpcResultServer, SagaGrpcResultTransportService,
};
#[cfg(feature = "redis-stream")]
pub use transport_redis::{
publisher_duplicate_hints_total, safe_trim_by_group_frontier, stream_group_backlog,
verify_stream_transport_ready, SagaRedisStreamCommandConsumer, SagaRedisStreamPublisher,
SagaRedisStreamResultConsumer, SagaStreamAuth, SagaStreamConsumerConfig, SagaStreamPoller,
SagaStreamVerificationKey, StreamPollReport,
};
#[cfg(any(
feature = "kafka",
feature = "redis-stream",
feature = "grpc-transport"
))]
pub use transport_shared::{ParticipantCommandHandler, SagaCommandHandler, SagaResultHandler};
use nasaga_core::{CancelMode, Compensation, ResolutionMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SagaCommandProcessingError {
ProducerUnauthorized,
IdentityInvalid,
RouteUnauthorized,
ContractInvalid,
}
impl SagaCommandProcessingError {
pub const fn dead_letter_reason(self) -> &'static str {
match self {
Self::ProducerUnauthorized => "saga_command_producer_unauthorized",
Self::IdentityInvalid => "saga_command_identity_invalid",
Self::RouteUnauthorized => "saga_command_route_unauthorized",
Self::ContractInvalid => "saga_command_contract_invalid",
}
}
pub fn from_dead_letter_reason(reason: &str) -> Option<Self> {
match reason {
"saga_command_producer_unauthorized" => Some(Self::ProducerUnauthorized),
"saga_command_identity_invalid" => Some(Self::IdentityInvalid),
"saga_command_route_unauthorized" => Some(Self::RouteUnauthorized),
"saga_command_contract_invalid" => Some(Self::ContractInvalid),
_ => None,
}
}
}
impl std::fmt::Display for SagaCommandProcessingError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::ProducerUnauthorized => "saga command producer is unauthorized",
Self::IdentityInvalid => "saga command identity is invalid",
Self::RouteUnauthorized => "saga command route is unauthorized",
Self::ContractInvalid => "saga command contract is invalid",
})
}
}
impl std::error::Error for SagaCommandProcessingError {}
pub const SAGA_COMMAND_DEAD_LETTER_REASONS: &[&str] = &[
"saga_command_producer_unauthorized",
"saga_command_identity_invalid",
"saga_command_route_unauthorized",
"saga_command_contract_invalid",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SagaResultProcessingError {
Paused,
AuthorityUnavailable,
ProducerUnauthorized,
IdentityInvalid,
ContractInvalid,
}
impl SagaResultProcessingError {
pub const fn dead_letter_reason(self) -> Option<&'static str> {
match self {
Self::Paused | Self::AuthorityUnavailable => None,
Self::ProducerUnauthorized => Some("saga_result_producer_unauthorized"),
Self::IdentityInvalid => Some("saga_result_identity_invalid"),
Self::ContractInvalid => Some("saga_result_contract_invalid"),
}
}
}
impl std::fmt::Display for SagaResultProcessingError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Paused => "saga result processing deferred while instance is paused",
Self::AuthorityUnavailable => "saga result processing authority is unavailable",
Self::ProducerUnauthorized => "saga result producer is unauthorized",
Self::IdentityInvalid => "saga result identity is invalid",
Self::ContractInvalid => "saga result contract is invalid",
})
}
}
impl std::error::Error for SagaResultProcessingError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandDeliveryDisposition {
Retry,
Defer,
DeadLetter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandDeliveryPolicy {
pub max_retries: u32,
}
impl Default for CommandDeliveryPolicy {
fn default() -> Self {
Self { max_retries: 16 }
}
}
impl CommandDeliveryPolicy {
pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> CommandDeliveryDisposition {
let disposition = classify_command_delivery_error(error);
if disposition == CommandDeliveryDisposition::Retry && retry_attempt > self.max_retries {
CommandDeliveryDisposition::DeadLetter
} else {
disposition
}
}
}
pub fn command_dead_letter_reason(error: &anyhow::Error) -> Option<&'static str> {
error.chain().find_map(|cause| {
cause
.downcast_ref::<SagaCommandProcessingError>()
.map(|error| error.dead_letter_reason())
})
}
pub fn classify_command_delivery_error(error: &anyhow::Error) -> CommandDeliveryDisposition {
if command_dead_letter_reason(error).is_some() {
CommandDeliveryDisposition::DeadLetter
} else if error.chain().any(|cause| {
cause
.downcast_ref::<SagaTransactionError>()
.is_some_and(|error| error.requires_unbounded_redelivery())
}) {
CommandDeliveryDisposition::Defer
} else {
CommandDeliveryDisposition::Retry
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResultDeliveryDisposition {
Retry,
Defer,
DeadLetter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResultDeliveryPolicy {
pub max_retries: u32,
}
impl Default for ResultDeliveryPolicy {
fn default() -> Self {
Self { max_retries: 16 }
}
}
impl ResultDeliveryPolicy {
pub fn decide(self, error: &anyhow::Error, retry_attempt: u32) -> ResultDeliveryDisposition {
let disposition = classify_result_delivery_error(error);
if disposition == ResultDeliveryDisposition::Retry && retry_attempt > self.max_retries {
ResultDeliveryDisposition::DeadLetter
} else {
disposition
}
}
}
pub fn classify_result_delivery_error(error: &anyhow::Error) -> ResultDeliveryDisposition {
if error.chain().any(|cause| {
cause
.downcast_ref::<SagaTransactionError>()
.is_some_and(|error| error.requires_unbounded_redelivery())
}) {
return ResultDeliveryDisposition::Defer;
}
let classified = error
.chain()
.find_map(|cause| cause.downcast_ref::<SagaResultProcessingError>().copied());
if matches!(
classified,
Some(SagaResultProcessingError::Paused | SagaResultProcessingError::AuthorityUnavailable)
) {
ResultDeliveryDisposition::Defer
} else if classified.is_some_and(|error| error.dead_letter_reason().is_some()) {
ResultDeliveryDisposition::DeadLetter
} else {
ResultDeliveryDisposition::Retry
}
}
#[derive(Debug, Clone, Copy)]
pub struct SagaStepDescriptor {
pub payload_content_type: &'static str,
pub payload_schema_id: &'static str,
pub workflow: &'static str,
pub definition_version: u32,
pub step: &'static str,
pub binding: Option<&'static str>,
pub service_type: &'static str,
pub compensation: Compensation,
pub cancel_mode: CancelMode,
pub allow_unknown: bool,
pub resolution_mode: Option<ResolutionMode>,
pub source: &'static str,
}
#[linkme::distributed_slice]
pub static COLLECTED_SAGA_STEPS: [SagaStepDescriptor];
#[derive(Clone, Copy)]
pub struct SagaWorkflowDescriptor {
pub factory: fn() -> anyhow::Result<nasaga_core::WorkflowDefinition>,
pub source: &'static str,
}
#[linkme::distributed_slice]
pub static COLLECTED_SAGA_WORKFLOWS: [SagaWorkflowDescriptor];
pub fn collect_workflow_definitions() -> anyhow::Result<DefinitionRegistry> {
let mut registry = DefinitionRegistry::new();
for descriptor in COLLECTED_SAGA_WORKFLOWS {
let definition = (descriptor.factory)().map_err(|error| {
anyhow::anyhow!(
"saga workflow definition factory failed at {}: {}",
descriptor.source,
error
)
})?;
registry.register(definition).map_err(|error| {
anyhow::anyhow!(
"saga workflow definition is invalid at {}: {}",
descriptor.source,
error
)
})?;
}
Ok(registry)
}
pub fn verify_descriptors(registry: &DefinitionRegistry) -> anyhow::Result<()> {
let mut seen = std::collections::BTreeSet::new();
for descriptor in COLLECTED_SAGA_STEPS {
nasaga_core::SagaPayloadContract {
content_type: descriptor.payload_content_type.to_owned(),
schema_id: descriptor.payload_schema_id.to_owned(),
}
.validate()?;
let key = (
descriptor.workflow,
descriptor.definition_version,
descriptor.step,
);
if !seen.insert(key) {
anyhow::bail!(
"duplicate Saga step `{}` v{} `{}` ({})",
descriptor.workflow,
descriptor.definition_version,
descriptor.step,
descriptor.source
);
}
let workflow = nasaga_core::WorkflowName::new(descriptor.workflow)
.map_err(|value| anyhow::anyhow!("bad descriptor workflow: {}", value.code()))?;
let version = nasaga_core::DefinitionVersion::new(descriptor.definition_version)
.map_err(|value| anyhow::anyhow!("bad descriptor version: {}", value.code()))?;
let Some(definition) = registry.get(&workflow, version) else {
continue;
};
let step = nasaga_core::StepName::new(descriptor.step)
.map_err(|value| anyhow::anyhow!("bad descriptor step: {}", value.code()))?;
let Some(step_definition) = definition.step(&step) else {
anyhow::bail!(
"descriptor step `{}` is absent from workflow `{}` v{} ({})",
descriptor.step,
descriptor.workflow,
descriptor.definition_version,
descriptor.source
);
};
if step_definition.payload_contract().content_type != descriptor.payload_content_type
|| step_definition.payload_contract().schema_id != descriptor.payload_schema_id
|| step_definition.compensation() != descriptor.compensation
|| step_definition.cancel_mode() != descriptor.cancel_mode
|| step_definition.resolution().allow_unknown() != descriptor.allow_unknown
|| step_definition.resolution().mode() != descriptor.resolution_mode
{
anyhow::bail!(
"descriptor contract drift on step `{}` of `{}` v{} ({})",
descriptor.step,
descriptor.workflow,
descriptor.definition_version,
descriptor.source
);
}
}
Ok(())
}
#[doc(hidden)]
pub mod __private {
pub use anyhow;
pub use linkme;
pub use nasaga_core as core;
}
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
static KAFKA_RESULT_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
static QUOTA_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
static ACTION_RATE_REJECTIONS_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_RESULT_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
static KAFKA_RESULT_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_RESULT_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_RESULT_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_RESULT_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_COMMAND_PROCESSING_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_COMMAND_PROCESSING_MICROS: AtomicU64 = AtomicU64::new(0);
static KAFKA_COMMAND_RETRY_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_COMMAND_DLT_REQUESTED_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_COMMAND_ACK_TOTAL: AtomicU64 = AtomicU64::new(0);
static KAFKA_COMMAND_DUPLICATE_TOTAL: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SagaProcessMetrics {
pub quota_rejections_total: u64,
pub action_rate_rejections_total: u64,
pub kafka_result_processing_total: u64,
pub kafka_result_processing_micros_sum: u64,
pub kafka_result_retry_total: u64,
pub kafka_result_dlt_requested_total: u64,
pub kafka_result_ack_total: u64,
pub kafka_result_duplicate_total: u64,
pub kafka_command_processing_total: u64,
pub kafka_command_processing_micros_sum: u64,
pub kafka_command_retry_total: u64,
pub kafka_command_dlt_requested_total: u64,
pub kafka_command_ack_total: u64,
pub kafka_command_duplicate_total: u64,
}
pub fn process_metrics_snapshot() -> SagaProcessMetrics {
SagaProcessMetrics {
quota_rejections_total: QUOTA_REJECTIONS_TOTAL.load(Ordering::Relaxed),
action_rate_rejections_total: ACTION_RATE_REJECTIONS_TOTAL.load(Ordering::Relaxed),
kafka_result_processing_total: KAFKA_RESULT_PROCESSING_TOTAL.load(Ordering::Relaxed),
kafka_result_processing_micros_sum: KAFKA_RESULT_PROCESSING_MICROS.load(Ordering::Relaxed),
kafka_result_retry_total: KAFKA_RESULT_RETRY_TOTAL.load(Ordering::Relaxed),
kafka_result_dlt_requested_total: KAFKA_RESULT_DLT_REQUESTED_TOTAL.load(Ordering::Relaxed),
kafka_result_ack_total: KAFKA_RESULT_ACK_TOTAL.load(Ordering::Relaxed),
kafka_result_duplicate_total: KAFKA_RESULT_DUPLICATE_TOTAL.load(Ordering::Relaxed),
kafka_command_processing_total: KAFKA_COMMAND_PROCESSING_TOTAL.load(Ordering::Relaxed),
kafka_command_processing_micros_sum: KAFKA_COMMAND_PROCESSING_MICROS
.load(Ordering::Relaxed),
kafka_command_retry_total: KAFKA_COMMAND_RETRY_TOTAL.load(Ordering::Relaxed),
kafka_command_dlt_requested_total: KAFKA_COMMAND_DLT_REQUESTED_TOTAL
.load(Ordering::Relaxed),
kafka_command_ack_total: KAFKA_COMMAND_ACK_TOTAL.load(Ordering::Relaxed),
kafka_command_duplicate_total: KAFKA_COMMAND_DUPLICATE_TOTAL.load(Ordering::Relaxed),
}
}
pub fn record_quota_rejection() {
QUOTA_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
}
pub fn record_action_rate_rejection() {
ACTION_RATE_REJECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed);
}
pub fn record_kafka_processing(elapsed: Duration) {
KAFKA_RESULT_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
saturating_add_duration(&KAFKA_RESULT_PROCESSING_MICROS, elapsed);
}
pub fn record_kafka_retry() {
KAFKA_RESULT_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
}
pub fn record_kafka_dlt_requested() {
KAFKA_RESULT_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
}
pub fn record_kafka_ack(duplicate: bool) {
KAFKA_RESULT_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
if duplicate {
KAFKA_RESULT_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
}
}
pub fn record_kafka_command_processing(elapsed: Duration) {
KAFKA_COMMAND_PROCESSING_TOTAL.fetch_add(1, Ordering::Relaxed);
saturating_add_duration(&KAFKA_COMMAND_PROCESSING_MICROS, elapsed);
}
pub fn record_kafka_command_retry() {
KAFKA_COMMAND_RETRY_TOTAL.fetch_add(1, Ordering::Relaxed);
}
pub fn record_kafka_command_dlt_requested() {
KAFKA_COMMAND_DLT_REQUESTED_TOTAL.fetch_add(1, Ordering::Relaxed);
}
pub fn record_kafka_command_ack(duplicate: bool) {
KAFKA_COMMAND_ACK_TOTAL.fetch_add(1, Ordering::Relaxed);
if duplicate {
KAFKA_COMMAND_DUPLICATE_TOTAL.fetch_add(1, Ordering::Relaxed);
}
}
fn saturating_add_duration(target: &AtomicU64, elapsed: Duration) {
let micros = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(micros))
});
}