use std::path::PathBuf;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use vti_common::store::KeyspaceHandle;
use crate::config::AppConfig;
use crate::registry::{
SyncJob, SyncJobState, delete_sync_job, get_sync_job, list_sync_jobs, store_sync_job,
};
use crate::store::keyspaces;
use vta_sdk::display_name::shorten_did;
type CliResult = Result<(), Box<dyn std::error::Error>>;
fn open(config_path: Option<PathBuf>) -> Result<KeyspaceHandle, Box<dyn std::error::Error>> {
let config = AppConfig::load(config_path)?;
let store = crate::store::offline::open_offline(&config.store)?;
Ok(store.keyspace(keyspaces::SYNC_QUEUE)?)
}
fn state_label(state: SyncJobState) -> &'static str {
match state {
SyncJobState::Pending => "pending",
SyncJobState::InFlight => "in-flight",
SyncJobState::Complete => "complete",
SyncJobState::Failed => "FAILED",
}
}
pub fn requeue(job: &mut SyncJob, now: DateTime<Utc>) {
job.state = SyncJobState::Pending;
job.attempts = 0;
job.next_attempt_at = now;
job.last_error = None;
}
pub async fn run_list(config_path: Option<PathBuf>, all: bool) -> CliResult {
let ks = open(config_path)?;
let mut jobs = list_sync_jobs(&ks).await?;
if !all {
jobs.retain(|j| j.state == SyncJobState::Failed);
}
if jobs.is_empty() {
if all {
println!("The membership-sync queue is empty.");
} else {
println!("No failed sync jobs. (Use --all to see pending and in-flight rows.)");
}
return Ok(());
}
jobs.sort_by_key(|j| std::cmp::Reverse(j.last_attempted_at.unwrap_or(j.created_at)));
println!(
"{:<38} {:<10} {:<16} {:<8} MEMBER",
"JOB ID", "STATE", "KIND", "ATTEMPTS"
);
for j in &jobs {
println!(
"{:<38} {:<10} {:<16} {:<8} {}",
j.id,
state_label(j.state),
j.kind.as_str(),
j.attempts,
shorten_did(&j.member_did)
);
println!(" member: {}", j.member_did);
if let Some(err) = &j.last_error {
println!(" error: {err}");
}
if let Some(at) = j.last_attempted_at {
println!(" last attempt: {at}");
}
}
let failed = jobs
.iter()
.filter(|j| j.state == SyncJobState::Failed)
.count();
if failed > 0 {
println!();
println!(
"{failed} failed job(s). The syncer will not retry these on its own.\n\
Fix the cause first, then `vtc sync-jobs retry --job-id <id>`.\n\
An error naming `unsupportedType` means the deployed trust registry does not\n\
route that Trust Task at all — upgrade the registry, then retry."
);
}
Ok(())
}
pub async fn run_retry(
config_path: Option<PathBuf>,
job_id: Option<String>,
all: bool,
) -> CliResult {
if job_id.is_none() && !all {
return Err("pass --job-id <id>, or --all to retry every failed job".into());
}
let ks = open(config_path)?;
let targets: Vec<SyncJob> = match &job_id {
Some(id) => {
let uuid = Uuid::parse_str(id).map_err(|e| format!("not a job id: {e}"))?;
match get_sync_job(&ks, uuid).await? {
Some(j) => vec![j],
None => {
return Err(format!(
"no sync job {id}. It may already have been retried, discarded, or \
purged by the retention sweeper."
)
.into());
}
}
}
None => list_sync_jobs(&ks)
.await?
.into_iter()
.filter(|j| j.state == SyncJobState::Failed)
.collect(),
};
if targets.is_empty() {
println!("No failed sync jobs to retry.");
return Ok(());
}
let now = Utc::now();
let mut requeued = 0usize;
for mut job in targets {
if job.state != SyncJobState::Failed {
println!(
"skipping {} — state is {}, not failed (the syncer already owns it)",
job.id,
state_label(job.state)
);
continue;
}
requeue(&mut job, now);
store_sync_job(&ks, &job).await?;
requeued += 1;
println!(
"requeued {} ({} {})",
job.id,
job.kind.as_str(),
job.member_did
);
}
if requeued > 0 {
println!();
println!(
"{requeued} job(s) requeued. Start the daemon; the syncer dispatches them on its \
next tick."
);
}
Ok(())
}
pub async fn run_discard(config_path: Option<PathBuf>, job_id: String) -> CliResult {
let ks = open(config_path)?;
let uuid = Uuid::parse_str(&job_id).map_err(|e| format!("not a job id: {e}"))?;
let Some(job) = get_sync_job(&ks, uuid).await? else {
return Err(format!("no sync job {job_id}").into());
};
if job.state != SyncJobState::Failed {
return Err(format!(
"sync job {job_id} is {}, not failed — discarding a live row would drop work the \
syncer is still doing.",
state_label(job.state)
)
.into());
}
delete_sync_job(&ks, uuid).await?;
println!(
"discarded {} ({} {}).",
job.id,
job.kind.as_str(),
job.member_did
);
println!(
"The registry's record for this member is unchanged — it still reflects whatever was \
last published, which for a failed publishMember is nothing at all."
);
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::registry::SyncJobKind;
use crate::store::Store;
use vti_common::config::StoreConfig;
async fn temp_queue() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("store");
let queue = store.keyspace(keyspaces::SYNC_QUEUE).expect("keyspace");
(queue, dir)
}
fn failed(kind: SyncJobKind, did: &str) -> SyncJob {
let mut job = SyncJob::fresh(kind, did);
job.state = SyncJobState::Failed;
job.attempts = 17;
job.last_error = Some(
"permanent registry failure: registry rejected registry/record/put: unsupportedType"
.into(),
);
job.next_attempt_at = Utc::now() + chrono::Duration::days(1);
job
}
#[tokio::test]
async fn requeue_makes_a_failed_job_dispatchable() {
let (ks, _dir) = temp_queue().await;
let mut job = failed(SyncJobKind::PublishMember, "did:key:zMember");
store_sync_job(&ks, &job).await.unwrap();
requeue(&mut job, Utc::now());
store_sync_job(&ks, &job).await.unwrap();
let back = get_sync_job(&ks, job.id).await.unwrap().unwrap();
assert_eq!(back.state, SyncJobState::Pending);
assert_eq!(back.attempts, 0);
assert!(back.last_error.is_none());
assert!(back.is_dispatchable(Utc::now()));
}
#[tokio::test]
async fn requeue_preserves_the_rtbf_batch_marker() {
let mut job = failed(SyncJobKind::MarkDeparted, "did:key:zGone");
job.rtbf_batched = true;
requeue(&mut job, Utc::now());
assert!(job.rtbf_batched, "RTBF marker must survive a retry");
}
#[tokio::test]
async fn retry_all_targets_only_failed_rows() {
let (ks, _dir) = temp_queue().await;
let pending = SyncJob::fresh(SyncJobKind::UpdateMember, "did:key:zBusy");
store_sync_job(&ks, &pending).await.unwrap();
let mut in_flight = SyncJob::fresh(SyncJobKind::PublishMember, "did:key:zMid");
in_flight.state = SyncJobState::InFlight;
store_sync_job(&ks, &in_flight).await.unwrap();
let dead = failed(SyncJobKind::PublishMember, "did:key:zDead");
store_sync_job(&ks, &dead).await.unwrap();
let targets: Vec<_> = list_sync_jobs(&ks)
.await
.unwrap()
.into_iter()
.filter(|j| j.state == SyncJobState::Failed)
.collect();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].id, dead.id);
}
#[tokio::test]
async fn discard_refuses_a_live_row() {
let (ks, dir) = temp_queue().await;
let mut in_flight = SyncJob::fresh(SyncJobKind::PublishMember, "did:key:zMid");
in_flight.state = SyncJobState::InFlight;
store_sync_job(&ks, &in_flight).await.unwrap();
drop(ks);
let config = dir.path().join("config.toml");
std::fs::write(
&config,
format!(
"vtc_did = \"did:key:zVtc\"\n[store]\ndata_dir = \"{}\"\n",
dir.path().display()
),
)
.unwrap();
let err = run_discard(Some(config), in_flight.id.to_string())
.await
.expect_err("a live row must not be discardable");
assert!(
err.to_string().contains("in-flight"),
"the refusal should name the state: {err}"
);
}
}