use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use hmac::{Hmac, Mac as _};
use nadis::client::RedisClient;
use naoutbox_core::{OutboxEvent, OutboxPublishError, OutboxPublisher};
use nasaga_core::{DefinitionVersion, ServiceIdentity, StepName, WorkflowName};
use natelemetry::TraceContext;
use sha2::Sha256;
use crate::transport_shared::{SagaCommandHandler, SagaResultHandler};
use crate::{canonical_bytes, validate_redis_stream_route};
use crate::{SagaCommandEnvelope, SagaResultEnvelope};
const FIELD_EVENT: &str = "event";
const FIELD_EVENT_ID: &str = "event_id";
const FIELD_PAYLOAD: &str = "payload";
const FIELD_TRACEPARENT: &str = "traceparent";
const FIELD_KEY_ID: &str = "key_id";
const FIELD_SIGNATURE: &str = "sig";
#[derive(Clone)]
pub enum SagaStreamAuth {
Hmac {
key_id: String,
key: Vec<u8>,
},
HmacKeyring {
keys: BTreeMap<String, SagaStreamVerificationKey>,
},
ExclusiveStream,
}
impl std::fmt::Debug for SagaStreamAuth {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Hmac { key_id, .. } => formatter
.debug_struct("Hmac")
.field("key_id", key_id)
.finish_non_exhaustive(),
Self::HmacKeyring { keys } => formatter
.debug_struct("HmacKeyring")
.field("key_ids", &keys.keys().collect::<Vec<_>>())
.finish_non_exhaustive(),
Self::ExclusiveStream => formatter.write_str("ExclusiveStream"),
}
}
}
#[derive(Clone)]
pub struct SagaStreamVerificationKey {
producer: ServiceIdentity,
key: Vec<u8>,
}
impl SagaStreamVerificationKey {
pub fn new(producer: ServiceIdentity, key: Vec<u8>) -> anyhow::Result<Self> {
anyhow::ensure!(!key.is_empty(), "saga stream verification key is empty");
Ok(Self { producer, key })
}
}
fn entry_mac(
key: &[u8],
stream: &str,
event_id: &str,
event_type: &str,
payload: &[u8],
traceparent: Option<&[u8]>,
) -> Hmac<Sha256> {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(&canonical_bytes(&[
stream.as_bytes(),
event_id.as_bytes(),
event_type.as_bytes(),
payload,
if traceparent.is_some() {
b"1"
} else {
b"0"
},
traceparent.unwrap_or_default(),
]));
mac
}
fn sign_entry(
key: &[u8],
stream: &str,
event_id: &str,
event_type: &str,
payload: &[u8],
traceparent: Option<&[u8]>,
) -> String {
hex::encode(
entry_mac(key, stream, event_id, event_type, payload, traceparent)
.finalize()
.into_bytes(),
)
}
pub struct SagaRedisStreamPublisher {
client: Arc<RedisClient>,
streams: BTreeMap<String, String>,
auth: SagaStreamAuth,
duplicates_hint: AtomicU64,
}
impl SagaRedisStreamPublisher {
pub fn new(
client: Arc<RedisClient>,
streams: BTreeMap<String, String>,
auth: SagaStreamAuth,
key_tag: Option<&str>,
) -> anyhow::Result<Self> {
anyhow::ensure!(
!matches!(auth, SagaStreamAuth::HmacKeyring { .. }),
"saga stream publisher cannot use a verification keyring"
);
if streams.is_empty() {
anyhow::bail!("saga stream publisher requires at least one event route");
}
for (event_type, stream) in &streams {
if event_type.is_empty() || validate_redis_stream_route(stream, key_tag).is_err() {
anyhow::bail!("saga stream route violates the naming or hash-tag contract");
}
}
Ok(Self {
client,
streams,
auth,
duplicates_hint: AtomicU64::new(0),
})
}
pub fn duplicate_hints(&self) -> u64 {
self.duplicates_hint.load(Ordering::Relaxed)
}
}
#[async_trait::async_trait]
impl OutboxPublisher for SagaRedisStreamPublisher {
async fn publish(&self, event: &OutboxEvent) -> Result<(), OutboxPublishError> {
let Some(stream) = self.streams.get(&event.event_type) else {
return Err(OutboxPublishError::new("saga stream route missing"));
};
let mut fields: Vec<(&str, Vec<u8>)> = vec![
(FIELD_EVENT, event.event_type.clone().into_bytes()),
(FIELD_EVENT_ID, event.event_id.clone().into_bytes()),
(FIELD_PAYLOAD, event.payload.clone()),
];
if let Some(traceparent) = &event.traceparent {
fields.push((FIELD_TRACEPARENT, traceparent.clone().into_bytes()));
}
if let SagaStreamAuth::Hmac { key_id, key } = &self.auth {
fields.push((FIELD_KEY_ID, key_id.clone().into_bytes()));
fields.push((
FIELD_SIGNATURE,
sign_entry(
key,
stream,
&event.event_id,
&event.event_type,
&event.payload,
event.traceparent.as_deref().map(str::as_bytes),
)
.into_bytes(),
));
}
let mut command = redis::cmd("XADD");
command.arg(stream).arg("*");
for (name, value) in &fields {
command.arg(*name).arg(value.as_slice());
}
let mut connection = self.client.conn();
let appended: Result<String, _> = command.query_async(&mut connection).await;
match appended {
Ok(_) => Ok(()),
Err(_) => {
self.duplicates_hint.fetch_add(1, Ordering::Relaxed);
PUBLISHER_DUPLICATE_HINTS.fetch_add(1, Ordering::Relaxed);
Err(OutboxPublishError::transient("saga stream publish failed"))
}
}
}
}
struct StreamEntry {
entry_id: String,
fields: BTreeMap<String, Vec<u8>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct StreamPollReport {
pub acked: u64,
pub dead_lettered: u64,
pub retained: u64,
pub reclaimed: u64,
pub deleted_pending: u64,
pub auth_rejected: u64,
pub handled: u64,
pub handler_micros_sum: u64,
}
static PUBLISHER_DUPLICATE_HINTS: AtomicU64 = AtomicU64::new(0);
pub fn publisher_duplicate_hints_total() -> u64 {
PUBLISHER_DUPLICATE_HINTS.load(Ordering::Relaxed)
}
pub async fn stream_group_backlog(
client: &RedisClient,
stream: &str,
group: &str,
now_ms: i64,
) -> anyhow::Result<(u64, Option<u64>)> {
let mut connection = client.conn();
let pending: redis::Value = redis::cmd("XPENDING")
.arg(stream)
.arg(group)
.query_async(&mut connection)
.await
.map_err(|error| anyhow::anyhow!("XPENDING backlog probe failed: {error}"))?;
let redis::Value::Array(parts) = pending else {
anyhow::bail!("XPENDING backlog probe returned an unexpected reply shape");
};
if parts.is_empty() {
anyhow::bail!("XPENDING backlog probe returned an empty reply");
}
let count = match &parts[0] {
redis::Value::Int(count) => (*count).max(0) as u64,
_ => anyhow::bail!("XPENDING backlog probe returned a non-numeric count"),
};
if count == 0 {
return Ok((0, None));
}
let oldest = match parts.get(1) {
Some(redis::Value::BulkString(bytes)) => {
parse_entry_id(&String::from_utf8_lossy(bytes)).map(|(ms, _)| ms)
}
_ => None,
};
let age = oldest.map(|ms| (now_ms.max(0) as u64).saturating_sub(ms));
Ok((count, age))
}
const DLT_ACK_SCRIPT: &str = r#"
if redis.call('EXISTS', KEYS[2]) == 0 then
redis.call('XADD', KEYS[1], '*', 'event_id', ARGV[3], 'reason', ARGV[4], 'payload', ARGV[5])
redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[6])
end
redis.call('XACK', KEYS[3], ARGV[1], ARGV[2])
return 1
"#;
enum StreamVerdict {
Ack,
DeadLetter(&'static str),
Retain,
}
#[derive(Debug, Clone)]
pub struct SagaStreamConsumerConfig {
pub stream: String,
pub dlt_stream: String,
pub marker_prefix: String,
pub group: String,
pub consumer: String,
pub batch: u32,
pub block_ms: u64,
pub handler_timeout_ms: u64,
pub min_idle_ms: u64,
pub replay_from_beginning: bool,
pub marker_ttl_seconds: u64,
pub auth: SagaStreamAuth,
pub producer: ServiceIdentity,
pub key_tag: Option<String>,
}
impl SagaStreamConsumerConfig {
pub fn validate(&self) -> anyhow::Result<()> {
let tag = self.key_tag.as_deref();
if validate_redis_stream_route(&self.stream, tag).is_err()
|| validate_redis_stream_route(&self.dlt_stream, tag).is_err()
|| validate_redis_stream_route(&self.marker_prefix, tag).is_err()
{
anyhow::bail!("saga stream consumer violates the naming or hash-tag contract");
}
if self.group.is_empty()
|| self.group.len() > 128
|| self.consumer.is_empty()
|| self.consumer.len() > 128
|| self.batch == 0
|| self.batch > 10_000
|| self.block_ms == 0
|| self.block_ms > 60_000
|| self.handler_timeout_ms == 0
|| self.handler_timeout_ms > 60_000
|| self.min_idle_ms == 0
|| self.marker_ttl_seconds == 0
{
anyhow::bail!("saga stream consumer requires bounded budgets and identities");
}
if u64::from(self.batch) * self.handler_timeout_ms > 3_600_000 {
anyhow::bail!(
"saga stream consumer worst-case round (batch x handler_timeout_ms) must stay within one hour"
);
}
Ok(())
}
}
fn parse_entries(value: &redis::Value) -> Vec<StreamEntry> {
let mut entries = Vec::new();
let redis::Value::Array(items) = value else {
return entries;
};
for item in items {
let redis::Value::Array(pair) = item else {
continue;
};
if pair.len() != 2 {
continue;
}
let entry_id = match &pair[0] {
redis::Value::BulkString(bytes) => String::from_utf8_lossy(bytes).to_string(),
redis::Value::SimpleString(text) => text.clone(),
_ => continue,
};
let mut fields = BTreeMap::new();
if let redis::Value::Array(kv) = &pair[1] {
let mut iterator = kv.iter();
while let (Some(name), Some(value)) = (iterator.next(), iterator.next()) {
let name = match name {
redis::Value::BulkString(bytes) => String::from_utf8_lossy(bytes).to_string(),
redis::Value::SimpleString(text) => text.clone(),
_ => continue,
};
let value = match value {
redis::Value::BulkString(bytes) => bytes.clone(),
redis::Value::SimpleString(text) => text.clone().into_bytes(),
_ => continue,
};
fields.insert(name, value);
}
}
entries.push(StreamEntry { entry_id, fields });
}
entries
}
struct StreamConsumerCore {
config: Arc<SagaStreamConsumerConfig>,
reclaim_cursor: std::sync::Mutex<String>,
}
impl StreamConsumerCore {
async fn ensure_group(&self, client: &RedisClient) -> anyhow::Result<()> {
let start = if self.config.replay_from_beginning {
"0-0"
} else {
"$"
};
let mut connection = client.conn();
let created: Result<redis::Value, redis::RedisError> = redis::cmd("XGROUP")
.arg("CREATE")
.arg(&self.config.stream)
.arg(&self.config.group)
.arg(start)
.arg("MKSTREAM")
.query_async(&mut connection)
.await;
match created {
Ok(_) => Ok(()),
Err(error) if error.to_string().contains("BUSYGROUP") => Ok(()),
Err(error) => Err(anyhow::anyhow!("saga stream group create failed: {error}")),
}
}
async fn poll_once<F, Fut>(
&self,
client: &RedisClient,
mut adjudicate: F,
) -> anyhow::Result<StreamPollReport>
where
F: FnMut(SagaStreamEntry) -> Fut,
Fut: std::future::Future<Output = StreamVerdict>,
{
let mut report = StreamPollReport::default();
let mut connection = client.conn();
let reclaim_start = self
.reclaim_cursor
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let reclaim: redis::Value = redis::cmd("XAUTOCLAIM")
.arg(&self.config.stream)
.arg(&self.config.group)
.arg(&self.config.consumer)
.arg(self.config.min_idle_ms)
.arg(&reclaim_start)
.arg("COUNT")
.arg(self.config.batch)
.query_async(&mut connection)
.await
.map_err(|error| anyhow::anyhow!("saga stream XAUTOCLAIM failed: {error}"))?;
let mut reclaimed_entries: Vec<StreamEntry> = Vec::new();
match &reclaim {
redis::Value::Array(parts) if parts.len() >= 2 => {
let next_cursor = match &parts[0] {
redis::Value::BulkString(bytes) => String::from_utf8_lossy(bytes).to_string(),
redis::Value::SimpleString(text) => text.clone(),
_ => {
anyhow::bail!("saga stream XAUTOCLAIM returned an unreadable next cursor")
}
};
*self
.reclaim_cursor
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = next_cursor;
reclaimed_entries.extend(parse_entries(&parts[1]));
if parts.len() >= 3 {
if let redis::Value::Array(deleted) = &parts[2] {
report.deleted_pending += deleted.len() as u64;
}
}
}
_ => anyhow::bail!("saga stream XAUTOCLAIM returned an unexpected reply shape"),
}
for entry in reclaimed_entries {
report.reclaimed += 1;
self.settle_entry(&mut connection, entry, &mut adjudicate, &mut report)
.await?;
}
let fresh: redis::Value = redis::cmd("XREADGROUP")
.arg("GROUP")
.arg(&self.config.group)
.arg(&self.config.consumer)
.arg("COUNT")
.arg(self.config.batch)
.arg("BLOCK")
.arg(self.config.block_ms)
.arg("STREAMS")
.arg(&self.config.stream)
.arg(">")
.query_async(&mut connection)
.await
.map_err(|error| anyhow::anyhow!("saga stream XREADGROUP failed: {error}"))?;
let mut fresh_entries: Vec<StreamEntry> = Vec::new();
match &fresh {
redis::Value::Nil => {}
redis::Value::Array(streams) => {
for stream in streams {
if let redis::Value::Array(pair) = stream {
if pair.len() == 2 {
fresh_entries.extend(parse_entries(&pair[1]));
}
}
}
}
_ => anyhow::bail!("saga stream XREADGROUP returned an unexpected reply shape"),
}
for entry in fresh_entries {
self.settle_entry(&mut connection, entry, &mut adjudicate, &mut report)
.await?;
}
Ok(report)
}
async fn settle_entry<F, Fut>(
&self,
connection: &mut (impl redis::aio::ConnectionLike + Send),
entry: StreamEntry,
adjudicate: &mut F,
report: &mut StreamPollReport,
) -> anyhow::Result<()>
where
F: FnMut(SagaStreamEntry) -> Fut,
Fut: std::future::Future<Output = StreamVerdict>,
{
let view = SagaStreamEntry {
config: Arc::clone(&self.config),
fields: entry.fields.clone(),
};
let started = std::time::Instant::now();
let verdict = adjudicate(view).await;
report.handled += 1;
report.handler_micros_sum += started.elapsed().as_micros() as u64;
match verdict {
StreamVerdict::Ack => {
let _acked: u64 = redis::cmd("XACK")
.arg(&self.config.stream)
.arg(&self.config.group)
.arg(&entry.entry_id)
.query_async(&mut *connection)
.await
.map_err(|error| {
anyhow::anyhow!("saga stream XACK failed after commit: {error}")
})?;
report.acked += 1;
}
StreamVerdict::DeadLetter(reason) => {
let event_id = entry
.fields
.get(FIELD_EVENT_ID)
.map(|bytes| String::from_utf8_lossy(bytes).to_string())
.unwrap_or_else(|| format!("entry:{}", entry.entry_id));
let marker = format!("{}:{}", self.config.marker_prefix, event_id);
let payload = entry.fields.get(FIELD_PAYLOAD).cloned().unwrap_or_default();
let _script: i64 = redis::cmd("EVAL")
.arg(DLT_ACK_SCRIPT)
.arg(3)
.arg(&self.config.dlt_stream)
.arg(&marker)
.arg(&self.config.stream)
.arg(&self.config.group)
.arg(&entry.entry_id)
.arg(&event_id)
.arg(reason)
.arg(payload.as_slice())
.arg(self.config.marker_ttl_seconds)
.query_async(&mut *connection)
.await
.map_err(|error| {
anyhow::anyhow!("saga stream DLT+XACK script failed: {error}")
})?;
report.dead_lettered += 1;
if reason.contains("unauthorized") || reason.contains("signature") {
report.auth_rejected += 1;
}
}
StreamVerdict::Retain => {
report.retained += 1;
}
}
Ok(())
}
}
pub struct SagaStreamEntry {
config: Arc<SagaStreamConsumerConfig>,
fields: BTreeMap<String, Vec<u8>>,
}
impl SagaStreamEntry {
fn verify_origin(&self) -> Result<Option<ServiceIdentity>, &'static str> {
let (key, producer) = match &self.config.auth {
SagaStreamAuth::Hmac { key_id, key } => {
let presented_key = self.field_text(FIELD_KEY_ID);
if presented_key.as_deref() != Some(key_id.as_str()) {
return Err("saga_stream_signature_key_unknown");
}
(key.as_slice(), None)
}
SagaStreamAuth::HmacKeyring { keys } => {
let presented_key = self
.field_text(FIELD_KEY_ID)
.ok_or("saga_stream_signature_key_unknown")?;
let trusted = keys
.get(&presented_key)
.ok_or("saga_stream_signature_key_unknown")?;
(trusted.key.as_slice(), Some(trusted.producer.clone()))
}
SagaStreamAuth::ExclusiveStream => return Ok(None),
};
if !self.fields.contains_key(FIELD_SIGNATURE) {
return Err("saga_stream_signature_missing");
}
let decoded = self
.field_text(FIELD_SIGNATURE)
.and_then(|hex_text| hex::decode(hex_text).ok())
.ok_or("saga_stream_signature_invalid")?;
let event_id = self.field_text(FIELD_EVENT_ID).unwrap_or_default();
let event_type = self.field_text(FIELD_EVENT).unwrap_or_default();
let payload = self.fields.get(FIELD_PAYLOAD).cloned().unwrap_or_default();
let traceparent = self.fields.get(FIELD_TRACEPARENT).map(Vec::as_slice);
entry_mac(
key,
&self.config.stream,
&event_id,
&event_type,
&payload,
traceparent,
)
.verify_slice(&decoded)
.map_err(|_| "saga_stream_signature_invalid")?;
Ok(producer)
}
fn field_text(&self, name: &str) -> Option<String> {
self.fields
.get(name)
.map(|bytes| String::from_utf8_lossy(bytes).to_string())
}
fn trace_context(&self) -> Option<TraceContext> {
self.field_text(FIELD_TRACEPARENT)
.and_then(|raw| TraceContext::parse_traceparent(&raw))
}
}
pub struct SagaRedisStreamResultConsumer<H> {
handler: Arc<H>,
core: StreamConsumerCore,
}
impl<H: SagaResultHandler> SagaRedisStreamResultConsumer<H> {
pub fn new(handler: Arc<H>, config: SagaStreamConsumerConfig) -> anyhow::Result<Self> {
config.validate()?;
Ok(Self {
handler,
core: StreamConsumerCore {
config: Arc::new(config),
reclaim_cursor: std::sync::Mutex::new("0-0".to_string()),
},
})
}
pub async fn ensure_group(&self, client: &RedisClient) -> anyhow::Result<()> {
self.core.ensure_group(client).await
}
pub fn config(&self) -> &SagaStreamConsumerConfig {
&self.core.config
}
pub async fn poll_once(
&self,
client: &RedisClient,
now_ms: i64,
) -> anyhow::Result<StreamPollReport> {
let handler = Arc::clone(&self.handler);
let configured_producer = self.core.config.producer.clone();
let timeout = Duration::from_millis(self.core.config.handler_timeout_ms);
self.core
.poll_once(client, move |view| {
let handler = Arc::clone(&handler);
let configured_producer = configured_producer.clone();
async move {
let producer = match view.verify_origin() {
Ok(producer) => producer.unwrap_or(configured_producer),
Err(reason) => return StreamVerdict::DeadLetter(reason),
};
let Some(payload) = view.fields.get(FIELD_PAYLOAD) else {
return StreamVerdict::DeadLetter("saga_stream_payload_missing");
};
let Ok(envelope) = serde_json::from_slice::<SagaResultEnvelope>(payload) else {
return StreamVerdict::DeadLetter("saga_result_payload_undecodable");
};
let receipt_trace = view.trace_context();
let outcome = tokio::time::timeout(
timeout,
Box::pin(handler.handle_authenticated_result_traced(
&envelope,
&producer,
receipt_trace.as_ref(),
now_ms,
)),
)
.await;
match outcome {
Ok(Ok(_)) => StreamVerdict::Ack,
Ok(Err(error)) => classify_result_error(&error),
Err(_) => StreamVerdict::Retain,
}
}
})
.await
}
}
fn classify_result_error(error: &anyhow::Error) -> StreamVerdict {
if matches!(
crate::classify_result_delivery_error(error),
crate::ResultDeliveryDisposition::DeadLetter
) {
let reason = error
.chain()
.find_map(|cause| {
cause
.downcast_ref::<crate::SagaResultProcessingError>()
.copied()
})
.and_then(crate::SagaResultProcessingError::dead_letter_reason)
.unwrap_or("saga_result_contract_invalid");
return StreamVerdict::DeadLetter(reason);
}
StreamVerdict::Retain
}
pub struct SagaRedisStreamCommandConsumer<H> {
handler: Arc<H>,
core: StreamConsumerCore,
workflow: WorkflowName,
version: DefinitionVersion,
digest: String,
step: StepName,
}
impl<H: SagaCommandHandler> SagaRedisStreamCommandConsumer<H> {
pub fn new(
handler: Arc<H>,
config: SagaStreamConsumerConfig,
workflow: WorkflowName,
version: DefinitionVersion,
digest: impl Into<String>,
step: StepName,
) -> anyhow::Result<Self> {
config.validate()?;
let digest = digest.into();
if digest.len() != 64
|| !digest
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
{
anyhow::bail!("saga stream command route requires a canonical definition digest");
}
Ok(Self {
handler,
core: StreamConsumerCore {
config: Arc::new(config),
reclaim_cursor: std::sync::Mutex::new("0-0".to_string()),
},
workflow,
version,
digest,
step,
})
}
pub fn for_managed_capability(
handler: Arc<H>,
config: SagaStreamConsumerConfig,
workflow: WorkflowName,
version: DefinitionVersion,
step: StepName,
) -> anyhow::Result<Self> {
config.validate()?;
Ok(Self {
handler,
core: StreamConsumerCore {
config: Arc::new(config),
reclaim_cursor: std::sync::Mutex::new("0-0".to_string()),
},
workflow,
version,
digest: String::new(),
step,
})
}
pub async fn ensure_group(&self, client: &RedisClient) -> anyhow::Result<()> {
self.core.ensure_group(client).await
}
pub fn config(&self) -> &SagaStreamConsumerConfig {
&self.core.config
}
pub async fn poll_once(&self, client: &RedisClient) -> anyhow::Result<StreamPollReport> {
let handler = Arc::clone(&self.handler);
let configured_producer = self.core.config.producer.clone();
let timeout = Duration::from_millis(self.core.config.handler_timeout_ms);
let workflow = self.workflow.clone();
let version = self.version;
let digest = self.digest.clone();
let step = self.step.clone();
self.core
.poll_once(client, move |view| {
let handler = Arc::clone(&handler);
let configured_producer = configured_producer.clone();
let workflow = workflow.clone();
let digest = digest.clone();
let step = step.clone();
async move {
let producer = match view.verify_origin() {
Ok(producer) => producer.unwrap_or(configured_producer),
Err(reason) => return StreamVerdict::DeadLetter(reason),
};
let Some(payload) = view.fields.get(FIELD_PAYLOAD) else {
return StreamVerdict::DeadLetter("saga_stream_payload_missing");
};
let Ok(envelope) = serde_json::from_slice::<SagaCommandEnvelope>(payload)
else {
return StreamVerdict::DeadLetter("saga_command_payload_undecodable");
};
let identity_ok = envelope.verified().is_ok();
if !identity_ok {
return StreamVerdict::DeadLetter("saga_command_identity_invalid");
}
if envelope.workflow != workflow.as_str()
|| envelope.definition_version != version.get()
|| (!digest.is_empty() && envelope.definition_digest != digest)
|| envelope.step != step.as_str()
{
return StreamVerdict::DeadLetter("saga_command_route_unauthorized");
}
let receipt_trace = view.trace_context();
let outcome = tokio::time::timeout(
timeout,
Box::pin(handler.handle_authenticated_command_traced(
&envelope,
&producer,
receipt_trace.as_ref(),
)),
)
.await;
match outcome {
Ok(Ok(_)) => StreamVerdict::Ack,
Ok(Err(error)) => classify_command_error(&error),
Err(_) => StreamVerdict::Retain,
}
}
})
.await
}
}
fn classify_command_error(error: &anyhow::Error) -> StreamVerdict {
if let Some(reason) = crate::command_dead_letter_reason(error) {
return StreamVerdict::DeadLetter(reason);
}
StreamVerdict::Retain
}
#[async_trait::async_trait]
pub trait SagaStreamPoller: Send + Sync {
fn config(&self) -> &SagaStreamConsumerConfig;
async fn ensure_group(&self, client: &RedisClient) -> anyhow::Result<()>;
async fn poll_once(
&self,
client: &RedisClient,
now_ms: i64,
) -> anyhow::Result<StreamPollReport>;
}
#[async_trait::async_trait]
impl<H: SagaResultHandler> SagaStreamPoller for SagaRedisStreamResultConsumer<H> {
fn config(&self) -> &SagaStreamConsumerConfig {
SagaRedisStreamResultConsumer::config(self)
}
async fn ensure_group(&self, client: &RedisClient) -> anyhow::Result<()> {
SagaRedisStreamResultConsumer::ensure_group(self, client).await
}
async fn poll_once(
&self,
client: &RedisClient,
now_ms: i64,
) -> anyhow::Result<StreamPollReport> {
SagaRedisStreamResultConsumer::poll_once(self, client, now_ms).await
}
}
#[async_trait::async_trait]
impl<H: SagaCommandHandler> SagaStreamPoller for SagaRedisStreamCommandConsumer<H> {
fn config(&self) -> &SagaStreamConsumerConfig {
SagaRedisStreamCommandConsumer::config(self)
}
async fn ensure_group(&self, client: &RedisClient) -> anyhow::Result<()> {
SagaRedisStreamCommandConsumer::ensure_group(self, client).await
}
async fn poll_once(
&self,
client: &RedisClient,
_now_ms: i64,
) -> anyhow::Result<StreamPollReport> {
SagaRedisStreamCommandConsumer::poll_once(self, client).await
}
}
pub async fn safe_trim_by_group_frontier(
client: &RedisClient,
stream: &str,
) -> anyhow::Result<u64> {
const TRIM_SCRIPT: &str = r#"
local function parse_id(raw)
if type(raw) ~= 'string' then return nil end
local ms, seq = string.match(raw, '^(%d+)-(%d+)$')
if ms == nil then return nil end
return { tonumber(ms), tonumber(seq) }
end
local function less(a, b)
if a[1] ~= b[1] then return a[1] < b[1] end
return a[2] < b[2]
end
local groups = redis.call('XINFO', 'GROUPS', KEYS[1])
if type(groups) ~= 'table' or #groups == 0 then
return 0
end
local frontier = nil
for _, group in ipairs(groups) do
if type(group) ~= 'table' then return 0 end
local name = nil
local delivered = nil
for index = 1, #group - 1, 2 do
local field = group[index]
if field == 'name' then name = group[index + 1] end
if field == 'last-delivered-id' then delivered = group[index + 1] end
end
if name == nil then return 0 end
local candidate = parse_id(delivered)
local pending = redis.call('XPENDING', KEYS[1], name)
if type(pending) ~= 'table' or #pending < 2 then return 0 end
local count = pending[1]
if type(count) ~= 'number' then return 0 end
if count > 0 then
local minimum = parse_id(pending[2])
if minimum == nil then return 0 end
if candidate == nil or less(minimum, candidate) then candidate = minimum end
end
if candidate == nil then return 0 end
if frontier == nil or less(candidate, frontier) then frontier = candidate end
end
if frontier == nil then return 0 end
return redis.call('XTRIM', KEYS[1], 'MINID', frontier[1] .. '-' .. frontier[2])
"#;
let mut connection = client.conn();
let trimmed: u64 = redis::cmd("EVAL")
.arg(TRIM_SCRIPT)
.arg(1)
.arg(stream)
.query_async(&mut connection)
.await
.map_err(|error| anyhow::anyhow!("safe trim script failed: {error}"))?;
Ok(trimmed)
}
fn parse_entry_id(raw: &str) -> Option<(u64, u64)> {
let (ms, seq) = raw.split_once('-')?;
Some((ms.parse().ok()?, seq.parse().ok()?))
}
pub async fn verify_stream_transport_ready(
client: &RedisClient,
configs: &[&SagaStreamConsumerConfig],
) -> anyhow::Result<()> {
let mut connection = client.conn();
let pong: String = redis::cmd("PING")
.query_async(&mut connection)
.await
.map_err(|error| anyhow::anyhow!("redis stream transport unreachable: {error}"))?;
if pong != "PONG" {
anyhow::bail!("redis stream transport returned an unexpected ping reply");
}
for config in configs {
config.validate()?;
StreamConsumerCore {
config: Arc::new((*config).clone()),
reclaim_cursor: std::sync::Mutex::new("0-0".to_string()),
}
.ensure_group(client)
.await?;
}
Ok(())
}