use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::CanonicalStore;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProjectionTaskStatus {
Pending,
InProgress,
Completed,
Failed,
DeadLetter,
}
impl ProjectionTaskStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "PENDING",
Self::InProgress => "IN_PROGRESS",
Self::Completed => "COMPLETED",
Self::Failed => "FAILED",
Self::DeadLetter => "DEAD_LETTER",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"PENDING" => Some(Self::Pending),
"IN_PROGRESS" => Some(Self::InProgress),
"COMPLETED" => Some(Self::Completed),
"FAILED" => Some(Self::Failed),
"DEAD_LETTER" => Some(Self::DeadLetter),
_ => None,
}
}
pub fn is_claimable(self) -> bool {
matches!(self, Self::Pending | Self::Failed)
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::DeadLetter)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectionOperation {
Upsert,
Delete,
}
impl ProjectionOperation {
pub fn as_str(self) -> &'static str {
match self {
Self::Upsert => "upsert",
Self::Delete => "delete",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"upsert" => Some(Self::Upsert),
"delete" => Some(Self::Delete),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionTaskInsert {
pub idempotency_key: String,
pub project_id: String,
pub manifest_checksum: String,
pub message_type: String,
pub source_schema: String,
pub source_table: String,
pub source_row_key: serde_json::Value,
pub operation: ProjectionOperation,
pub target_backend: String,
pub target_instance: String,
pub projection_kind: String,
pub resource_name: String,
pub target_options: serde_json::Value,
pub source_payload: serde_json::Value,
pub source_checksum: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionTaskRow {
pub task_id: Uuid,
pub idempotency_key: String,
pub project_id: String,
pub target_backend: String,
pub target_instance: String,
pub projection_kind: String,
pub resource_name: String,
pub operation: ProjectionOperation,
pub source_row_key: serde_json::Value,
pub target_options: serde_json::Value,
pub source_payload: serde_json::Value,
pub source_checksum: String,
pub status: ProjectionTaskStatus,
pub retry_count: i32,
pub last_error: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub next_retry_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionClaimFilter {
pub batch_size: i64,
pub max_retries: i32,
pub target_backend: Option<String>,
pub target_instance: Option<String>,
pub project_id: Option<String>,
}
impl Default for ProjectionClaimFilter {
fn default() -> Self {
Self {
batch_size: 50,
max_retries: 5,
target_backend: None,
target_instance: None,
project_id: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionTaskSummary {
pub pending: i64,
pub in_progress: i64,
pub completed: i64,
pub failed: i64,
pub dead_letter: i64,
}
impl ProjectionTaskSummary {
pub fn total(&self) -> i64 {
self.pending + self.in_progress + self.completed + self.failed + self.dead_letter
}
pub fn claimable(&self) -> i64 {
self.pending + self.failed
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PendingTaskMetric {
pub project_id: String,
pub target_backend: String,
pub target_instance: String,
pub projection_kind: String,
pub pending: i64,
pub oldest_age_seconds: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeadLetterGroup {
pub source_table: String,
pub target_backend: String,
pub target_instance: String,
pub dead_count: i64,
}
#[derive(Debug)]
pub enum SystemStoreError {
Io {
backend: &'static str,
source: String,
},
Query {
backend: &'static str,
sql: String,
source: String,
},
UnsupportedVersion {
backend: &'static str,
required: &'static str,
detail: String,
},
SchemaMismatch {
backend: &'static str,
detail: String,
},
InvalidInput(String),
}
impl std::fmt::Display for SystemStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { backend, source } => {
write!(f, "{backend} system store I/O failure: {source}")
}
Self::Query {
backend,
sql,
source,
} => {
let preview: String = sql.chars().take(80).collect();
write!(
f,
"{backend} system store query failed: {source} (sql preview: '{preview}…')"
)
}
Self::UnsupportedVersion {
backend,
required,
detail,
} => write!(f, "{backend} system store requires {required}: {detail}"),
Self::SchemaMismatch { backend, detail } => {
write!(f, "{backend} system store schema mismatch: {detail}")
}
Self::InvalidInput(msg) => write!(f, "system store invalid input: {msg}"),
}
}
}
impl std::error::Error for SystemStoreError {}
impl SystemStoreError {
pub fn io(backend: &'static str, err: impl std::fmt::Display) -> Self {
Self::Io {
backend,
source: err.to_string(),
}
}
pub fn query(
backend: &'static str,
sql: impl Into<String>,
err: impl std::fmt::Display,
) -> Self {
Self::Query {
backend,
sql: sql.into(),
source: err.to_string(),
}
}
}
pub type SystemStoreResult<T> = Result<T, SystemStoreError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct AdvisoryLeaseRow {
pub owner_id: String,
pub expires_at_ms: i64,
}
pub(crate) fn advisory_lease_can_acquire(
current: Option<&AdvisoryLeaseRow>,
owner_id: &str,
now_ms: i64,
) -> bool {
current.is_none_or(|lease| lease.expires_at_ms <= now_ms || lease.owner_id == owner_id)
}
pub(crate) fn new_advisory_lease_row(
owner_id: &str,
now_ms: i64,
ttl: Duration,
) -> AdvisoryLeaseRow {
let ttl_ms = ttl.as_millis().max(1) as i64;
AdvisoryLeaseRow {
owner_id: owner_id.to_string(),
expires_at_ms: now_ms.saturating_add(ttl_ms),
}
}
pub(crate) fn advisory_lease_is_owned_by(
current: Option<&AdvisoryLeaseRow>,
owner_id: &str,
) -> bool {
current.is_some_and(|lease| lease.owner_id == owner_id)
}
pub(crate) fn apply_saga_filter(row: &SagaRow, filter: &SagaListFilter) -> bool {
filter
.tenant_id
.as_ref()
.is_none_or(|value| row.tenant_id == *value)
&& filter.status.is_none_or(|value| row.status == value)
&& filter
.tx_id
.as_ref()
.is_none_or(|value| row.tx_id == *value)
&& filter
.correlation_id
.as_ref()
.is_none_or(|value| row.correlation_id == *value)
}
pub(crate) fn apply_admin_audit_filter(row: &AdminAuditRow, filter: &AdminAuditListFilter) -> bool {
filter
.operation
.as_ref()
.is_none_or(|value| row.operation == *value)
&& filter
.actor
.as_ref()
.is_none_or(|value| row.actor == *value)
&& filter
.tenant_id
.as_ref()
.is_none_or(|value| row.tenant_id == *value)
&& filter
.project_id
.as_ref()
.is_none_or(|value| row.project_id == *value)
}
pub(crate) fn apply_migration_run_filter(
row: &MigrationRunRow,
filter: &MigrationRunsFilter,
) -> bool {
filter
.project_id
.as_ref()
.is_none_or(|value| row.project_id == *value)
&& filter.state.is_none_or(|value| row.state == value)
&& filter
.catalog_version
.as_ref()
.is_none_or(|value| row.catalog_version == *value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProjectionTaskFailurePolicy {
StrictFailedOrDeadLetter,
LegacyAllowAnyStatusRemoveCompleted,
}
#[async_trait]
pub(crate) trait JsonProjectionTaskAdapter: Send + Sync {
fn projection_backend_label(&self) -> &'static str;
fn projection_idem_key(&self, idempotency_key: &str) -> String;
fn projection_all_key(&self) -> String;
fn projection_task_key(&self, task_id: Uuid) -> String;
async fn get_projection_idem(&self, key: &str) -> SystemStoreResult<Option<String>>;
async fn set_projection_idem(&self, key: &str, value: &String) -> SystemStoreResult<()>;
async fn get_projection_row(&self, key: &str) -> SystemStoreResult<Option<ProjectionTaskRow>>;
async fn set_projection_row(&self, key: &str, row: &ProjectionTaskRow)
-> SystemStoreResult<()>;
async fn load_projection_rows(
&self,
set_key: &str,
) -> SystemStoreResult<Vec<ProjectionTaskRow>>;
async fn add_projection_row_key(
&self,
set_key: &str,
row_key: String,
max_items: usize,
) -> SystemStoreResult<Vec<String>>;
async fn remove_projection_row_key(
&self,
set_key: &str,
row_key: &str,
) -> SystemStoreResult<bool>;
}
pub(crate) fn projection_task_row_from_insert(
task_id: Uuid,
task: &ProjectionTaskInsert,
now: DateTime<Utc>,
) -> ProjectionTaskRow {
ProjectionTaskRow {
task_id,
idempotency_key: task.idempotency_key.clone(),
project_id: task.project_id.clone(),
target_backend: task.target_backend.clone(),
target_instance: task.target_instance.clone(),
projection_kind: task.projection_kind.clone(),
resource_name: task.resource_name.clone(),
operation: task.operation,
source_row_key: task.source_row_key.clone(),
target_options: task.target_options.clone(),
source_payload: task.source_payload.clone(),
source_checksum: task.source_checksum.clone(),
status: ProjectionTaskStatus::Pending,
retry_count: 0,
last_error: String::new(),
created_at: now,
updated_at: now,
next_retry_at: None,
completed_at: None,
}
}
pub(crate) fn projection_task_matches_claim(
row: &ProjectionTaskRow,
filter: &ProjectionClaimFilter,
now: DateTime<Utc>,
) -> bool {
row.status.is_claimable()
&& row.retry_count < filter.max_retries
&& !row
.next_retry_at
.is_some_and(|next_retry_at| next_retry_at > now)
&& !filter
.target_backend
.as_ref()
.is_some_and(|value| row.target_backend != *value)
&& !filter
.target_instance
.as_ref()
.is_some_and(|value| row.target_instance != *value)
&& !filter
.project_id
.as_ref()
.is_some_and(|value| row.project_id != *value)
}
pub(crate) fn mark_projection_task_claimed(row: &mut ProjectionTaskRow, now: DateTime<Utc>) {
row.status = ProjectionTaskStatus::InProgress;
row.updated_at = now;
row.next_retry_at = None;
}
fn mark_projection_task_requeued(
row: &mut ProjectionTaskRow,
last_error: &str,
now: DateTime<Utc>,
) {
row.status = ProjectionTaskStatus::Pending;
row.retry_count = 0;
row.last_error = last_error.to_string();
row.updated_at = now;
row.next_retry_at = None;
}
pub(crate) async fn enqueue_json_projection_task<A>(
adapter: &A,
task: &ProjectionTaskInsert,
max_items: usize,
) -> SystemStoreResult<Uuid>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
let idem_key = adapter.projection_idem_key(&task.idempotency_key);
if let Some(existing) = adapter.get_projection_idem(&idem_key).await? {
return Uuid::parse_str(&existing).map_err(|err| {
SystemStoreError::InvalidInput(format!(
"bad {} task id: {err}",
adapter.projection_backend_label()
))
});
}
let now = Utc::now();
let task_id = Uuid::new_v4();
let row = projection_task_row_from_insert(task_id, task, now);
let row_key = adapter.projection_task_key(task_id);
adapter
.set_projection_idem(&idem_key, &task_id.to_string())
.await?;
adapter.set_projection_row(&row_key, &row).await?;
adapter
.add_projection_row_key(&adapter.projection_all_key(), row_key, max_items)
.await?;
Ok(task_id)
}
pub(crate) async fn claim_json_projection_tasks<A>(
adapter: &A,
filter: &ProjectionClaimFilter,
) -> SystemStoreResult<Vec<ProjectionTaskRow>>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
let mut rows = adapter
.load_projection_rows(&adapter.projection_all_key())
.await?;
rows.sort_by(|a, b| a.created_at.cmp(&b.created_at));
let mut claimed = Vec::new();
for mut row in rows {
if claimed.len() >= filter.batch_size.max(1) as usize {
break;
}
if !projection_task_matches_claim(&row, filter, Utc::now()) {
continue;
}
mark_projection_task_claimed(&mut row, Utc::now());
adapter
.set_projection_row(&adapter.projection_task_key(row.task_id), &row)
.await?;
claimed.push(row);
}
Ok(claimed)
}
pub(crate) async fn mark_json_projection_task_completed<A>(
adapter: &A,
task_id: Uuid,
) -> SystemStoreResult<()>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
let row_key = adapter.projection_task_key(task_id);
let Some(mut row) = adapter.get_projection_row(&row_key).await? else {
return Ok(());
};
row.status = ProjectionTaskStatus::Completed;
row.updated_at = Utc::now();
row.completed_at = Some(row.updated_at);
row.next_retry_at = None;
adapter.set_projection_row(&row_key, &row).await?;
let _ = adapter
.remove_projection_row_key(&adapter.projection_all_key(), &row_key)
.await?;
Ok(())
}
pub(crate) async fn mark_json_projection_task_failed<A>(
adapter: &A,
task_id: Uuid,
new_retry_count: i32,
new_status: ProjectionTaskStatus,
error: &str,
policy: ProjectionTaskFailurePolicy,
) -> SystemStoreResult<()>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
if policy == ProjectionTaskFailurePolicy::StrictFailedOrDeadLetter
&& !matches!(
new_status,
ProjectionTaskStatus::Failed | ProjectionTaskStatus::DeadLetter
)
{
return Err(SystemStoreError::InvalidInput(format!(
"mark_projection_task_failed: status must be FAILED or DEAD_LETTER, got {new_status:?}"
)));
}
let row_key = adapter.projection_task_key(task_id);
let Some(mut row) = adapter.get_projection_row(&row_key).await? else {
return Ok(());
};
row.status = new_status;
row.retry_count = new_retry_count;
row.last_error = error.to_string();
row.updated_at = Utc::now();
row.next_retry_at = (new_status == ProjectionTaskStatus::Failed).then_some(row.updated_at);
adapter.set_projection_row(&row_key, &row).await?;
if policy == ProjectionTaskFailurePolicy::LegacyAllowAnyStatusRemoveCompleted
&& new_status == ProjectionTaskStatus::Completed
{
let _ = adapter
.remove_projection_row_key(&adapter.projection_all_key(), &row_key)
.await?;
}
Ok(())
}
pub(crate) async fn requeue_json_dead_letter_tasks<A>(
adapter: &A,
target_backend: Option<&str>,
) -> SystemStoreResult<i64>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
let rows = adapter
.load_projection_rows(&adapter.projection_all_key())
.await?;
let mut count = 0;
for mut row in rows {
if row.status != ProjectionTaskStatus::DeadLetter
|| target_backend.is_some_and(|backend| row.target_backend != backend)
{
continue;
}
mark_projection_task_requeued(&mut row, "operator requeue", Utc::now());
adapter
.set_projection_row(&adapter.projection_task_key(row.task_id), &row)
.await?;
count += 1;
}
Ok(count)
}
pub(crate) async fn reset_stale_json_in_progress_tasks<A>(
adapter: &A,
stale_after: Duration,
) -> SystemStoreResult<i64>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
let rows = adapter
.load_projection_rows(&adapter.projection_all_key())
.await?;
let cutoff = Utc::now() - chrono::Duration::from_std(stale_after).unwrap_or_default();
let mut count = 0;
for mut row in rows {
if row.status != ProjectionTaskStatus::InProgress || row.updated_at > cutoff {
continue;
}
row.status = ProjectionTaskStatus::Pending;
row.last_error = "stale in-progress reconciliation".to_string();
row.updated_at = Utc::now();
row.next_retry_at = None;
adapter
.set_projection_row(&adapter.projection_task_key(row.task_id), &row)
.await?;
count += 1;
}
Ok(count)
}
pub(crate) fn summarize_projection_tasks(
rows: impl IntoIterator<Item = ProjectionTaskRow>,
) -> ProjectionTaskSummary {
let mut summary = ProjectionTaskSummary::default();
for row in rows {
match row.status {
ProjectionTaskStatus::Pending => summary.pending += 1,
ProjectionTaskStatus::InProgress => summary.in_progress += 1,
ProjectionTaskStatus::Completed => summary.completed += 1,
ProjectionTaskStatus::Failed => summary.failed += 1,
ProjectionTaskStatus::DeadLetter => summary.dead_letter += 1,
}
}
summary
}
pub(crate) fn pending_projection_task_metrics(
rows: impl IntoIterator<Item = ProjectionTaskRow>,
limit: i64,
now: DateTime<Utc>,
) -> Vec<PendingTaskMetric> {
let mut groups: BTreeMap<(String, String, String, String), (i64, f64)> = BTreeMap::new();
for row in rows.into_iter().filter(|row| {
matches!(
row.status,
ProjectionTaskStatus::Pending | ProjectionTaskStatus::Failed
)
}) {
let key = (
row.project_id,
row.target_backend,
row.target_instance,
row.projection_kind,
);
let age = (now - row.created_at).num_milliseconds().max(0) as f64 / 1000.0;
groups
.entry(key)
.and_modify(|entry| {
entry.0 += 1;
entry.1 = entry.1.max(age);
})
.or_insert((1, age));
}
groups
.into_iter()
.take(limit.max(0) as usize)
.map(
|(
(project_id, target_backend, target_instance, projection_kind),
(pending, oldest_age_seconds),
)| PendingTaskMetric {
project_id,
target_backend,
target_instance,
projection_kind,
pending,
oldest_age_seconds,
},
)
.collect()
}
pub(crate) fn projection_dead_letter_groups(
rows: impl IntoIterator<Item = ProjectionTaskRow>,
limit: i64,
) -> Vec<DeadLetterGroup> {
let mut groups: BTreeMap<(String, String, String), i64> = BTreeMap::new();
for row in rows
.into_iter()
.filter(|row| row.status == ProjectionTaskStatus::DeadLetter)
{
*groups
.entry((row.resource_name, row.target_backend, row.target_instance))
.or_default() += 1;
}
groups
.into_iter()
.take(limit.max(0) as usize)
.map(
|((source_table, target_backend, target_instance), dead_count)| DeadLetterGroup {
source_table,
target_backend,
target_instance,
dead_count,
},
)
.collect()
}
pub(crate) async fn requeue_json_dead_letter_by_source<A>(
adapter: &A,
source_table: &str,
target_backend: &str,
target_instance: &str,
) -> SystemStoreResult<i64>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
let rows = adapter
.load_projection_rows(&adapter.projection_all_key())
.await?;
let mut count = 0;
for mut row in rows {
if row.status != ProjectionTaskStatus::DeadLetter
|| row.resource_name != source_table
|| row.target_backend != target_backend
|| row.target_instance != target_instance
{
continue;
}
mark_projection_task_requeued(&mut row, "reconciliation repair", Utc::now());
adapter
.set_projection_row(&adapter.projection_task_key(row.task_id), &row)
.await?;
count += 1;
}
Ok(count)
}
pub(crate) async fn pending_json_projection_task_count<A>(
adapter: &A,
idempotency_keys: &[String],
) -> SystemStoreResult<i64>
where
A: JsonProjectionTaskAdapter + ?Sized,
{
let mut count = 0;
for idem in idempotency_keys {
let idem_key = adapter.projection_idem_key(idem);
let Some(task_id) = adapter
.get_projection_idem(&idem_key)
.await?
.and_then(|id| Uuid::parse_str(&id).ok())
else {
continue;
};
let Some(row) = adapter
.get_projection_row(&adapter.projection_task_key(task_id))
.await?
else {
continue;
};
if !matches!(
row.status,
ProjectionTaskStatus::Completed
| ProjectionTaskStatus::DeadLetter
| ProjectionTaskStatus::Failed
) {
count += 1;
}
}
Ok(count)
}
#[async_trait]
pub trait ProjectionTaskStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_projection_tables(&self) -> SystemStoreResult<()>;
async fn enqueue_projection_task(&self, task: &ProjectionTaskInsert)
-> SystemStoreResult<Uuid>;
async fn claim_projection_tasks(
&self,
filter: &ProjectionClaimFilter,
) -> SystemStoreResult<Vec<ProjectionTaskRow>>;
async fn mark_projection_task_completed(&self, task_id: Uuid) -> SystemStoreResult<()>;
async fn mark_projection_task_failed(
&self,
task_id: Uuid,
new_retry_count: i32,
new_status: ProjectionTaskStatus,
error: &str,
) -> SystemStoreResult<()>;
async fn requeue_dead_letter_tasks(
&self,
target_backend: Option<&str>,
) -> SystemStoreResult<i64>;
async fn reset_stale_in_progress_tasks(&self, stale_after: Duration) -> SystemStoreResult<i64>;
async fn projection_task_summary(&self) -> SystemStoreResult<ProjectionTaskSummary>;
async fn pending_task_metrics(&self, limit: i64) -> SystemStoreResult<Vec<PendingTaskMetric>>;
async fn dead_letter_groups(&self, limit: i64) -> SystemStoreResult<Vec<DeadLetterGroup>>;
async fn requeue_dead_letter_by_source(
&self,
source_table: &str,
target_backend: &str,
target_instance: &str,
) -> SystemStoreResult<i64>;
async fn pending_projection_task_count(
&self,
idempotency_keys: &[String],
) -> SystemStoreResult<i64>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SagaStatus {
Indeterminate,
InProgress,
Pending,
Committed,
Compensated,
Failed,
InDoubt,
FailedCompensation,
ManualReview,
}
impl SagaStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Indeterminate => "indeterminate",
Self::InProgress => "in_progress",
Self::Pending => "pending",
Self::Committed => "committed",
Self::Compensated => "compensated",
Self::Failed => "failed",
Self::InDoubt => "in_doubt",
Self::FailedCompensation => "failed_compensation",
Self::ManualReview => "manual_review",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"indeterminate" => Some(Self::Indeterminate),
"in_progress" => Some(Self::InProgress),
"pending" => Some(Self::Pending),
"committed" => Some(Self::Committed),
"compensated" => Some(Self::Compensated),
"failed" => Some(Self::Failed),
"in_doubt" => Some(Self::InDoubt),
"failed_compensation" => Some(Self::FailedCompensation),
"manual_review" => Some(Self::ManualReview),
_ => None,
}
}
pub fn all() -> &'static [SagaStatus] {
&[
Self::Indeterminate,
Self::InProgress,
Self::Pending,
Self::Committed,
Self::Compensated,
Self::Failed,
Self::InDoubt,
Self::FailedCompensation,
Self::ManualReview,
]
}
pub fn is_recoverable(self) -> bool {
matches!(self, Self::Indeterminate | Self::InProgress | Self::InDoubt)
}
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::Committed | Self::Compensated | Self::ManualReview
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompensationStatus {
None,
Completed,
ManualReview,
RetryRequested,
}
impl CompensationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Completed => "completed",
Self::ManualReview => "manual_review",
Self::RetryRequested => "retry_requested",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"none" => Some(Self::None),
"completed" => Some(Self::Completed),
"manual_review" => Some(Self::ManualReview),
"retry_requested" => Some(Self::RetryRequested),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaInsert {
pub tx_id: String,
pub tenant_id: String,
pub correlation_id: String,
pub backend_instance: String,
pub operation: String,
pub status: SagaStatus,
pub steps: serde_json::Value,
pub compensations: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaRow {
pub saga_id: Uuid,
pub tx_id: String,
pub tenant_id: String,
pub correlation_id: String,
pub status: SagaStatus,
pub backend_instance: String,
pub operation: String,
pub current_step: i32,
pub retry_count: i32,
pub recovery_attempts: i32,
pub compensation_status: CompensationStatus,
pub steps: serde_json::Value,
pub compensations: serde_json::Value,
pub last_error: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SagaListFilter {
pub tenant_id: Option<String>,
pub status: Option<SagaStatus>,
pub tx_id: Option<String>,
pub correlation_id: Option<String>,
pub limit: i64,
pub offset: i64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaSummary {
pub indeterminate: i64,
pub in_progress: i64,
pub pending: i64,
pub committed: i64,
pub compensated: i64,
pub failed: i64,
pub in_doubt: i64,
pub failed_compensation: i64,
pub manual_review: i64,
}
impl SagaSummary {
pub fn total(&self) -> i64 {
self.indeterminate
+ self.in_progress
+ self.pending
+ self.committed
+ self.compensated
+ self.failed
+ self.in_doubt
+ self.failed_compensation
+ self.manual_review
}
pub fn recoverable(&self) -> i64 {
self.indeterminate + self.in_progress + self.in_doubt + self.failed_compensation
}
}
#[async_trait]
pub trait SagaStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_saga_tables(&self) -> SystemStoreResult<()>;
async fn record_saga(&self, saga: &SagaInsert) -> SystemStoreResult<Uuid>;
async fn get_saga(&self, saga_id: Uuid) -> SystemStoreResult<Option<SagaRow>>;
async fn list_sagas(&self, filter: &SagaListFilter) -> SystemStoreResult<Vec<SagaRow>>;
async fn update_saga_status(
&self,
saga_id: Uuid,
status: SagaStatus,
compensation_status: CompensationStatus,
) -> SystemStoreResult<()>;
async fn update_saga_statuses_batch(
&self,
saga_ids: &[Uuid],
status: SagaStatus,
compensation_status: CompensationStatus,
) -> SystemStoreResult<()> {
for &saga_id in saga_ids {
self.update_saga_status(saga_id, status, compensation_status)
.await?;
}
Ok(())
}
async fn mark_saga_manual_review(&self, saga_id: Uuid) -> SystemStoreResult<()>;
async fn request_saga_recompensation(&self, saga_id: Uuid) -> SystemStoreResult<()>;
async fn increment_recovery_attempts(
&self,
saga_id: Uuid,
error: &str,
) -> SystemStoreResult<i64>;
async fn claim_recoverable_sagas(
&self,
stale_after: std::time::Duration,
limit: i64,
) -> SystemStoreResult<Vec<SagaRow>>;
async fn mark_stale_in_progress_indeterminate(
&self,
stale_after: std::time::Duration,
) -> SystemStoreResult<i64>;
async fn saga_summary(&self) -> SystemStoreResult<SagaSummary>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminAuditInsert {
pub actor: String,
pub operation: String,
pub target: String,
pub request_json: serde_json::Value,
pub result: String,
pub tenant_id: String,
pub project_id: String,
pub correlation_id: String,
pub signer_key_id: String,
pub external_anchor: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminAuditRow {
pub audit_id: Uuid,
pub actor: String,
pub operation: String,
pub target: String,
pub request_json: serde_json::Value,
pub result: String,
pub tenant_id: String,
pub project_id: String,
pub correlation_id: String,
pub previous_hash: String,
pub current_hash: String,
pub signer_key_id: String,
pub external_anchor: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AdminAuditListFilter {
pub operation: Option<String>,
pub actor: Option<String>,
pub tenant_id: Option<String>,
pub project_id: Option<String>,
pub limit: i64,
pub offset: i64,
pub redact_request_json: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "outcome")]
pub enum AdminAuditChainReport {
Passed {
checked_count: i64,
last_hash: String,
},
Failed {
checked_count: i64,
first_broken_audit_id: Uuid,
reason: AdminAuditBreakReason,
expected_previous_hash: String,
actual_previous_hash: String,
expected_current_hash: String,
actual_current_hash: String,
},
}
impl AdminAuditChainReport {
pub fn is_passed(&self) -> bool {
matches!(self, Self::Passed { .. })
}
pub fn checked_count(&self) -> i64 {
match self {
Self::Passed { checked_count, .. } => *checked_count,
Self::Failed { checked_count, .. } => *checked_count,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdminAuditBreakReason {
PreviousHashMismatch,
MissingCurrentHash,
CurrentHashMismatch,
}
impl AdminAuditBreakReason {
pub fn as_str(self) -> &'static str {
match self {
Self::PreviousHashMismatch => "previous_hash_mismatch",
Self::MissingCurrentHash => "missing_current_hash",
Self::CurrentHashMismatch => "current_hash_mismatch",
}
}
}
pub fn compute_admin_audit_hash(
previous_hash: &str,
actor: &str,
operation: &str,
target: &str,
request_json: &serde_json::Value,
result: &str,
tenant_id: &str,
project_id: &str,
correlation_id: &str,
signer_key_id: &str,
external_anchor: &str,
) -> String {
use sha2::{Digest, Sha256};
let canonical = serde_json::json!({
"previous_hash": previous_hash,
"actor": actor,
"operation": operation,
"target": target,
"request_json": request_json,
"result": result,
"tenant_id": tenant_id,
"project_id": project_id,
"correlation_id": correlation_id,
"signer_key_id": signer_key_id,
"external_anchor": external_anchor,
});
let encoded = serde_json::to_vec(&canonical).unwrap_or_default();
format!("{:x}", Sha256::digest(encoded))
}
pub fn verify_admin_audit_chain_step(
row: &AdminAuditRow,
expected_previous_hash: &str,
checked_count: i64,
) -> Result<String, AdminAuditChainReport> {
if row.previous_hash != expected_previous_hash {
return Err(AdminAuditChainReport::Failed {
checked_count,
first_broken_audit_id: row.audit_id,
reason: AdminAuditBreakReason::PreviousHashMismatch,
expected_previous_hash: expected_previous_hash.to_string(),
actual_previous_hash: row.previous_hash.clone(),
expected_current_hash: String::new(),
actual_current_hash: String::new(),
});
}
if row.current_hash.is_empty() {
let expected_current_hash = compute_admin_audit_hash(
&row.previous_hash,
&row.actor,
&row.operation,
&row.target,
&row.request_json,
&row.result,
&row.tenant_id,
&row.project_id,
&row.correlation_id,
&row.signer_key_id,
&row.external_anchor,
);
return Err(AdminAuditChainReport::Failed {
checked_count,
first_broken_audit_id: row.audit_id,
reason: AdminAuditBreakReason::MissingCurrentHash,
expected_previous_hash: expected_previous_hash.to_string(),
actual_previous_hash: row.previous_hash.clone(),
expected_current_hash,
actual_current_hash: row.current_hash.clone(),
});
}
let expected_current_hash = compute_admin_audit_hash(
&row.previous_hash,
&row.actor,
&row.operation,
&row.target,
&row.request_json,
&row.result,
&row.tenant_id,
&row.project_id,
&row.correlation_id,
&row.signer_key_id,
&row.external_anchor,
);
if row.current_hash != expected_current_hash {
return Err(AdminAuditChainReport::Failed {
checked_count,
first_broken_audit_id: row.audit_id,
reason: AdminAuditBreakReason::CurrentHashMismatch,
expected_previous_hash: expected_previous_hash.to_string(),
actual_previous_hash: row.previous_hash.clone(),
expected_current_hash,
actual_current_hash: row.current_hash.clone(),
});
}
Ok(row.current_hash.clone())
}
#[async_trait]
pub trait AdminAuditStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_admin_audit_tables(&self) -> SystemStoreResult<()>;
async fn latest_admin_audit_hash(&self) -> SystemStoreResult<String>;
async fn append_admin_audit(&self, entry: &AdminAuditInsert) -> SystemStoreResult<Uuid>;
async fn list_admin_audit(
&self,
filter: &AdminAuditListFilter,
) -> SystemStoreResult<Vec<AdminAuditRow>>;
async fn verify_admin_audit_chain(
&self,
limit: Option<i64>,
) -> SystemStoreResult<AdminAuditChainReport>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MigrationRunState {
DryRun,
Preflight,
Approved,
Applying,
Verifying,
Completed,
Error,
DeadLetter,
}
impl MigrationRunState {
pub fn as_str(self) -> &'static str {
match self {
Self::DryRun => "DRY_RUN",
Self::Preflight => "PREFLIGHT",
Self::Approved => "APPROVED",
Self::Applying => "APPLYING",
Self::Verifying => "VERIFYING",
Self::Completed => "COMPLETED",
Self::Error => "ERROR",
Self::DeadLetter => "DEAD_LETTER",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"DRY_RUN" => Some(Self::DryRun),
"PREFLIGHT" => Some(Self::Preflight),
"APPROVED" => Some(Self::Approved),
"APPLYING" => Some(Self::Applying),
"VERIFYING" => Some(Self::Verifying),
"COMPLETED" => Some(Self::Completed),
"ERROR" => Some(Self::Error),
"DEAD_LETTER" => Some(Self::DeadLetter),
_ => None,
}
}
pub fn all() -> &'static [MigrationRunState] {
&[
Self::DryRun,
Self::Preflight,
Self::Approved,
Self::Applying,
Self::Verifying,
Self::Completed,
Self::Error,
Self::DeadLetter,
]
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Error | Self::DeadLetter)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum OpLedgerStatus {
Pending,
Applied,
Verified,
Skipped,
Failed,
RolledBack,
}
impl OpLedgerStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "PENDING",
Self::Applied => "APPLIED",
Self::Verified => "VERIFIED",
Self::Skipped => "SKIPPED",
Self::Failed => "FAILED",
Self::RolledBack => "ROLLED_BACK",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"PENDING" => Some(Self::Pending),
"APPLIED" => Some(Self::Applied),
"VERIFIED" => Some(Self::Verified),
"SKIPPED" => Some(Self::Skipped),
"FAILED" => Some(Self::Failed),
"ROLLED_BACK" => Some(Self::RolledBack),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationRunInsert {
pub project_id: String,
pub catalog_version: String,
pub operations_hash: String,
pub approval_token: String,
pub state: MigrationRunState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationOpInsert {
pub run_id: Uuid,
pub operation_index: i32,
pub backend: String,
pub resource_uri: String,
pub operation_kind: String,
pub status: OpLedgerStatus,
pub payload_json: serde_json::Value,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationRunRow {
pub run_id: Uuid,
pub project_id: String,
pub catalog_version: String,
pub state: MigrationRunState,
pub operations_hash: String,
pub approval_token: String,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationOpRow {
pub id: i64,
pub run_id: Uuid,
pub operation_index: i32,
pub backend: String,
pub resource_uri: String,
pub operation_kind: String,
pub status: OpLedgerStatus,
pub payload_json: serde_json::Value,
pub error: String,
pub applied_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MigrationRunsFilter {
pub project_id: Option<String>,
pub state: Option<MigrationRunState>,
pub catalog_version: Option<String>,
pub limit: i64,
pub offset: i64,
}
#[async_trait]
pub trait MigrationAuditStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_migration_audit_tables(&self) -> SystemStoreResult<()>;
async fn start_migration_run(&self, run: &MigrationRunInsert) -> SystemStoreResult<Uuid>;
async fn record_migration_op(&self, op: &MigrationOpInsert) -> SystemStoreResult<i64>;
async fn finish_migration_run(
&self,
run_id: Uuid,
new_state: MigrationRunState,
error: &str,
) -> SystemStoreResult<()>;
async fn get_migration_run(&self, run_id: Uuid) -> SystemStoreResult<Option<MigrationRunRow>>;
async fn list_migration_ops(&self, run_id: Uuid) -> SystemStoreResult<Vec<MigrationOpRow>>;
async fn list_migration_runs(
&self,
filter: &MigrationRunsFilter,
) -> SystemStoreResult<Vec<MigrationRunRow>>;
}
pub(crate) const JSON_ADMIN_AUDIT_LOCK: &str = "admin-audit-chain";
#[async_trait]
pub(crate) trait JsonSystemRecordAdapter: Send + Sync {
fn record_backend_label(&self) -> &'static str;
fn record_point_key(&self, suffix: &str) -> String;
fn saga_record_key(&self, saga_id: Uuid) -> String;
fn admin_audit_record_key(&self, audit_id: Uuid) -> String;
fn migration_run_record_key(&self, run_id: Uuid) -> String;
async fn get_saga_row(&self, key: &str) -> SystemStoreResult<Option<SagaRow>>;
async fn set_saga_row(&self, key: &str, row: &SagaRow) -> SystemStoreResult<()>;
async fn load_saga_rows(&self, set_key: &str) -> SystemStoreResult<Vec<SagaRow>>;
async fn get_admin_audit_row(&self, key: &str) -> SystemStoreResult<Option<AdminAuditRow>>;
async fn set_admin_audit_row(&self, key: &str, row: &AdminAuditRow) -> SystemStoreResult<()>;
async fn get_string_record(&self, key: &str) -> SystemStoreResult<Option<String>>;
async fn set_string_record(&self, key: &str, value: &String) -> SystemStoreResult<()>;
async fn get_migration_run_row(&self, key: &str) -> SystemStoreResult<Option<MigrationRunRow>>;
async fn set_migration_run_row(
&self,
key: &str,
row: &MigrationRunRow,
) -> SystemStoreResult<()>;
async fn load_migration_run_rows(
&self,
set_key: &str,
) -> SystemStoreResult<Vec<MigrationRunRow>>;
async fn load_migration_op_rows(&self, set_key: &str)
-> SystemStoreResult<Vec<MigrationOpRow>>;
async fn list_record_set(&self, set_key: &str) -> SystemStoreResult<Vec<String>>;
async fn add_record_to_set(&self, set_key: &str, item: String) -> SystemStoreResult<()>;
}
pub(crate) async fn record_json_saga<A>(adapter: &A, saga: &SagaInsert) -> SystemStoreResult<Uuid>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let now = Utc::now();
let saga_id = Uuid::new_v4();
let row = SagaRow {
saga_id,
tx_id: saga.tx_id.clone(),
tenant_id: saga.tenant_id.clone(),
correlation_id: saga.correlation_id.clone(),
status: saga.status,
backend_instance: saga.backend_instance.clone(),
operation: saga.operation.clone(),
current_step: 0,
retry_count: 0,
recovery_attempts: 0,
compensation_status: CompensationStatus::None,
steps: saga.steps.clone(),
compensations: saga.compensations.clone(),
last_error: String::new(),
created_at: now,
updated_at: now,
};
let key = adapter.saga_record_key(saga_id);
adapter.set_saga_row(&key, &row).await?;
adapter
.add_record_to_set(&adapter.record_point_key("saga_all"), key)
.await?;
Ok(saga_id)
}
pub(crate) async fn get_json_saga<A>(
adapter: &A,
saga_id: Uuid,
) -> SystemStoreResult<Option<SagaRow>>
where
A: JsonSystemRecordAdapter + ?Sized,
{
adapter
.get_saga_row(&adapter.saga_record_key(saga_id))
.await
}
pub(crate) async fn list_json_sagas<A>(
adapter: &A,
filter: &SagaListFilter,
) -> SystemStoreResult<Vec<SagaRow>>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let mut rows = adapter
.load_saga_rows(&adapter.record_point_key("saga_all"))
.await?;
rows.retain(|row| apply_saga_filter(row, filter));
rows.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(rows
.into_iter()
.skip(filter.offset.max(0) as usize)
.take(filter.limit.max(0) as usize)
.collect())
}
pub(crate) async fn update_json_saga_status<A>(
adapter: &A,
saga_id: Uuid,
status: SagaStatus,
compensation_status: CompensationStatus,
) -> SystemStoreResult<()>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let key = adapter.saga_record_key(saga_id);
let Some(mut row) = adapter.get_saga_row(&key).await? else {
return Ok(());
};
row.status = status;
row.compensation_status = compensation_status;
row.updated_at = Utc::now();
adapter.set_saga_row(&key, &row).await
}
pub(crate) async fn increment_json_saga_recovery_attempts<A>(
adapter: &A,
saga_id: Uuid,
error: &str,
) -> SystemStoreResult<i64>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let key = adapter.saga_record_key(saga_id);
let Some(mut row) = adapter.get_saga_row(&key).await? else {
return Ok(0);
};
row.recovery_attempts += 1;
row.last_error = error.to_string();
row.updated_at = Utc::now();
let attempts = i64::from(row.recovery_attempts);
adapter.set_saga_row(&key, &row).await?;
Ok(attempts)
}
pub(crate) async fn claim_json_recoverable_sagas<A>(
adapter: &A,
stale_after: Duration,
limit: i64,
) -> SystemStoreResult<Vec<SagaRow>>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let cutoff = Utc::now() - chrono::Duration::from_std(stale_after).unwrap_or_default();
let mut rows = adapter
.load_saga_rows(&adapter.record_point_key("saga_all"))
.await?;
rows.retain(|row| {
matches!(
row.status,
SagaStatus::Indeterminate | SagaStatus::InDoubt | SagaStatus::FailedCompensation
) || (row.status == SagaStatus::InProgress && row.updated_at <= cutoff)
});
rows.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
Ok(rows.into_iter().take(limit.max(0) as usize).collect())
}
pub(crate) async fn mark_json_stale_sagas_indeterminate<A>(
adapter: &A,
stale_after: Duration,
) -> SystemStoreResult<i64>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let cutoff = Utc::now() - chrono::Duration::from_std(stale_after).unwrap_or_default();
let rows = adapter
.load_saga_rows(&adapter.record_point_key("saga_all"))
.await?;
let mut count = 0;
for mut row in rows {
if row.status == SagaStatus::InProgress && row.updated_at <= cutoff {
row.status = SagaStatus::Indeterminate;
row.updated_at = Utc::now();
adapter
.set_saga_row(&adapter.saga_record_key(row.saga_id), &row)
.await?;
count += 1;
}
}
Ok(count)
}
pub(crate) async fn summarize_json_sagas<A>(adapter: &A) -> SystemStoreResult<SagaSummary>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let rows = adapter
.load_saga_rows(&adapter.record_point_key("saga_all"))
.await?;
let mut summary = SagaSummary::default();
for row in rows {
match row.status {
SagaStatus::Indeterminate => summary.indeterminate += 1,
SagaStatus::InProgress => summary.in_progress += 1,
SagaStatus::Pending => summary.pending += 1,
SagaStatus::Committed => summary.committed += 1,
SagaStatus::Compensated => summary.compensated += 1,
SagaStatus::Failed => summary.failed += 1,
SagaStatus::InDoubt => summary.in_doubt += 1,
SagaStatus::FailedCompensation => summary.failed_compensation += 1,
SagaStatus::ManualReview => summary.manual_review += 1,
}
}
Ok(summary)
}
pub(crate) async fn latest_json_admin_audit_hash<A>(adapter: &A) -> SystemStoreResult<String>
where
A: JsonSystemRecordAdapter + ?Sized,
{
Ok(adapter
.get_string_record(&adapter.record_point_key("admin_audit_latest_hash"))
.await?
.unwrap_or_default())
}
pub(crate) async fn append_json_admin_audit<A>(
adapter: &A,
entry: &AdminAuditInsert,
) -> SystemStoreResult<Uuid>
where
A: JsonSystemRecordAdapter + CanonicalStore + ?Sized,
{
let backend = adapter.record_backend_label();
let owner = Uuid::new_v4().to_string();
let started = Instant::now();
while !adapter
.try_acquire_advisory_lease(JSON_ADMIN_AUDIT_LOCK, &owner, Duration::from_secs(10))
.await
.map_err(|err| SystemStoreError::io(backend, err))?
{
if started.elapsed() > Duration::from_secs(10) {
return Err(SystemStoreError::io(backend, "admin audit lock timeout"));
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let audit_id = Uuid::new_v4();
let previous_hash = latest_json_admin_audit_hash(adapter).await?;
let current_hash = compute_admin_audit_hash(
&previous_hash,
&entry.actor,
&entry.operation,
&entry.target,
&entry.request_json,
&entry.result,
&entry.tenant_id,
&entry.project_id,
&entry.correlation_id,
&entry.signer_key_id,
&entry.external_anchor,
);
let row = AdminAuditRow {
audit_id,
actor: entry.actor.clone(),
operation: entry.operation.clone(),
target: entry.target.clone(),
request_json: entry.request_json.clone(),
result: entry.result.clone(),
tenant_id: entry.tenant_id.clone(),
project_id: entry.project_id.clone(),
correlation_id: entry.correlation_id.clone(),
previous_hash,
current_hash: current_hash.clone(),
signer_key_id: entry.signer_key_id.clone(),
external_anchor: entry.external_anchor.clone(),
created_at: Utc::now(),
};
let key = adapter.admin_audit_record_key(audit_id);
let result = async {
adapter.set_admin_audit_row(&key, &row).await?;
adapter
.add_record_to_set(&adapter.record_point_key("admin_audit_order"), key)
.await?;
adapter
.set_string_record(
&adapter.record_point_key("admin_audit_latest_hash"),
¤t_hash,
)
.await
}
.await;
let _ = adapter
.release_advisory_lease(JSON_ADMIN_AUDIT_LOCK, &owner)
.await;
result?;
Ok(audit_id)
}
pub(crate) async fn list_json_admin_audit<A>(
adapter: &A,
filter: &AdminAuditListFilter,
) -> SystemStoreResult<Vec<AdminAuditRow>>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let keys = adapter
.list_record_set(&adapter.record_point_key("admin_audit_order"))
.await?;
let mut rows = Vec::new();
for key in keys {
if let Some(mut row) = adapter.get_admin_audit_row(&key).await?
&& apply_admin_audit_filter(&row, filter)
{
if filter.redact_request_json {
row.request_json = serde_json::json!({"redacted": true});
}
rows.push(row);
}
}
rows.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(rows
.into_iter()
.skip(filter.offset.max(0) as usize)
.take(filter.limit.max(0) as usize)
.collect())
}
pub(crate) async fn verify_json_admin_audit_chain<A>(
adapter: &A,
limit: Option<i64>,
) -> SystemStoreResult<AdminAuditChainReport>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let mut keys = adapter
.list_record_set(&adapter.record_point_key("admin_audit_order"))
.await?;
if let Some(limit) = limit
&& limit >= 0
{
keys.truncate(limit as usize);
}
let mut checked = 0_i64;
let mut previous = String::new();
for key in keys {
let Some(row) = adapter.get_admin_audit_row(&key).await? else {
continue;
};
match verify_admin_audit_chain_step(&row, &previous, checked) {
Ok(next) => {
previous = next;
checked += 1;
}
Err(report) => return Ok(report),
}
}
Ok(AdminAuditChainReport::Passed {
checked_count: checked,
last_hash: previous,
})
}
pub(crate) async fn start_json_migration_run<A>(
adapter: &A,
run: &MigrationRunInsert,
) -> SystemStoreResult<Uuid>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let run_id = Uuid::new_v4();
let row = MigrationRunRow {
run_id,
project_id: run.project_id.clone(),
catalog_version: run.catalog_version.clone(),
state: run.state,
operations_hash: run.operations_hash.clone(),
approval_token: run.approval_token.clone(),
started_at: Utc::now(),
finished_at: None,
error: String::new(),
};
let key = adapter.migration_run_record_key(run_id);
adapter.set_migration_run_row(&key, &row).await?;
adapter
.add_record_to_set(&adapter.record_point_key("migration_runs"), key)
.await?;
Ok(run_id)
}
pub(crate) async fn get_json_migration_run<A>(
adapter: &A,
run_id: Uuid,
) -> SystemStoreResult<Option<MigrationRunRow>>
where
A: JsonSystemRecordAdapter + ?Sized,
{
adapter
.get_migration_run_row(&adapter.migration_run_record_key(run_id))
.await
}
pub(crate) async fn list_json_migration_ops<A>(
adapter: &A,
run_id: Uuid,
) -> SystemStoreResult<Vec<MigrationOpRow>>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let mut rows = adapter
.load_migration_op_rows(&adapter.record_point_key(&format!("migration_ops:{run_id}")))
.await?;
rows.sort_by_key(|row| row.operation_index);
Ok(rows)
}
pub(crate) async fn list_json_migration_runs<A>(
adapter: &A,
filter: &MigrationRunsFilter,
) -> SystemStoreResult<Vec<MigrationRunRow>>
where
A: JsonSystemRecordAdapter + ?Sized,
{
let mut rows = adapter
.load_migration_run_rows(&adapter.record_point_key("migration_runs"))
.await?;
rows.retain(|row| apply_migration_run_filter(row, filter));
rows.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(rows
.into_iter()
.skip(filter.offset.max(0) as usize)
.take(filter.limit.max(0) as usize)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_status_tokens_are_pinned() {
assert_eq!(ProjectionTaskStatus::Pending.as_str(), "PENDING");
assert_eq!(ProjectionTaskStatus::InProgress.as_str(), "IN_PROGRESS");
assert_eq!(ProjectionTaskStatus::Completed.as_str(), "COMPLETED");
assert_eq!(ProjectionTaskStatus::Failed.as_str(), "FAILED");
assert_eq!(ProjectionTaskStatus::DeadLetter.as_str(), "DEAD_LETTER");
for s in [
ProjectionTaskStatus::Pending,
ProjectionTaskStatus::InProgress,
ProjectionTaskStatus::Completed,
ProjectionTaskStatus::Failed,
ProjectionTaskStatus::DeadLetter,
] {
assert_eq!(ProjectionTaskStatus::parse(s.as_str()), Some(s));
}
assert_eq!(ProjectionTaskStatus::parse("bogus"), None);
}
#[test]
fn projection_operation_tokens_are_pinned() {
assert_eq!(ProjectionOperation::Upsert.as_str(), "upsert");
assert_eq!(ProjectionOperation::Delete.as_str(), "delete");
assert_eq!(
ProjectionOperation::parse("upsert"),
Some(ProjectionOperation::Upsert)
);
assert_eq!(
ProjectionOperation::parse("delete"),
Some(ProjectionOperation::Delete)
);
assert_eq!(ProjectionOperation::parse("INSERT"), None);
}
#[test]
fn projection_claimability_pinned() {
assert!(ProjectionTaskStatus::Pending.is_claimable());
assert!(ProjectionTaskStatus::Failed.is_claimable());
assert!(!ProjectionTaskStatus::InProgress.is_claimable());
assert!(!ProjectionTaskStatus::Completed.is_claimable());
assert!(!ProjectionTaskStatus::DeadLetter.is_claimable());
assert!(ProjectionTaskStatus::Completed.is_terminal());
assert!(ProjectionTaskStatus::DeadLetter.is_terminal());
assert!(!ProjectionTaskStatus::Pending.is_terminal());
}
#[test]
fn projection_summary_arithmetic_is_pinned() {
let s = ProjectionTaskSummary {
pending: 5,
in_progress: 2,
completed: 100,
failed: 3,
dead_letter: 1,
};
assert_eq!(s.total(), 111);
assert_eq!(s.claimable(), 8);
}
#[test]
fn saga_status_tokens_are_pinned() {
assert_eq!(SagaStatus::Indeterminate.as_str(), "indeterminate");
assert_eq!(SagaStatus::InProgress.as_str(), "in_progress");
assert_eq!(SagaStatus::Pending.as_str(), "pending");
assert_eq!(SagaStatus::Committed.as_str(), "committed");
assert_eq!(SagaStatus::Compensated.as_str(), "compensated");
assert_eq!(SagaStatus::Failed.as_str(), "failed");
assert_eq!(SagaStatus::InDoubt.as_str(), "in_doubt");
assert_eq!(
SagaStatus::FailedCompensation.as_str(),
"failed_compensation"
);
assert_eq!(SagaStatus::ManualReview.as_str(), "manual_review");
for s in SagaStatus::all() {
assert_eq!(SagaStatus::parse(s.as_str()), Some(*s));
}
assert_eq!(SagaStatus::parse("garbage"), None);
assert_eq!(SagaStatus::all().len(), 9, "exactly 9 saga statuses");
}
#[test]
fn saga_recoverability_pinned() {
assert!(SagaStatus::Indeterminate.is_recoverable());
assert!(SagaStatus::InProgress.is_recoverable());
assert!(SagaStatus::InDoubt.is_recoverable());
assert!(!SagaStatus::Pending.is_recoverable());
assert!(!SagaStatus::Committed.is_recoverable());
assert!(SagaStatus::Committed.is_terminal());
assert!(SagaStatus::Compensated.is_terminal());
assert!(SagaStatus::ManualReview.is_terminal());
assert!(!SagaStatus::FailedCompensation.is_terminal());
}
#[test]
fn compensation_status_tokens_are_pinned() {
assert_eq!(CompensationStatus::None.as_str(), "none");
assert_eq!(CompensationStatus::Completed.as_str(), "completed");
assert_eq!(CompensationStatus::ManualReview.as_str(), "manual_review");
assert_eq!(
CompensationStatus::RetryRequested.as_str(),
"retry_requested"
);
for token in ["none", "completed", "manual_review", "retry_requested"] {
assert_eq!(
CompensationStatus::parse(token).map(|s| s.as_str()),
Some(token)
);
}
}
#[test]
fn saga_summary_arithmetic_is_pinned() {
let s = SagaSummary {
indeterminate: 1,
in_progress: 2,
pending: 3,
committed: 10,
compensated: 5,
failed: 1,
in_doubt: 6,
failed_compensation: 2,
manual_review: 4,
};
assert_eq!(s.total(), 34);
assert_eq!(s.recoverable(), 1 + 2 + 6 + 2);
}
fn sample_audit_row(audit_id: Uuid, previous_hash: &str) -> AdminAuditRow {
let req = serde_json::json!({"target": "catalog"});
let current = compute_admin_audit_hash(
previous_hash,
"operator-1",
"ActivateCatalog",
"project-alpha",
&req,
"ok",
"tenant-1",
"project-alpha",
"corr-1",
"default",
"",
);
AdminAuditRow {
audit_id,
actor: "operator-1".to_string(),
operation: "ActivateCatalog".to_string(),
target: "project-alpha".to_string(),
request_json: req,
result: "ok".to_string(),
tenant_id: "tenant-1".to_string(),
project_id: "project-alpha".to_string(),
correlation_id: "corr-1".to_string(),
previous_hash: previous_hash.to_string(),
current_hash: current,
signer_key_id: "default".to_string(),
external_anchor: String::new(),
created_at: Utc::now(),
}
}
#[test]
fn admin_audit_hash_is_deterministic_and_collision_resistant() {
let req = serde_json::json!({"x": 1});
let h1 = compute_admin_audit_hash(
"", "actor", "Op", "t", &req, "ok", "tn", "pj", "co", "k", "",
);
let h2 = compute_admin_audit_hash(
"", "actor", "Op", "t", &req, "ok", "tn", "pj", "co", "k", "",
);
assert_eq!(h1, h2, "same inputs → same hash");
assert_eq!(h1.len(), 64, "SHA-256 hex is 64 chars");
let h_changed = compute_admin_audit_hash(
"",
"actor",
"Op",
"t",
&req,
"ok",
"tn",
"pj",
"co",
"DIFFERENT",
"",
);
assert_ne!(h1, h_changed);
}
#[test]
fn admin_audit_chain_step_accepts_valid_row() {
let row = sample_audit_row(Uuid::new_v4(), "");
let next_previous =
verify_admin_audit_chain_step(&row, "", 0).expect("first row links the empty seed");
assert_eq!(next_previous, row.current_hash);
}
#[test]
fn admin_audit_chain_step_rejects_previous_hash_mismatch() {
let mut row = sample_audit_row(Uuid::new_v4(), "abcd");
row.previous_hash = "wrong".to_string();
let err = verify_admin_audit_chain_step(&row, "expected-hash", 5).expect_err("must reject");
match err {
AdminAuditChainReport::Failed {
reason,
checked_count,
expected_previous_hash,
actual_previous_hash,
..
} => {
assert_eq!(reason, AdminAuditBreakReason::PreviousHashMismatch);
assert_eq!(checked_count, 5);
assert_eq!(expected_previous_hash, "expected-hash");
assert_eq!(actual_previous_hash, "wrong");
}
other => panic!("expected Failed, got: {other:?}"),
}
}
#[test]
fn admin_audit_chain_step_rejects_missing_current_hash() {
let mut row = sample_audit_row(Uuid::new_v4(), "");
row.current_hash = String::new();
let err = verify_admin_audit_chain_step(&row, "", 0).expect_err("must reject");
assert!(matches!(
err,
AdminAuditChainReport::Failed {
reason: AdminAuditBreakReason::MissingCurrentHash,
..
}
));
}
#[test]
fn admin_audit_chain_step_rejects_tampered_body() {
let mut row = sample_audit_row(Uuid::new_v4(), "");
row.operation = "MaliciouslyAlteredOp".to_string();
let err = verify_admin_audit_chain_step(&row, "", 0).expect_err("must reject");
match err {
AdminAuditChainReport::Failed {
reason,
expected_current_hash,
actual_current_hash,
..
} => {
assert_eq!(reason, AdminAuditBreakReason::CurrentHashMismatch);
assert_ne!(expected_current_hash, actual_current_hash);
}
other => panic!("expected Failed, got: {other:?}"),
}
}
#[test]
fn admin_audit_break_reason_tokens_pinned() {
assert_eq!(
AdminAuditBreakReason::PreviousHashMismatch.as_str(),
"previous_hash_mismatch"
);
assert_eq!(
AdminAuditBreakReason::MissingCurrentHash.as_str(),
"missing_current_hash"
);
assert_eq!(
AdminAuditBreakReason::CurrentHashMismatch.as_str(),
"current_hash_mismatch"
);
}
#[test]
fn admin_audit_chain_report_predicates() {
let passed = AdminAuditChainReport::Passed {
checked_count: 10,
last_hash: "abc".to_string(),
};
let failed = AdminAuditChainReport::Failed {
checked_count: 5,
first_broken_audit_id: Uuid::nil(),
reason: AdminAuditBreakReason::PreviousHashMismatch,
expected_previous_hash: String::new(),
actual_previous_hash: String::new(),
expected_current_hash: String::new(),
actual_current_hash: String::new(),
};
assert!(passed.is_passed());
assert!(!failed.is_passed());
assert_eq!(passed.checked_count(), 10);
assert_eq!(failed.checked_count(), 5);
}
#[test]
fn migration_run_state_tokens_pinned() {
assert_eq!(MigrationRunState::DryRun.as_str(), "DRY_RUN");
assert_eq!(MigrationRunState::Preflight.as_str(), "PREFLIGHT");
assert_eq!(MigrationRunState::Approved.as_str(), "APPROVED");
assert_eq!(MigrationRunState::Applying.as_str(), "APPLYING");
assert_eq!(MigrationRunState::Verifying.as_str(), "VERIFYING");
assert_eq!(MigrationRunState::Completed.as_str(), "COMPLETED");
assert_eq!(MigrationRunState::Error.as_str(), "ERROR");
assert_eq!(MigrationRunState::DeadLetter.as_str(), "DEAD_LETTER");
for s in MigrationRunState::all() {
assert_eq!(MigrationRunState::parse(s.as_str()), Some(*s));
}
assert_eq!(MigrationRunState::parse("garbage"), None);
assert_eq!(MigrationRunState::all().len(), 8);
assert!(MigrationRunState::Completed.is_terminal());
assert!(MigrationRunState::Error.is_terminal());
assert!(MigrationRunState::DeadLetter.is_terminal());
assert!(!MigrationRunState::Approved.is_terminal());
assert!(!MigrationRunState::Applying.is_terminal());
assert!(!MigrationRunState::DryRun.is_terminal());
}
#[test]
fn op_ledger_status_tokens_pinned() {
for (s, token) in [
(OpLedgerStatus::Pending, "PENDING"),
(OpLedgerStatus::Applied, "APPLIED"),
(OpLedgerStatus::Verified, "VERIFIED"),
(OpLedgerStatus::Skipped, "SKIPPED"),
(OpLedgerStatus::Failed, "FAILED"),
(OpLedgerStatus::RolledBack, "ROLLED_BACK"),
] {
assert_eq!(s.as_str(), token);
assert_eq!(OpLedgerStatus::parse(token), Some(s));
}
assert_eq!(OpLedgerStatus::parse("none"), None);
}
#[test]
fn system_store_error_display_is_useful() {
let big_sql = format!("SELECT {} FROM t", "x".repeat(500));
let e = SystemStoreError::query("postgres", big_sql.clone(), "syntax error at $1");
let s = format!("{e}");
assert!(s.contains("postgres"));
assert!(s.contains("syntax error at $1"));
assert!(!s.contains(&"x".repeat(200)));
assert!(s.len() < big_sql.len(), "error must not embed full SQL");
}
}