use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde_json::Value as Json;
use uuid::Uuid;
use super::clickhouse::{ClickHouseCanonicalStore, sql_lit};
use super::system_store::{
DeadLetterGroup, PendingTaskMetric, ProjectionClaimFilter, ProjectionOperation,
ProjectionTaskInsert, ProjectionTaskRow, ProjectionTaskStatus, ProjectionTaskStore,
ProjectionTaskSummary, SystemStoreError, SystemStoreResult,
};
const CLICKHOUSE_PROJECTION_MUTATION_LOCK: &str = "__udb_clickhouse_projection_tasks";
pub(super) fn ch_i64(row: &Json, key: &str) -> i64 {
let Some(cell) = row.get(key) else {
return 0;
};
cell.as_i64()
.or_else(|| cell.as_str().and_then(|s| s.parse::<i64>().ok()))
.unwrap_or(0)
}
pub(super) fn ch_i32(row: &Json, key: &str) -> i32 {
ch_i64(row, key) as i32
}
pub(super) fn ch_u64(row: &Json, key: &str) -> u64 {
let Some(cell) = row.get(key) else {
return 0;
};
cell.as_str()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| cell.as_u64())
.unwrap_or(0)
}
pub(super) fn ch_str(row: &Json, key: &str) -> String {
row.get(key)
.and_then(Json::as_str)
.map(|s| s.to_string())
.unwrap_or_default()
}
pub(super) fn ch_uuid(row: &Json, key: &str) -> SystemStoreResult<Uuid> {
let raw = ch_str(row, key);
Uuid::parse_str(&raw).map_err(|e| {
SystemStoreError::InvalidInput(format!(
"clickhouse row column '{key}' is not a uuid '{raw}': {e}"
))
})
}
pub(super) fn ch_json(row: &Json, key: &str, default: Json) -> Json {
match row.get(key).and_then(Json::as_str) {
Some(s) if !s.is_empty() => serde_json::from_str(s).unwrap_or(default),
_ => default,
}
}
pub(super) fn ch_dt(row: &Json, key: &str) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp_millis(ch_i64(row, key)).unwrap_or_else(Utc::now)
}
pub(super) fn ch_opt_dt(row: &Json, key: &str) -> Option<DateTime<Utc>> {
match ch_i64(row, key) {
0 => None,
millis => DateTime::<Utc>::from_timestamp_millis(millis),
}
}
pub(super) fn ch_err(op: &str, err: impl std::fmt::Display) -> SystemStoreError {
SystemStoreError::Io {
backend: "clickhouse",
source: format!("{op}: {err}"),
}
}
pub(super) fn opt_dt_lit(dt: Option<DateTime<Utc>>) -> i64 {
dt.map(|d| d.timestamp_millis()).unwrap_or(0)
}
const TASK_COLS: &str = "task_id, idempotency_key, project_id, target_backend, target_instance, \
projection_kind, resource_name, operation, source_row_key, target_options, \
source_payload, source_checksum, status, retry_count, last_error, \
created_at, updated_at, next_retry_at, completed_at";
const TASK_STATIC_COLS: &str = "manifest_checksum, message_type, source_schema, source_table";
#[derive(Default, Clone)]
struct TaskStaticCols {
manifest_checksum: String,
message_type: String,
source_schema: String,
source_table: String,
}
fn row_to_static_cols(row: &Json) -> TaskStaticCols {
TaskStaticCols {
manifest_checksum: ch_str(row, "manifest_checksum"),
message_type: ch_str(row, "message_type"),
source_schema: ch_str(row, "source_schema"),
source_table: ch_str(row, "source_table"),
}
}
fn row_to_projection_task(row: &Json) -> SystemStoreResult<ProjectionTaskRow> {
let task_id = ch_uuid(row, "task_id")?;
let operation_str = ch_str(row, "operation");
let operation = ProjectionOperation::parse(&operation_str).ok_or_else(|| {
SystemStoreError::InvalidInput(format!(
"unknown projection operation '{operation_str}' in clickhouse row"
))
})?;
let status_str = ch_str(row, "status");
let status = ProjectionTaskStatus::parse(&status_str).ok_or_else(|| {
SystemStoreError::InvalidInput(format!(
"unknown projection status '{status_str}' in clickhouse row"
))
})?;
Ok(ProjectionTaskRow {
task_id,
idempotency_key: ch_str(row, "idempotency_key"),
project_id: ch_str(row, "project_id"),
target_backend: ch_str(row, "target_backend"),
target_instance: ch_str(row, "target_instance"),
projection_kind: ch_str(row, "projection_kind"),
resource_name: ch_str(row, "resource_name"),
operation,
source_row_key: ch_json(row, "source_row_key", Json::Null),
target_options: ch_json(row, "target_options", Json::Null),
source_payload: ch_json(row, "source_payload", Json::Null),
source_checksum: ch_str(row, "source_checksum"),
status,
retry_count: ch_i32(row, "retry_count"),
last_error: ch_str(row, "last_error"),
created_at: ch_dt(row, "created_at"),
updated_at: ch_dt(row, "updated_at"),
next_retry_at: ch_opt_dt(row, "next_retry_at"),
completed_at: ch_opt_dt(row, "completed_at"),
})
}
impl ClickHouseCanonicalStore {
fn projection_table(&self) -> SystemStoreResult<String> {
self.qualified("udb_projection_tasks")
.map_err(|e| ch_err("projection_table", e))
}
async fn read_task_final(
&self,
task_id: Uuid,
) -> SystemStoreResult<Option<(ProjectionTaskRow, TaskStaticCols, u64)>> {
let tbl = self.projection_table()?;
let sql = format!(
"SELECT {TASK_COLS}, {TASK_STATIC_COLS}, version FROM {tbl} FINAL WHERE task_id = {id}",
id = sql_lit(&task_id.to_string()),
);
let rows = self
.executor()
.select_rows(&sql)
.await
.map_err(|e| ch_err("read_task_final", e))?;
match rows.first() {
Some(r) => Ok(Some((
row_to_projection_task(r)?,
row_to_static_cols(r),
ch_u64(r, "version"),
))),
None => Ok(None),
}
}
async fn insert_task_version(
&self,
task: &ProjectionTaskRow,
statics: &TaskStaticCols,
version: u64,
) -> SystemStoreResult<()> {
let tbl = self.projection_table()?;
let sql = format!(
"INSERT INTO {tbl} ({TASK_COLS}, {TASK_STATIC_COLS}, version) VALUES (\
{task_id}, {idem}, {project}, {backend}, {instance}, {kind}, {resource}, \
{operation}, {row_key}, {options}, {payload}, {checksum}, {status}, \
{retry}, {error}, {created}, {updated}, {next_retry}, {completed}, \
{manifest}, {message}, {schema}, {source_table}, {version})",
task_id = sql_lit(&task.task_id.to_string()),
idem = sql_lit(&task.idempotency_key),
project = sql_lit(&task.project_id),
backend = sql_lit(&task.target_backend),
instance = sql_lit(&task.target_instance),
kind = sql_lit(&task.projection_kind),
resource = sql_lit(&task.resource_name),
operation = sql_lit(task.operation.as_str()),
row_key = sql_lit(&task.source_row_key.to_string()),
options = sql_lit(&task.target_options.to_string()),
payload = sql_lit(&task.source_payload.to_string()),
checksum = sql_lit(&task.source_checksum),
status = sql_lit(task.status.as_str()),
retry = task.retry_count,
error = sql_lit(&task.last_error),
created = task.created_at.timestamp_millis(),
updated = task.updated_at.timestamp_millis(),
next_retry = opt_dt_lit(task.next_retry_at),
completed = opt_dt_lit(task.completed_at),
manifest = sql_lit(&statics.manifest_checksum),
message = sql_lit(&statics.message_type),
schema = sql_lit(&statics.source_schema),
source_table = sql_lit(&statics.source_table),
);
self.executor()
.execute_ddl(&sql)
.await
.map_err(|e| ch_err("insert_task_version", e))
}
async fn acquire_projection_mutation_lock(&self, op: &str) -> SystemStoreResult<String> {
let owner = format!("{op}:{}", Uuid::new_v4());
self.acquire_system_mutation_lease(CLICKHOUSE_PROJECTION_MUTATION_LOCK, &owner, op)
.await
.map_err(|err| ch_err(op, err))?;
Ok(owner)
}
async fn release_projection_mutation_lock(
&self,
owner: &str,
op: &str,
) -> SystemStoreResult<()> {
self.release_system_mutation_lease(CLICKHOUSE_PROJECTION_MUTATION_LOCK, owner, op)
.await
.map_err(|err| ch_err(op, err))
}
}
#[async_trait]
impl ProjectionTaskStore for ClickHouseCanonicalStore {
fn backend_label(&self) -> &'static str {
"clickhouse"
}
async fn ensure_projection_tables(&self) -> SystemStoreResult<()> {
let tbl = self.projection_table()?;
let ddl = format!(
"CREATE TABLE IF NOT EXISTS {tbl} (\
task_id String, \
idempotency_key String, \
project_id String, \
manifest_checksum String, \
message_type String, \
source_schema String, \
source_table String, \
source_row_key String, \
operation String, \
target_backend String, \
target_instance String, \
projection_kind String, \
resource_name String, \
target_options String, \
source_payload String, \
source_checksum String, \
status String, \
retry_count Int32, \
last_error String, \
created_at Int64, \
updated_at Int64, \
next_retry_at Int64, \
completed_at Int64, \
version UInt64\
) ENGINE = ReplacingMergeTree(version) ORDER BY task_id"
);
self.executor()
.execute_ddl(&ddl)
.await
.map_err(|e| ch_err("ensure_projection_tables", e))?;
Ok(())
}
async fn enqueue_projection_task(
&self,
task: &ProjectionTaskInsert,
) -> SystemStoreResult<Uuid> {
let owner = self
.acquire_projection_mutation_lock("enqueue_projection_task")
.await?;
let result = async {
let tbl = self.projection_table()?;
let existing_sql = format!(
"SELECT task_id FROM {tbl} FINAL WHERE idempotency_key = {key}",
key = sql_lit(&task.idempotency_key),
);
let existing = self
.executor()
.select_rows(&existing_sql)
.await
.map_err(|e| ch_err("enqueue_projection_task lookup", e))?;
if let Some(row) = existing.first() {
return ch_uuid(row, "task_id");
}
let new_id = Uuid::new_v4();
let now = Self::now_unix_ms();
let sql = format!(
"INSERT INTO {tbl} (\
task_id, idempotency_key, project_id, manifest_checksum, message_type, \
source_schema, source_table, source_row_key, operation, target_backend, \
target_instance, projection_kind, resource_name, target_options, \
source_payload, source_checksum, status, retry_count, last_error, \
created_at, updated_at, next_retry_at, completed_at, version) VALUES (\
{task_id}, {idem}, {project}, {manifest}, {message}, {schema}, {source_table}, \
{row_key}, {operation}, {backend}, {instance}, {kind}, {resource}, {options}, \
{payload}, {checksum}, {status}, 0, '', {now}, {now}, 0, 0, 1)",
task_id = sql_lit(&new_id.to_string()),
idem = sql_lit(&task.idempotency_key),
project = sql_lit(&task.project_id),
manifest = sql_lit(&task.manifest_checksum),
message = sql_lit(&task.message_type),
schema = sql_lit(&task.source_schema),
source_table = sql_lit(&task.source_table),
row_key = sql_lit(&task.source_row_key.to_string()),
operation = sql_lit(task.operation.as_str()),
backend = sql_lit(&task.target_backend),
instance = sql_lit(&task.target_instance),
kind = sql_lit(&task.projection_kind),
resource = sql_lit(&task.resource_name),
options = sql_lit(&task.target_options.to_string()),
payload = sql_lit(&task.source_payload.to_string()),
checksum = sql_lit(&task.source_checksum),
status = sql_lit(ProjectionTaskStatus::Pending.as_str()),
);
self.executor()
.execute_ddl(&sql)
.await
.map_err(|e| ch_err("enqueue_projection_task insert", e))?;
Ok(new_id)
}
.await;
self.release_projection_mutation_lock(&owner, "enqueue_projection_task")
.await?;
result
}
async fn claim_projection_tasks(
&self,
filter: &ProjectionClaimFilter,
) -> SystemStoreResult<Vec<ProjectionTaskRow>> {
if filter.batch_size <= 0 {
return Ok(Vec::new());
}
let owner = self
.acquire_projection_mutation_lock("claim_projection_tasks")
.await?;
let result = async {
let tbl = self.projection_table()?;
let scan = format!("SELECT {TASK_COLS}, {TASK_STATIC_COLS}, version FROM {tbl} FINAL");
let rows = self
.executor()
.select_rows(&scan)
.await
.map_err(|e| ch_err("claim_projection_tasks scan", e))?;
let now = Utc::now();
let mut candidates: Vec<(ProjectionTaskRow, TaskStaticCols, u64)> = Vec::new();
for row in &rows {
let task = row_to_projection_task(row)?;
if !task.status.is_claimable() {
continue;
}
if task.retry_count >= filter.max_retries {
continue;
}
if let Some(p) = &filter.project_id {
if &task.project_id != p {
continue;
}
}
if let Some(b) = &filter.target_backend {
if &task.target_backend != b {
continue;
}
}
if let Some(i) = &filter.target_instance {
if &task.target_instance != i {
continue;
}
}
if task.status == ProjectionTaskStatus::Failed {
if let Some(nra) = task.next_retry_at {
if nra > now {
continue;
}
}
}
candidates.push((task, row_to_static_cols(row), ch_u64(row, "version")));
}
candidates.sort_by(|a, b| a.0.created_at.cmp(&b.0.created_at));
let claim_now = Self::now_unix_ms();
let claim_dt =
DateTime::<Utc>::from_timestamp_millis(claim_now).unwrap_or_else(Utc::now);
let mut out = Vec::new();
for (mut task, statics, version) in candidates {
if out.len() as i64 >= filter.batch_size {
break;
}
let next_version = version.saturating_add(1).max(1);
let mut flipped = task.clone();
flipped.status = ProjectionTaskStatus::InProgress;
flipped.updated_at = claim_dt;
self.insert_task_version(&flipped, &statics, next_version)
.await?;
if let Some((confirmed, _, _)) = self.read_task_final(task.task_id).await? {
if confirmed.status == ProjectionTaskStatus::InProgress {
task.status = ProjectionTaskStatus::InProgress;
task.updated_at = claim_dt;
out.push(task);
}
}
}
Ok(out)
}
.await;
self.release_projection_mutation_lock(&owner, "claim_projection_tasks")
.await?;
result
}
async fn mark_projection_task_completed(&self, task_id: Uuid) -> SystemStoreResult<()> {
let owner = self
.acquire_projection_mutation_lock("mark_projection_task_completed")
.await?;
let result = async {
let Some((mut task, statics, version)) = self.read_task_final(task_id).await? else {
return Ok(());
};
let now = Self::now_unix_ms();
let now_dt = DateTime::<Utc>::from_timestamp_millis(now).unwrap_or_else(Utc::now);
task.status = ProjectionTaskStatus::Completed;
task.updated_at = now_dt;
task.completed_at = Some(now_dt);
task.next_retry_at = None; self.insert_task_version(&task, &statics, version.saturating_add(1).max(1))
.await
}
.await;
self.release_projection_mutation_lock(&owner, "mark_projection_task_completed")
.await?;
result
}
async fn mark_projection_task_failed(
&self,
task_id: Uuid,
new_retry_count: i32,
new_status: ProjectionTaskStatus,
error: &str,
) -> SystemStoreResult<()> {
if !matches!(
new_status,
ProjectionTaskStatus::Failed | ProjectionTaskStatus::DeadLetter
) {
return Err(SystemStoreError::InvalidInput(format!(
"mark_projection_task_failed only accepts FAILED or DEAD_LETTER, got {}",
new_status.as_str()
)));
}
let owner = self
.acquire_projection_mutation_lock("mark_projection_task_failed")
.await?;
let result = async {
let Some((mut task, statics, version)) = self.read_task_final(task_id).await? else {
return Ok(());
};
let now = Self::now_unix_ms();
task.status = new_status;
task.retry_count = new_retry_count;
task.last_error = error.to_string();
task.next_retry_at = None;
task.updated_at = DateTime::<Utc>::from_timestamp_millis(now).unwrap_or_else(Utc::now);
self.insert_task_version(&task, &statics, version.saturating_add(1).max(1))
.await
}
.await;
self.release_projection_mutation_lock(&owner, "mark_projection_task_failed")
.await?;
result
}
async fn requeue_dead_letter_tasks(
&self,
target_backend: Option<&str>,
) -> SystemStoreResult<i64> {
let owner = self
.acquire_projection_mutation_lock("requeue_dead_letter_tasks")
.await?;
let result = async {
let tbl = self.projection_table()?;
let scan = format!("SELECT {TASK_COLS}, {TASK_STATIC_COLS}, version FROM {tbl} FINAL");
let rows = self
.executor()
.select_rows(&scan)
.await
.map_err(|e| ch_err("requeue_dead_letter_tasks scan", e))?;
let now = Self::now_unix_ms();
let now_dt = DateTime::<Utc>::from_timestamp_millis(now).unwrap_or_else(Utc::now);
let mut n = 0i64;
for row in &rows {
let mut task = row_to_projection_task(row)?;
if task.status != ProjectionTaskStatus::DeadLetter {
continue;
}
if let Some(b) = target_backend {
if &task.target_backend != b {
continue;
}
}
let next_version = ch_u64(row, "version").saturating_add(1).max(1);
task.status = ProjectionTaskStatus::Pending;
task.retry_count = 0;
task.last_error = String::new();
task.next_retry_at = None;
task.updated_at = now_dt;
self.insert_task_version(&task, &row_to_static_cols(row), next_version)
.await?;
n += 1;
}
Ok(n)
}
.await;
self.release_projection_mutation_lock(&owner, "requeue_dead_letter_tasks")
.await?;
result
}
async fn reset_stale_in_progress_tasks(&self, stale_after: Duration) -> SystemStoreResult<i64> {
let owner = self
.acquire_projection_mutation_lock("reset_stale_in_progress_tasks")
.await?;
let result = async {
let tbl = self.projection_table()?;
let scan = format!("SELECT {TASK_COLS}, {TASK_STATIC_COLS}, version FROM {tbl} FINAL");
let rows = self
.executor()
.select_rows(&scan)
.await
.map_err(|e| ch_err("reset_stale_in_progress_tasks scan", e))?;
let cutoff = Utc::now()
- chrono::Duration::from_std(stale_after)
.unwrap_or_else(|_| chrono::Duration::zero());
let now = Self::now_unix_ms();
let now_dt = DateTime::<Utc>::from_timestamp_millis(now).unwrap_or_else(Utc::now);
let mut n = 0i64;
for row in &rows {
let mut task = row_to_projection_task(row)?;
if task.status != ProjectionTaskStatus::InProgress || task.updated_at >= cutoff {
continue;
}
let next_version = ch_u64(row, "version").saturating_add(1).max(1);
task.status = ProjectionTaskStatus::Pending;
task.last_error = "stale in-progress reconciliation".to_string();
task.updated_at = now_dt;
self.insert_task_version(&task, &row_to_static_cols(row), next_version)
.await?;
n += 1;
}
Ok(n)
}
.await;
self.release_projection_mutation_lock(&owner, "reset_stale_in_progress_tasks")
.await?;
result
}
async fn pending_task_metrics(&self, limit: i64) -> SystemStoreResult<Vec<PendingTaskMetric>> {
let tbl = self.projection_table()?;
let scan = format!("SELECT {TASK_COLS} FROM {tbl} FINAL");
let rows = self
.executor()
.select_rows(&scan)
.await
.map_err(|e| ch_err("pending_task_metrics scan", e))?;
use std::collections::HashMap;
let mut groups: HashMap<(String, String, String, String), (i64, DateTime<Utc>)> =
HashMap::new();
for row in &rows {
let task = row_to_projection_task(row)?;
if !matches!(
task.status,
ProjectionTaskStatus::Pending | ProjectionTaskStatus::Failed
) {
continue;
}
let key = (
task.project_id.clone(),
task.target_backend.clone(),
task.target_instance.clone(),
task.projection_kind.clone(),
);
let entry = groups.entry(key).or_insert((0, task.created_at));
entry.0 += 1;
if task.created_at < entry.1 {
entry.1 = task.created_at;
}
}
let now = Utc::now();
let mut out: Vec<PendingTaskMetric> = groups
.into_iter()
.map(
|((project_id, target_backend, target_instance, projection_kind), (n, oldest))| {
let age = (now - oldest).num_milliseconds() as f64 / 1000.0;
PendingTaskMetric {
project_id,
target_backend,
target_instance,
projection_kind,
pending: n,
oldest_age_seconds: age.max(0.0),
}
},
)
.collect();
out.truncate(limit.max(1) as usize);
Ok(out)
}
async fn dead_letter_groups(&self, limit: i64) -> SystemStoreResult<Vec<DeadLetterGroup>> {
let tbl = self.projection_table()?;
let scan = format!(
"SELECT source_table, target_backend, target_instance, status FROM {tbl} FINAL"
);
let rows = self
.executor()
.select_rows(&scan)
.await
.map_err(|e| ch_err("dead_letter_groups scan", e))?;
use std::collections::HashMap;
let mut groups: HashMap<(String, String, String), i64> = HashMap::new();
for row in &rows {
if ch_str(row, "status") != ProjectionTaskStatus::DeadLetter.as_str() {
continue;
}
*groups
.entry((
ch_str(row, "source_table"),
ch_str(row, "target_backend"),
ch_str(row, "target_instance"),
))
.or_insert(0) += 1;
}
let mut out: Vec<DeadLetterGroup> = groups
.into_iter()
.map(
|((source_table, target_backend, target_instance), dead_count)| DeadLetterGroup {
source_table,
target_backend,
target_instance,
dead_count,
},
)
.collect();
out.truncate(limit.max(1) as usize);
Ok(out)
}
async fn requeue_dead_letter_by_source(
&self,
source_table: &str,
target_backend: &str,
target_instance: &str,
) -> SystemStoreResult<i64> {
let owner = self
.acquire_projection_mutation_lock("requeue_dead_letter_by_source")
.await?;
let result = async {
let tbl = self.projection_table()?;
let scan = format!("SELECT {TASK_COLS}, {TASK_STATIC_COLS}, version FROM {tbl} FINAL");
let rows = self
.executor()
.select_rows(&scan)
.await
.map_err(|e| ch_err("requeue_dead_letter_by_source scan", e))?;
let now = Self::now_unix_ms();
let now_dt = DateTime::<Utc>::from_timestamp_millis(now).unwrap_or_else(Utc::now);
let mut n = 0i64;
for row in &rows {
let mut task = row_to_projection_task(row)?;
if task.status != ProjectionTaskStatus::DeadLetter {
continue;
}
let statics = row_to_static_cols(row);
if statics.source_table != source_table
|| task.target_backend != target_backend
|| task.target_instance != target_instance
{
continue;
}
let next_version = ch_u64(row, "version").saturating_add(1).max(1);
task.status = ProjectionTaskStatus::Pending;
task.retry_count = 0;
task.last_error = "reconciliation repair".to_string();
task.next_retry_at = None;
task.updated_at = now_dt;
self.insert_task_version(&task, &statics, next_version)
.await?;
n += 1;
}
Ok(n)
}
.await;
self.release_projection_mutation_lock(&owner, "requeue_dead_letter_by_source")
.await?;
result
}
async fn pending_projection_task_count(
&self,
idempotency_keys: &[String],
) -> SystemStoreResult<i64> {
if idempotency_keys.is_empty() {
return Ok(0);
}
let tbl = self.projection_table()?;
let in_list = idempotency_keys
.iter()
.map(|k| sql_lit(k))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT count() AS n FROM {tbl} FINAL WHERE idempotency_key IN ({in_list}) \
AND status NOT IN ('COMPLETED', 'DEAD_LETTER', 'FAILED')"
);
let rows = self
.executor()
.select_rows(&sql)
.await
.map_err(|e| ch_err("pending_projection_task_count", e))?;
Ok(rows.first().map(|r| ch_i64(r, "n")).unwrap_or(0))
}
async fn projection_task_summary(&self) -> SystemStoreResult<ProjectionTaskSummary> {
let tbl = self.projection_table()?;
let sql = format!("SELECT status, count() AS n FROM {tbl} FINAL GROUP BY status");
let rows = self
.executor()
.select_rows(&sql)
.await
.map_err(|e| ch_err("projection_task_summary", e))?;
let mut s = ProjectionTaskSummary::default();
for row in &rows {
let status = ch_str(row, "status");
let n = ch_i64(row, "n");
match ProjectionTaskStatus::parse(&status) {
Some(ProjectionTaskStatus::Pending) => s.pending += n,
Some(ProjectionTaskStatus::InProgress) => s.in_progress += n,
Some(ProjectionTaskStatus::Completed) => s.completed += n,
Some(ProjectionTaskStatus::Failed) => s.failed += n,
Some(ProjectionTaskStatus::DeadLetter) => s.dead_letter += n,
None => {
return Err(SystemStoreError::InvalidInput(format!(
"unknown projection status '{status}' in clickhouse summary"
)));
}
}
}
Ok(s)
}
}