use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde_json::{Value, json};
use tokio::sync::oneshot;
use tracing::{debug, info, warn};
use did_hosting_common::did_ops::{DidRecord, content_log_key, did_key};
use did_hosting_common::server::auth::session::now_epoch;
use crate::server::AppState;
pub const MSG_DID_LIST: &str = "https://trusttasks.org/spec/did-management/did/list/0.1";
const PAGE_LIMIT: u64 = 1000;
const REPLY_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub struct ReplicationStatus {
started_at: u64,
last_reconciled_at: AtomicU64,
stale_slots: AtomicU64,
repaired_total: AtomicU64,
}
impl ReplicationStatus {
pub fn new(now: u64) -> Self {
Self {
started_at: now,
last_reconciled_at: AtomicU64::new(0),
stale_slots: AtomicU64::new(0),
repaired_total: AtomicU64::new(0),
}
}
pub fn mark_reconciled(&self, now: u64) {
self.last_reconciled_at.store(now, Ordering::Relaxed);
self.stale_slots.store(0, Ordering::Relaxed);
}
pub fn last_reconciled_at(&self) -> Option<u64> {
match self.last_reconciled_at.load(Ordering::Relaxed) {
0 => None,
t => Some(t),
}
}
pub fn age(&self, now: u64) -> u64 {
now.saturating_sub(self.last_reconciled_at().unwrap_or(self.started_at))
}
pub fn is_fresh(&self, now: u64, bound: u64) -> bool {
self.age(now) <= bound
}
pub fn stale_slots(&self) -> u64 {
self.stale_slots.load(Ordering::Relaxed)
}
pub fn repaired_total(&self) -> u64 {
self.repaired_total.load(Ordering::Relaxed)
}
#[doc(hidden)]
pub fn set_last_reconciled_at(&self, at: u64) {
self.last_reconciled_at.store(at, Ordering::Relaxed);
}
}
pub fn is_fresh(state: &AppState) -> bool {
if state.config.control_did.is_none() {
return true;
}
state
.replication
.is_fresh(now_epoch(), state.config.replication.staleness_bound_secs)
}
type Waiters = Mutex<HashMap<String, oneshot::Sender<trust_tasks_rs::TrustTask<Value>>>>;
static PENDING: std::sync::LazyLock<Waiters> = std::sync::LazyLock::new(Default::default);
pub fn deliver_reply(
doc: trust_tasks_rs::TrustTask<Value>,
) -> Option<trust_tasks_rs::TrustTask<Value>> {
let Some(thread) = doc.thread_id.clone() else {
return Some(doc);
};
let waiter = PENDING
.lock()
.unwrap_or_else(|p| p.into_inner())
.remove(&thread);
match waiter {
Some(tx) => {
let _ = tx.send(doc);
None
}
None => Some(doc),
}
}
async fn exchange_over_messaging(
state: &AppState,
doc: trust_tasks_rs::TrustTask<Value>,
) -> Result<trust_tasks_rs::TrustTask<Value>, String> {
use did_hosting_common::server::didcomm_profile::TransportFallback;
use did_hosting_common::server::trust_tasks::send::send_trust_task;
let svc = state
.didcomm_service
.get()
.ok_or("the messaging service is not running")?;
let (Some(server_did), Some(control_did)) = (
state.config.server_did.as_deref(),
state.config.control_did.as_deref(),
) else {
return Err("server_did or control_did is not configured".into());
};
let (tx, rx) = oneshot::channel();
PENDING
.lock()
.map_err(|_| "reply table poisoned")?
.insert(doc.id.clone(), tx);
let fallback = TransportFallback::from_config(
state.config.mediator_did.as_deref(),
state.config.features.tsp,
);
let sent = send_trust_task(
svc,
"server",
server_did,
control_did,
&doc,
&fallback,
state.did_resolver.as_ref(),
)
.await;
let outcome = match sent {
Ok(_) => match tokio::time::timeout(REPLY_TIMEOUT, rx).await {
Ok(Ok(reply)) => Ok(reply),
Ok(Err(_)) => Err("the reply channel closed".into()),
Err(_) => Err("no reply within the timeout".into()),
},
Err(e) => Err(format!("send failed: {e}")),
};
if let Ok(mut pending) = PENDING.lock() {
pending.remove(&doc.id);
}
outcome
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Listed {
did_id: Option<String>,
version_count: u64,
disabled: bool,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ReconcileReport {
pub repaired: Vec<String>,
pub behind: Vec<String>,
pub extra: Vec<String>,
}
impl ReconcileReport {
pub fn is_clean(&self) -> bool {
self.behind.is_empty() && self.extra.is_empty()
}
}
pub async fn list_request(
state: &AppState,
offset: u64,
) -> Result<trust_tasks_rs::TrustTask<Value>, String> {
use did_hosting_common::server::trust_tasks::send::build_signed_request;
let (Some(identity), Some(server_did), Some(control_did)) = (
state.identity.as_deref(),
state.config.server_did.as_deref(),
state.config.control_did.as_deref(),
) else {
return Err("identity, server_did or control_did is not configured".into());
};
let signer =
did_hosting_common::server::trust_tasks::identity_signing_secret(identity, server_did)
.map_err(|e| e.to_string())?;
build_signed_request(
MSG_DID_LIST,
server_did,
control_did,
json!({ "limit": PAGE_LIMIT, "offset": offset }),
&signer,
)
.await
.map_err(|e| e.to_string())
}
async fn verified_page(
state: &AppState,
request_id: &str,
reply: trust_tasks_rs::TrustTask<Value>,
) -> Result<(Vec<(String, Listed)>, u64), String> {
let verifier =
crate::messaging::state_verifier(state).ok_or("no DID resolver to verify the reply")?;
crate::messaging::verify_control_plane(state, None, &reply, &verifier)
.await
.map_err(|r| format!("the listing did not verify: {}", r.reject_reason()))?;
if reply.thread_id.as_deref() != Some(request_id) {
return Err("the listing answers another request".into());
}
let type_uri = reply.type_uri.to_string();
if type_uri != format!("{MSG_DID_LIST}#response") {
let code = reply
.payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("?");
return Err(format!("the control plane refused the listing: {code}"));
}
use trust_tasks_rs::specs::did_management::did::list::v0_1 as list;
let _typed: list::Response = serde_json::from_value(reply.payload.clone())
.map_err(|e| format!("the listing does not fit its schema: {e}"))?;
let total = reply
.payload
.get("total")
.and_then(Value::as_u64)
.ok_or("the listing has no total")?;
let records = reply
.payload
.get("records")
.and_then(Value::as_array)
.ok_or("the listing has no records")?
.iter()
.filter_map(|r| {
Some((
r.get("mnemonic")?.as_str()?.to_string(),
Listed {
did_id: r.get("didId").and_then(Value::as_str).map(String::from),
version_count: r.get("versionCount")?.as_u64()?,
disabled: r.get("disabled").and_then(Value::as_bool).unwrap_or(false),
},
))
})
.collect();
Ok((records, total))
}
pub async fn reconcile_once<F, Fut>(
state: &AppState,
exchange: F,
) -> Result<ReconcileReport, String>
where
F: Fn(trust_tasks_rs::TrustTask<Value>) -> Fut,
Fut: std::future::Future<Output = Result<trust_tasks_rs::TrustTask<Value>, String>>,
{
let mut listed: HashMap<String, Listed> = HashMap::new();
let mut offset = 0u64;
loop {
let request = list_request(state, offset).await?;
let request_id = request.id.clone();
let reply = exchange(request).await?;
let (page, total) = verified_page(state, &request_id, reply).await?;
let got = page.len() as u64;
listed.extend(page);
offset += got;
if got == 0 || offset >= total {
if (listed.len() as u64) < total {
return Err(format!(
"the listing ended at {} of {total} slots",
listed.len()
));
}
break;
}
}
let report = apply_listing(state, &listed).await?;
let now = now_epoch();
state
.replication
.repaired_total
.fetch_add(report.repaired.len() as u64, Ordering::Relaxed);
if report.is_clean() {
state.replication.mark_reconciled(now);
debug!(slots = listed.len(), "reconcile clean");
} else {
state.replication.stale_slots.store(
(report.behind.len() + report.extra.len()) as u64,
Ordering::Relaxed,
);
info!(
behind = report.behind.len(),
extra = report.extra.len(),
"reconcile found slots out of step with the control plane"
);
}
Ok(report)
}
async fn apply_listing(
state: &AppState,
listed: &HashMap<String, Listed>,
) -> Result<ReconcileReport, String> {
let mut report = ReconcileReport::default();
let held: Vec<DidRecord> = state
.dids_ks
.prefix_iter_raw("did:")
.await
.map_err(|e| e.to_string())?
.into_iter()
.filter_map(|(_, v)| serde_json::from_slice::<DidRecord>(&v).ok())
.filter(|r| r.version_count > 0 && r.deleted_at.is_none())
.collect();
let held_names: HashSet<&str> = held.iter().map(|r| r.mnemonic.as_str()).collect();
for (mnemonic, want) in listed {
if want.version_count == 0 {
continue; }
if !held_names.contains(mnemonic.as_str()) {
report.behind.push(mnemonic.clone());
}
}
for mut record in held {
let Some(want) = listed.get(&record.mnemonic) else {
report.extra.push(record.mnemonic.clone());
continue;
};
if want.version_count == 0 {
report.extra.push(record.mnemonic.clone());
continue;
}
if want.did_id != record.did_id || want.version_count > record.version_count {
report.behind.push(record.mnemonic.clone());
}
if want.disabled != record.disabled {
record.disabled = want.disabled;
state
.dids_ks
.insert(did_key(&record.mnemonic), &record)
.await
.map_err(|e| e.to_string())?;
state
.did_cache
.invalidate(&content_log_key(&record.mnemonic));
warn!(
mnemonic = %record.mnemonic,
disabled = want.disabled,
"reconcile repaired a disabled state the edge had missed"
);
report.repaired.push(record.mnemonic.clone());
}
}
report.behind.sort();
report.extra.sort();
report.repaired.sort();
Ok(report)
}
pub async fn run_reconcile_loop(
state: AppState,
svc: affinidi_messaging_didcomm_service::DIDCommService,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let interval = Duration::from_secs(state.config.replication.reconcile_interval_secs.max(1));
let mut timer = tokio::time::interval(interval);
loop {
tokio::select! {
_ = timer.tick() => {}
_ = shutdown.changed() => break,
}
let outcome = reconcile_once(&state, |doc| exchange_over_messaging(&state, doc)).await;
match outcome {
Ok(report) if report.is_clean() => {}
Ok(_) => crate::control_register::register_via_didcomm(&state, &svc).await,
Err(e) => warn!(error = %e, "reconcile with the control plane failed"),
}
}
}