use std::time::Duration;
use async_trait::async_trait;
use serde_json::{Value as Json, json};
use uuid::Uuid;
use super::neo4j::{
LABEL_SAGA, Neo4jCanonicalStore, neo_err, now_unix_ms, prop_dt, prop_i32, prop_json, prop_str,
prop_uuid,
};
use super::system_store::{
CompensationStatus, SagaInsert, SagaListFilter, SagaRow, SagaStatus, SagaStore, SagaSummary,
SystemStoreError, SystemStoreResult,
};
fn node_to_saga(node: &Json) -> SystemStoreResult<SagaRow> {
let saga_id = prop_uuid(node, "saga_id")?;
let status_str = prop_str(node, "status");
let status = SagaStatus::parse(&status_str).ok_or_else(|| {
SystemStoreError::InvalidInput(format!("unknown saga status '{status_str}' in neo4j row"))
})?;
let comp_status_str = prop_str(node, "compensation_status");
let compensation_status = CompensationStatus::parse(&comp_status_str).ok_or_else(|| {
SystemStoreError::InvalidInput(format!(
"unknown compensation_status '{comp_status_str}' in neo4j row"
))
})?;
Ok(SagaRow {
saga_id,
tx_id: prop_str(node, "tx_id"),
tenant_id: prop_str(node, "tenant_id"),
correlation_id: prop_str(node, "correlation_id"),
status,
backend_instance: prop_str(node, "backend_instance"),
operation: prop_str(node, "operation"),
current_step: prop_i32(node, "current_step"),
retry_count: prop_i32(node, "retry_count"),
recovery_attempts: prop_i32(node, "recovery_attempts"),
compensation_status,
steps: prop_json(node, "steps", Json::Array(vec![])),
compensations: prop_json(node, "compensations", Json::Array(vec![])),
last_error: prop_str(node, "last_error"),
created_at: prop_dt(node, "created_at"),
updated_at: prop_dt(node, "updated_at"),
})
}
impl Neo4jCanonicalStore {
fn saga_tag(&self) -> Json {
json!(self.run_tag())
}
}
#[async_trait]
impl SagaStore for Neo4jCanonicalStore {
fn backend_label(&self) -> &'static str {
"neo4j"
}
async fn ensure_saga_tables(&self) -> SystemStoreResult<()> {
let indexes = [
format!(
"CREATE INDEX udb_saga_id_idx IF NOT EXISTS \
FOR (s:{LABEL_SAGA}) ON (s.run_tag, s.saga_id)"
),
format!(
"CREATE INDEX udb_saga_status_idx IF NOT EXISTS \
FOR (s:{LABEL_SAGA}) ON (s.run_tag, s.status)"
),
format!(
"CREATE INDEX udb_saga_tenant_idx IF NOT EXISTS \
FOR (s:{LABEL_SAGA}) ON (s.run_tag, s.tenant_id)"
),
];
for ddl in indexes {
self.executor()
.cypher_rows(&ddl, json!({}))
.await
.map_err(|e| neo_err("ensure_saga_tables", e))?;
}
Ok(())
}
async fn record_saga(&self, saga: &SagaInsert) -> SystemStoreResult<Uuid> {
let saga_id = Uuid::new_v4();
let now = now_unix_ms();
let cypher = format!(
"CREATE (s:{LABEL_SAGA} {{run_tag:$tag, saga_id:$saga_id, tx_id:$tx_id, \
tenant_id:$tenant_id, correlation_id:$correlation_id, status:$status, \
backend_instance:$backend_instance, operation:$operation, current_step:0, \
retry_count:0, recovery_attempts:0, compensation_status:$comp, \
steps:$steps, compensations:$compensations, last_error:'', \
created_at:$now, updated_at:$now}})"
);
self.executor()
.cypher_rows(
&cypher,
json!({
"tag": self.saga_tag(),
"saga_id": saga_id.to_string(),
"tx_id": saga.tx_id,
"tenant_id": saga.tenant_id,
"correlation_id": saga.correlation_id,
"status": saga.status.as_str(),
"backend_instance": saga.backend_instance,
"operation": saga.operation,
"comp": CompensationStatus::None.as_str(),
"steps": saga.steps.to_string(),
"compensations": saga.compensations.to_string(),
"now": now,
}),
)
.await
.map_err(|e| neo_err("record_saga", e))?;
Ok(saga_id)
}
async fn get_saga(&self, saga_id: Uuid) -> SystemStoreResult<Option<SagaRow>> {
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag, saga_id:$saga_id}}) RETURN s{{.*}} AS s"
);
let rows = self
.executor()
.cypher_rows(
&cypher,
json!({ "tag": self.saga_tag(), "saga_id": saga_id.to_string() }),
)
.await
.map_err(|e| neo_err("get_saga", e))?;
match rows.first().and_then(|r| r.get("s")) {
Some(node) => Ok(Some(node_to_saga(node)?)),
None => Ok(None),
}
}
async fn list_sagas(&self, filter: &SagaListFilter) -> SystemStoreResult<Vec<SagaRow>> {
let mut filters = String::new();
if filter.tenant_id.is_some() {
filters.push_str(" AND s.tenant_id = $tenant_id");
}
if filter.status.is_some() {
filters.push_str(" AND s.status = $status");
}
if filter.tx_id.is_some() {
filters.push_str(" AND s.tx_id = $tx_id");
}
if filter.correlation_id.is_some() {
filters.push_str(" AND s.correlation_id = $correlation_id");
}
let limit = if filter.limit <= 0 { 100 } else { filter.limit };
let offset = filter.offset.max(0);
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag}}) \
WHERE true{filters} \
WITH s ORDER BY s.updated_at DESC SKIP $offset LIMIT $limit \
RETURN s{{.*}} AS s"
);
let mut params = json!({ "tag": self.saga_tag(), "offset": offset, "limit": limit });
let obj = params.as_object_mut().expect("params is an object");
if let Some(t) = &filter.tenant_id {
obj.insert("tenant_id".to_string(), json!(t));
}
if let Some(s) = filter.status {
obj.insert("status".to_string(), json!(s.as_str()));
}
if let Some(t) = &filter.tx_id {
obj.insert("tx_id".to_string(), json!(t));
}
if let Some(c) = &filter.correlation_id {
obj.insert("correlation_id".to_string(), json!(c));
}
let rows = self
.executor()
.cypher_rows(&cypher, params)
.await
.map_err(|e| neo_err("list_sagas", e))?;
let mut out = Vec::with_capacity(rows.len());
for row in &rows {
let node = row
.get("s")
.ok_or_else(|| neo_err("list_sagas", "row missing 's' projection"))?;
out.push(node_to_saga(node)?);
}
Ok(out)
}
async fn update_saga_status(
&self,
saga_id: Uuid,
status: SagaStatus,
compensation_status: CompensationStatus,
) -> SystemStoreResult<()> {
let now = now_unix_ms();
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag, saga_id:$saga_id}}) \
SET s.status=$status, s.compensation_status=$comp, s.updated_at=$now \
RETURN count(s) AS n"
);
let rows = self
.executor()
.cypher_rows(
&cypher,
json!({
"tag": self.saga_tag(),
"saga_id": saga_id.to_string(),
"status": status.as_str(),
"comp": compensation_status.as_str(),
"now": now,
}),
)
.await
.map_err(|e| neo_err("update_saga_status", e))?;
let n = rows
.first()
.and_then(|r| r.get("n"))
.and_then(Json::as_i64)
.unwrap_or(0);
if n == 0 {
return Err(SystemStoreError::InvalidInput(format!(
"saga {saga_id} not found for update_saga_status"
)));
}
Ok(())
}
async fn update_saga_statuses_batch(
&self,
saga_ids: &[Uuid],
status: SagaStatus,
compensation_status: CompensationStatus,
) -> SystemStoreResult<()> {
if saga_ids.is_empty() {
return Ok(());
}
let now = now_unix_ms();
let ids: Vec<String> = saga_ids.iter().map(|id| id.to_string()).collect();
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag}}) WHERE s.saga_id IN $ids \
SET s.status=$status, s.compensation_status=$comp, s.updated_at=$now"
);
self.executor()
.cypher_rows(
&cypher,
json!({
"tag": self.saga_tag(),
"ids": ids,
"status": status.as_str(),
"comp": compensation_status.as_str(),
"now": now,
}),
)
.await
.map_err(|e| neo_err("update_saga_statuses_batch", e))?;
Ok(())
}
async fn mark_saga_manual_review(&self, saga_id: Uuid) -> SystemStoreResult<()> {
let now = now_unix_ms();
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag, saga_id:$saga_id}}) \
SET s.status=$status, s.updated_at=$now \
RETURN count(s) AS n"
);
let rows = self
.executor()
.cypher_rows(
&cypher,
json!({
"tag": self.saga_tag(),
"saga_id": saga_id.to_string(),
"status": SagaStatus::ManualReview.as_str(),
"now": now,
}),
)
.await
.map_err(|e| neo_err("mark_saga_manual_review", e))?;
let n = rows
.first()
.and_then(|r| r.get("n"))
.and_then(Json::as_i64)
.unwrap_or(0);
if n == 0 {
return Err(SystemStoreError::InvalidInput(format!(
"saga {saga_id} not found"
)));
}
Ok(())
}
async fn request_saga_recompensation(&self, saga_id: Uuid) -> SystemStoreResult<()> {
let now = now_unix_ms();
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag, saga_id:$saga_id}}) \
WHERE s.status IN ['failed_compensation','manual_review'] \
SET s.status=$status, s.last_error='', s.retry_count=s.retry_count + 1, \
s.compensation_status=$comp, s.updated_at=$now \
RETURN count(s) AS n"
);
let rows = self
.executor()
.cypher_rows(
&cypher,
json!({
"tag": self.saga_tag(),
"saga_id": saga_id.to_string(),
"status": SagaStatus::Indeterminate.as_str(),
"comp": CompensationStatus::RetryRequested.as_str(),
"now": now,
}),
)
.await
.map_err(|e| neo_err("request_saga_recompensation", e))?;
let n = rows
.first()
.and_then(|r| r.get("n"))
.and_then(Json::as_i64)
.unwrap_or(0);
if n == 0 {
return Err(SystemStoreError::InvalidInput(format!(
"saga {saga_id} is not in a retryable state (must be failed_compensation or manual_review)"
)));
}
Ok(())
}
async fn increment_recovery_attempts(
&self,
saga_id: Uuid,
error: &str,
) -> SystemStoreResult<i64> {
let now = now_unix_ms();
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag, saga_id:$saga_id}}) \
SET s.recovery_attempts=s.recovery_attempts + 1, s.last_error=$err, s.updated_at=$now \
RETURN s.recovery_attempts AS attempts"
);
let rows = self
.executor()
.cypher_rows(
&cypher,
json!({
"tag": self.saga_tag(),
"saga_id": saga_id.to_string(),
"err": error,
"now": now,
}),
)
.await
.map_err(|e| neo_err("increment_recovery_attempts", e))?;
rows.first()
.and_then(|r| r.get("attempts"))
.and_then(Json::as_i64)
.ok_or_else(|| {
SystemStoreError::InvalidInput(format!(
"saga {saga_id} not found for increment_recovery_attempts"
))
})
}
async fn claim_recoverable_sagas(
&self,
stale_after: Duration,
limit: i64,
) -> SystemStoreResult<Vec<SagaRow>> {
let cutoff = now_unix_ms() - (stale_after.as_millis() as i64);
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag}}) \
WHERE s.status IN ['indeterminate','in_doubt'] \
OR (s.status='in_progress' AND s.updated_at < $cutoff) \
WITH s ORDER BY s.updated_at ASC LIMIT $limit \
RETURN s{{.*}} AS s"
);
let rows = self
.executor()
.cypher_rows(
&cypher,
json!({
"tag": self.saga_tag(),
"cutoff": cutoff,
"limit": limit.max(1),
}),
)
.await
.map_err(|e| neo_err("claim_recoverable_sagas", e))?;
let mut out = Vec::with_capacity(rows.len());
for row in &rows {
let node = row
.get("s")
.ok_or_else(|| neo_err("claim_recoverable_sagas", "row missing 's' projection"))?;
out.push(node_to_saga(node)?);
}
Ok(out)
}
async fn mark_stale_in_progress_indeterminate(
&self,
stale_after: Duration,
) -> SystemStoreResult<i64> {
let now = now_unix_ms();
let cutoff = now - (stale_after.as_millis() as i64);
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag}}) \
WHERE s.status='in_progress' AND s.updated_at < $cutoff \
SET s.status='indeterminate', \
s.last_error='stale in-progress reconciled at startup', s.updated_at=$now \
RETURN count(s) AS n"
);
let rows = self
.executor()
.cypher_rows(
&cypher,
json!({ "tag": self.saga_tag(), "cutoff": cutoff, "now": now }),
)
.await
.map_err(|e| neo_err("mark_stale_in_progress_indeterminate", e))?;
Ok(rows
.first()
.and_then(|r| r.get("n"))
.and_then(Json::as_i64)
.unwrap_or(0))
}
async fn saga_summary(&self) -> SystemStoreResult<SagaSummary> {
let cypher = format!(
"MATCH (s:{LABEL_SAGA} {{run_tag:$tag}}) RETURN s.status AS status, count(s) AS n"
);
let rows = self
.executor()
.cypher_rows(&cypher, json!({ "tag": self.saga_tag() }))
.await
.map_err(|e| neo_err("saga_summary", e))?;
let mut s = SagaSummary::default();
for row in &rows {
let status = prop_str(row, "status");
let n = row.get("n").and_then(Json::as_i64).unwrap_or(0);
match SagaStatus::parse(&status) {
Some(SagaStatus::Indeterminate) => s.indeterminate += n,
Some(SagaStatus::InProgress) => s.in_progress += n,
Some(SagaStatus::Pending) => s.pending += n,
Some(SagaStatus::Committed) => s.committed += n,
Some(SagaStatus::Compensated) => s.compensated += n,
Some(SagaStatus::Failed) => s.failed += n,
Some(SagaStatus::InDoubt) => s.in_doubt += n,
Some(SagaStatus::FailedCompensation) => s.failed_compensation += n,
Some(SagaStatus::ManualReview) => s.manual_review += n,
None => {
return Err(SystemStoreError::InvalidInput(format!(
"unknown saga status '{status}' in neo4j summary"
)));
}
}
}
Ok(s)
}
}