use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_messaging_didcomm_service::{
DIDCommService, DIDCommServiceConfig, ListenerConfig, Protocols, RestartPolicy, RetryConfig,
};
use affinidi_tdk::secrets_resolver::ThreadedSecretsResolver;
use did_hosting_common::server::auth::extractor::AuthState;
use did_hosting_common::server::didcomm_profile::{
advertised_protocols, build_tdk_profile_for_identity, reconcile_listener_protocols,
wait_for_did_resolution,
};
use did_hosting_common::server::identity::{self, ServiceIdentity};
use did_hosting_common::server::init;
use did_hosting_common::server::passkey::PasskeyState;
use did_hosting_common::server::store::{
KS_ACL, KS_DIDS, KS_REGISTRY, KS_SESSIONS, KS_STATS, KS_TIMESERIES,
};
use tokio_util::sync::CancellationToken;
use webauthn_rs::prelude::Webauthn;
use crate::auth::jwt::JwtKeys;
use crate::auth::session::cleanup_expired_sessions;
use crate::config::{AppConfig, AuthConfig};
use crate::error::AppError;
use crate::messaging;
use crate::registry::{self, ServiceStatus};
use crate::routes;
use crate::secret_store::ServerSecrets;
use crate::store::{KeyspaceHandle, Store};
use tokio::sync::{oneshot, watch};
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{Level, debug, error, info, warn};
pub struct PendingConfirm {
pub holder_did: String,
pub expected_digest: String,
pub tx: tokio::sync::oneshot::Sender<bool>,
}
pub type PendingConfirms = Arc<tokio::sync::Mutex<HashMap<String, PendingConfirm>>>;
#[derive(Clone)]
pub struct AppState {
pub store: Store,
pub sessions_ks: KeyspaceHandle,
pub acl_ks: KeyspaceHandle,
pub registry_ks: KeyspaceHandle,
pub dids_ks: KeyspaceHandle,
pub config: Arc<AppConfig>,
pub did_resolver: Option<DIDCacheClient>,
pub secrets_resolver: Option<Arc<ThreadedSecretsResolver>>,
pub identity: Option<Arc<ServiceIdentity>>,
pub trust_tasks_verifier:
Option<Arc<did_hosting_common::server::trust_tasks::TransportBoundVerifier>>,
pub jwt_keys: Option<Arc<JwtKeys>>,
pub webauthn: Option<Arc<Webauthn>>,
pub http_client: reqwest::Client,
pub didcomm_service: Arc<std::sync::OnceLock<DIDCommService>>,
pub stats_collector: Arc<did_hosting_common::server::stats_collector::StatsCollector>,
pub stats_ks: KeyspaceHandle,
pub timeseries_ks: KeyspaceHandle,
pub signing_key_bytes: Option<[u8; 32]>,
pub replay_cache: Arc<crate::replay::ReplayCache>,
pub path_locks: crate::path_locks::PathLocks,
pub acl_locks: did_hosting_common::server::path_locks::PathLocks,
pub pending_challenges: Arc<crate::pending_challenges::PendingChallengeTracker>,
pub ip_rate_limiter: Arc<crate::rate_limit::IpRateLimiter>,
pub pending_confirms: PendingConfirms,
pub outbox_notify: Arc<tokio::sync::Notify>,
}
impl AppState {
pub fn require_didcomm_auth(
&self,
) -> Result<(&DIDCacheClient, &ThreadedSecretsResolver, &JwtKeys), AppError> {
let did_resolver = self
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Authentication("DID resolver not configured".into()))?;
let secrets_resolver = self
.secrets_resolver
.as_ref()
.ok_or_else(|| AppError::Authentication("secrets resolver not configured".into()))?;
let jwt_keys = self
.jwt_keys
.as_ref()
.ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))?;
Ok((did_resolver, secrets_resolver.as_ref(), jwt_keys.as_ref()))
}
}
impl AuthState for AppState {
fn jwt_keys(&self) -> Option<&Arc<JwtKeys>> {
self.jwt_keys.as_ref()
}
fn sessions_ks(&self) -> &KeyspaceHandle {
&self.sessions_ks
}
}
impl PasskeyState for AppState {
fn webauthn(&self) -> Option<&Arc<Webauthn>> {
self.webauthn.as_ref()
}
fn acl_ks(&self) -> &KeyspaceHandle {
&self.acl_ks
}
fn access_token_expiry(&self) -> u64 {
self.config.auth.access_token_expiry
}
fn refresh_token_expiry(&self) -> u64 {
self.config.auth.refresh_token_expiry
}
fn public_url(&self) -> Option<&str> {
self.config.public_url.as_deref()
}
fn enrollment_ttl(&self) -> u64 {
self.config.auth.passkey_enrollment_ttl
}
}
pub async fn run(config: AppConfig, store: Store, secrets: ServerSecrets) -> Result<(), AppError> {
if !config.features.rest_api && !config.features.didcomm && !config.features.tsp {
return Err(AppError::Config(
"at least one of 'rest_api', 'didcomm', or 'tsp' must be enabled in [features]".into(),
));
}
#[cfg(feature = "ui")]
if !config.features.rest_api {
warn!("UI feature is compiled in but rest_api is disabled — UI will not be accessible");
}
let sessions_ks = store.keyspace(KS_SESSIONS)?;
let acl_ks = store.keyspace(KS_ACL)?;
let registry_ks = store.keyspace(KS_REGISTRY)?;
let dids_ks = store.keyspace(KS_DIDS)?;
let stats_ks = store.keyspace(KS_STATS)?;
let timeseries_ks = store.keyspace(KS_TIMESERIES)?;
let identity = identity::load_identity(
config.server_did.as_deref(),
config.mediator_did.as_deref(),
identity::ProtocolSet {
didcomm: config.features.didcomm,
tsp: config.features.tsp,
},
&secrets,
&store,
)
.await;
let did_resolver = identity.as_ref().map(|i| i.did_resolver.clone());
let secrets_resolver = identity.as_ref().map(|i| i.secrets_resolver.clone());
let jwt_keys = init::init_jwt_keys(&secrets);
let webauthn = config.public_url.as_ref().and_then(|url| {
match did_hosting_common::server::passkey::build_webauthn(url) {
Ok(w) => {
info!("WebAuthn (passkey) auth enabled");
Some(Arc::new(w))
}
Err(e) => {
warn!("WebAuthn initialization failed: {e} — passkey auth disabled");
None
}
}
});
let std_listener = if config.features.rest_api {
let addr = format!("{}:{}", config.server.host, config.server.port);
let listener = std::net::TcpListener::bind(&addr).map_err(AppError::Io)?;
listener.set_nonblocking(true).map_err(AppError::Io)?;
info!("control plane listening addr={addr}");
Some(listener)
} else {
None
};
let storage_sessions_ks = sessions_ks.clone();
let storage_auth_config = config.auth.clone();
let has_auth = jwt_keys.is_some();
let stats_dids_ks = dids_ks.clone();
let trust_tasks_verifier = did_resolver.clone().map(|client| {
let resolver = Arc::new(trust_tasks_proof::affinidi::CachedDidResolver::new(
Arc::new(client),
));
Arc::new(
did_hosting_common::server::trust_tasks::TransportBoundVerifier::with_resolver(
resolver,
),
)
});
let state = AppState {
store: store.clone(),
sessions_ks,
acl_ks,
registry_ks,
dids_ks,
config: Arc::new(config),
did_resolver,
secrets_resolver,
identity,
trust_tasks_verifier,
jwt_keys,
webauthn,
http_client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("reqwest client construction must succeed"),
didcomm_service: Arc::new(std::sync::OnceLock::new()),
stats_collector: {
use did_hosting_common::server::stats_collector::{StatsAggregate, StatsCollector};
let collector = StatsCollector::new();
let mut total_resolves = 0u64;
let mut total_updates = 0u64;
let mut last_resolved_at: Option<u64> = None;
let mut last_updated_at: Option<u64> = None;
if let Ok(raw) = stats_ks.prefix_iter_raw("stats:").await {
for (_key, value) in raw {
if let Ok(s) = serde_json::from_slice::<did_hosting_common::DidStats>(&value) {
total_resolves += s.total_resolves;
total_updates += s.total_updates;
last_resolved_at = match (last_resolved_at, s.last_resolved_at) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
last_updated_at = match (last_updated_at, s.last_updated_at) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
}
}
}
let total_dids = stats_dids_ks
.prefix_iter_raw("did:")
.await
.map(|v| v.len())
.unwrap_or(0) as u64;
collector.seed_aggregate(&StatsAggregate {
total_dids,
total_resolves,
total_updates,
last_resolved_at,
last_updated_at,
});
info!(
total_dids,
total_resolves, total_updates, "stats collector seeded from store"
);
Arc::new(collector)
},
stats_ks: stats_ks.clone(),
timeseries_ks: timeseries_ks.clone(),
signing_key_bytes: init::decode_multibase_ed25519_key(&secrets.signing_key).ok(),
replay_cache: Arc::new(crate::replay::ReplayCache::new()),
path_locks: crate::path_locks::PathLocks::new(),
acl_locks: did_hosting_common::server::path_locks::PathLocks::new(),
pending_challenges: Arc::new(crate::pending_challenges::PendingChallengeTracker::new()),
ip_rate_limiter: Arc::new(crate::rate_limit::IpRateLimiter::new()),
pending_confirms: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
outbox_notify: Arc::new(tokio::sync::Notify::new()),
};
backfill_service_badges(&state.store).await;
seed_registry(&state).await;
info!("--- enabled services ---");
info!(
" REST API : {}",
if state.config.features.rest_api {
"enabled"
} else {
"disabled"
}
);
info!(
" DIDComm : {}",
if state.config.features.didcomm {
"enabled"
} else {
"disabled"
}
);
if let Some(ref url) = state.config.public_url {
info!(" public URL : {url}");
}
if let Some(ref did) = state.config.server_did {
info!(" control DID : {did}");
}
if let Some(ref did) = state.config.mediator_did {
info!(" mediator DID : {did}");
}
let (rest_shutdown_tx, rest_shutdown_rx) = watch::channel(false);
let (storage_shutdown_tx, storage_shutdown_rx) = watch::channel(false);
let (rest_ready_tx, rest_ready_rx) = oneshot::channel::<()>();
let rest_handle = if let Some(listener) = std_listener {
let mut rest_shutdown = rest_shutdown_rx.clone();
let rest_state = state.clone();
Some(
std::thread::Builder::new()
.name("control-rest".into())
.spawn(move || {
run_rest_thread(listener, rest_state, &mut rest_shutdown, rest_ready_tx)
})
.map_err(|e| AppError::Internal(format!("failed to spawn REST thread: {e}")))?,
)
} else {
let _ = rest_ready_tx.send(());
None
};
let mut storage_shutdown = storage_shutdown_rx.clone();
let storage_stats_ks = state.stats_ks.clone();
let storage_timeseries_ks = state.timeseries_ks.clone();
let storage_dids_ks = state.dids_ks.clone();
let storage_collector = state.stats_collector.clone();
let storage_handle = std::thread::Builder::new()
.name("control-storage".into())
.spawn(move || {
run_storage_thread(
store,
storage_sessions_ks,
storage_stats_ks,
storage_timeseries_ks,
storage_dids_ks,
storage_auth_config,
has_auth,
storage_collector,
&mut storage_shutdown,
)
})
.map_err(|e| AppError::Internal(format!("failed to spawn storage thread: {e}")))?;
let _ = rest_ready_rx.await;
if let Err(e) = crate::identity_rotation::reload_now(&state).await {
warn!("failed to establish the service identity from its DID document: {e}");
}
let didcomm_shutdown = CancellationToken::new();
if state.config.features.didcomm || state.config.features.tsp {
match start_didcomm_service(&state, didcomm_shutdown.clone()).await {
Ok(Some(svc)) => {
let _ = state.didcomm_service.set(svc);
}
Ok(None) => {}
Err(e) => {
warn!("failed to start DIDComm service: {e}");
}
}
}
crate::identity_rotation::resume_mediator_drains(&state);
let health_shutdown = CancellationToken::new();
let health_token = health_shutdown.clone();
let health_registry_ks = state.registry_ks.clone();
let health_didcomm = state.didcomm_service.clone();
let health_control_did = state.config.server_did.clone();
let health_interval_secs = state.config.registry.health_check_interval.max(10);
let health_resolver = state.did_resolver.clone();
let health_fallback =
did_hosting_common::server::didcomm_profile::TransportFallback::from_config(
state.config.mediator_did.as_deref(),
state.config.features.tsp,
);
tokio::spawn(async move {
let mut timer = tokio::time::interval(Duration::from_secs(health_interval_secs));
timer.tick().await; loop {
tokio::select! {
_ = timer.tick() => {
if let Err(e) = run_health_checks(
&health_registry_ks,
&health_didcomm,
health_control_did.as_deref(),
health_interval_secs,
&health_fallback,
health_resolver.as_ref(),
).await {
warn!("health check error: {e}");
}
}
_ = health_token.cancelled() => break,
}
}
});
let (purge_shutdown_tx, purge_shutdown_rx) = tokio::sync::watch::channel(false);
let purge_store = state.store.clone();
let purge_handle = tokio::spawn(async move {
crate::purge_sweep::run_purge_sweep_loop(purge_store, purge_shutdown_rx).await;
});
let (identity_shutdown_tx, identity_shutdown_rx) = tokio::sync::watch::channel(false);
let identity_state = state.clone();
let identity_handle = tokio::spawn(async move {
crate::identity_rotation::run_identity_sweep_loop(identity_state, identity_shutdown_rx)
.await;
});
let (outbox_shutdown_tx, outbox_shutdown_rx) = tokio::sync::watch::channel(false);
let outbox_state = state.clone();
let outbox_notify = state.outbox_notify.clone();
let outbox_handle = tokio::spawn(async move {
crate::outbox::run_outbox_loop(outbox_state, outbox_notify, outbox_shutdown_rx).await;
});
init::shutdown_signal().await;
let mut any_panic = false;
health_shutdown.cancel();
didcomm_shutdown.cancel();
let _ = purge_shutdown_tx.send(true);
let _ = identity_shutdown_tx.send(true);
let _ = outbox_shutdown_tx.send(true);
let _ = rest_shutdown_tx.send(true);
if let Some(handle) = rest_handle {
match tokio::task::spawn_blocking(move || handle.join()).await {
Ok(Ok(())) => info!("REST thread stopped"),
Ok(Err(_)) => {
error!("REST thread panicked");
any_panic = true;
}
Err(e) => {
error!("failed to join REST thread: {e}");
any_panic = true;
}
}
}
let _ = storage_shutdown_tx.send(true);
match tokio::task::spawn_blocking(move || storage_handle.join()).await {
Ok(Ok(())) => info!("storage thread stopped"),
Ok(Err(_)) => {
error!("storage thread panicked");
any_panic = true;
}
Err(e) => {
error!("failed to join storage thread: {e}");
any_panic = true;
}
}
if let Err(e) = purge_handle.await {
warn!("purge sweep task didn't shut down cleanly: {e}");
}
if let Err(e) = identity_handle.await {
warn!("identity sweep task didn't shut down cleanly: {e}");
}
if let Err(e) = outbox_handle.await {
warn!("outbox worker didn't shut down cleanly: {e}");
}
if any_panic {
return Err(AppError::Internal("one or more threads panicked".into()));
}
info!("control plane shut down");
Ok(())
}
pub async fn start_didcomm_service(
state: &AppState,
shutdown: CancellationToken,
) -> Result<Option<DIDCommService>, AppError> {
let identity = match state.identity.as_ref() {
Some(identity) => identity,
None => {
info!("DIDComm not configured — server_did not set");
return Ok(None);
}
};
let mediator_did = match identity.mediator_did() {
Some(did) => did,
None => {
info!("mediator_did not configured — DIDComm messaging disabled");
return Ok(None);
}
};
info!(
control_did = %identity.did,
mediator_did = %mediator_did,
generations = identity.generations().len(),
"building TDK profile for DIDComm"
);
wait_for_did_resolution(&mediator_did, "mediator", &identity.did_resolver, &shutdown).await?;
let profile = build_tdk_profile_for_identity("control", identity, Some(&mediator_did)).await?;
info!("TDK profile built, configuring DIDComm listener");
let advertised = advertised_protocols(&identity.did, Some(&identity.did_resolver)).await;
let transports = reconcile_listener_protocols(identity.protocols(), advertised, &identity.did);
let tsp_enabled = transports.tsp;
let protocols = match (transports.didcomm, transports.tsp) {
(true, true) => Protocols::BOTH,
(false, true) => Protocols::TSP_ONLY,
_ => Protocols::DIDCOMM_ONLY,
};
let listener = ListenerConfig {
id: "control".into(),
profile,
restart_policy: RestartPolicy::Always {
backoff: RetryConfig::default(),
},
auto_delete: true,
protocols,
..Default::default()
};
let router = messaging::build_control_router(state.clone())
.map_err(|e| AppError::Internal(format!("failed to build DIDComm router: {e}")))?;
let config = DIDCommServiceConfig {
listeners: vec![listener],
};
let svc = if tsp_enabled {
info!("starting messaging service with DIDComm + TSP on the mediator connection");
DIDCommService::start_with_tsp(
config,
router,
crate::tsp::WebvhTspHandler::new(state.clone()),
shutdown,
)
.await
} else {
info!("starting DIDComm service with mediator connection");
DIDCommService::start(config, router, shutdown).await
}
.map_err(|e| AppError::Internal(format!("failed to start messaging service: {e}")))?;
info!(
tsp = tsp_enabled,
control_did = %identity.did,
"messaging service started"
);
Ok(Some(svc))
}
pub async fn backfill_service_badges(store: &Store) {
use did_hosting_common::server::migrations::{M02CacheDidRecordServices, MigrationRunner};
let runner = MigrationRunner::new(vec![Arc::new(M02CacheDidRecordServices)]);
match runner.run_pending(store).await {
Ok(summary) => info!(
applied = ?summary.applied,
skipped = ?summary.skipped,
"service-badge backfill complete"
),
Err(e) => warn!(
error = %e,
"service-badge backfill failed; DID badges may be missing until each DID is next published"
),
}
}
pub async fn seed_registry(state: &AppState) {
for instance_config in &state.config.registry.instances {
let service_type = match instance_config.service_type.as_str() {
"server" => registry::ServiceType::Server,
"witness" => registry::ServiceType::Witness,
"watcher" => registry::ServiceType::Watcher,
other => {
warn!(service_type = %other, "unknown service type in registry config, skipping");
continue;
}
};
let instance_id = uuid::Uuid::new_v4().to_string();
let instance = registry::ServiceInstance {
instance_id,
service_type,
label: instance_config.label.clone(),
url: instance_config.url.clone(),
status: ServiceStatus::Active,
last_health_check: None,
registered_at: crate::auth::session::now_epoch(),
metadata: serde_json::Value::Null,
enabled_methods: vec!["webvh".to_string()],
served_domains: Vec::new(),
protocol_version: "1.0".to_string(),
advertised_services: None,
services_checked_at: None,
trust_task_capable: false,
sync_batch_capable: false,
last_inbound_transport: None,
last_inbound_at: None,
last_outbound_transport: None,
last_outbound_at: None,
};
if let Err(e) = registry::register_instance(&state.registry_ks, &instance).await {
warn!(url = %instance_config.url, error = %e, "failed to seed registry instance");
} else {
info!(
url = %instance_config.url,
service_type = %instance_config.service_type,
"seeded registry instance"
);
}
}
}
fn run_rest_thread(
std_listener: std::net::TcpListener,
state: AppState,
shutdown_rx: &mut watch::Receiver<bool>,
ready_tx: oneshot::Sender<()>,
) {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.expect("failed to build REST runtime");
rt.block_on(async {
info!("REST thread started");
let listener = tokio::net::TcpListener::from_std(std_listener)
.expect("failed to convert std TcpListener to tokio TcpListener");
let app = routes::router()
.with_state(state)
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::DEBUG))
.on_response(
DefaultOnResponse::new()
.level(Level::DEBUG)
.latency_unit(tower_http::LatencyUnit::Millis),
),
)
.layer(axum::middleware::from_fn(
did_hosting_common::server::security_headers,
));
let _ = ready_tx.send(());
let mut rx = shutdown_rx.clone();
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
let _ = rx.changed().await;
})
.await
.expect("axum serve failed");
info!("REST thread shutting down");
});
}
#[allow(clippy::too_many_arguments)]
fn run_storage_thread(
store: Store,
sessions_ks: KeyspaceHandle,
stats_ks: KeyspaceHandle,
timeseries_ks: KeyspaceHandle,
dids_ks: KeyspaceHandle,
auth_config: AuthConfig,
has_auth: bool,
collector: Arc<did_hosting_common::server::stats_collector::StatsCollector>,
shutdown_rx: &mut watch::Receiver<bool>,
) {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build storage runtime");
rt.block_on(async {
info!("storage thread started");
let session_interval = Duration::from_secs(auth_config.session_cleanup_interval);
let flush_interval = Duration::from_secs(10);
let mut session_timer = tokio::time::interval(session_interval);
let mut flush_timer = tokio::time::interval(flush_interval);
session_timer.tick().await;
flush_timer.tick().await;
loop {
tokio::select! {
_ = session_timer.tick(), if has_auth => {
if let Err(e) = cleanup_expired_sessions(&sessions_ks, auth_config.challenge_ttl).await {
warn!("session cleanup error: {e}");
}
}
_ = flush_timer.tick() => {
if let Err(e) = flush_stats_to_store(&collector, &stats_ks, ×eries_ks, &dids_ks, &store).await {
warn!("stats flush error: {e}");
}
}
_ = shutdown_rx.changed() => {
info!("storage thread shutting down");
break;
}
}
}
let _ = flush_stats_to_store(&collector, &stats_ks, ×eries_ks, &dids_ks, &store).await;
if let Err(e) = store.persist().await {
error!("failed to persist store on shutdown: {e}");
} else {
info!("store persisted");
}
});
}
pub async fn flush_stats_to_store(
collector: &did_hosting_common::server::stats_collector::StatsCollector,
stats_ks: &KeyspaceHandle,
timeseries_ks: &KeyspaceHandle,
dids_ks: &KeyspaceHandle,
store: &Store,
) -> Result<(), AppError> {
let deltas = collector.drain_for_sync();
if deltas.is_empty() {
if let Ok(dids) = dids_ks.prefix_iter_raw("did:").await {
collector.set_total_dids(dids.len() as u64);
}
return Ok(());
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let bucket_epoch = now / 300 * 300;
let mut all_resolve_delta = 0u64;
let mut all_update_delta = 0u64;
let mut batch = store.batch();
for d in &deltas {
let key = format!("stats:{}", d.mnemonic);
let mut stats: did_hosting_common::DidStats =
stats_ks.get(key.as_str()).await?.unwrap_or_default();
stats.total_resolves += d.resolve_delta;
stats.total_updates += d.update_delta;
if let Some(t) = d.last_resolved_at {
stats.last_resolved_at = Some(stats.last_resolved_at.map_or(t, |prev| prev.max(t)));
}
if let Some(t) = d.last_updated_at {
stats.last_updated_at = Some(stats.last_updated_at.map_or(t, |prev| prev.max(t)));
}
batch.insert(stats_ks, key, &stats)?;
if d.resolve_delta > 0 || d.update_delta > 0 {
let ts_key = format!("ts:{}:{bucket_epoch}", d.mnemonic);
let existing: serde_json::Value = timeseries_ks
.get(ts_key.as_str())
.await?
.unwrap_or(serde_json::json!({"r": 0, "u": 0}));
let r = existing.get("r").and_then(|v| v.as_u64()).unwrap_or(0) + d.resolve_delta;
let u = existing.get("u").and_then(|v| v.as_u64()).unwrap_or(0) + d.update_delta;
batch.insert(timeseries_ks, ts_key, &serde_json::json!({"r": r, "u": u}))?;
all_resolve_delta += d.resolve_delta;
all_update_delta += d.update_delta;
}
}
if all_resolve_delta > 0 || all_update_delta > 0 {
let all_key = format!("ts:_all:{bucket_epoch}");
let existing: serde_json::Value = timeseries_ks
.get(all_key.as_str())
.await?
.unwrap_or(serde_json::json!({"r": 0, "u": 0}));
let r = existing.get("r").and_then(|v| v.as_u64()).unwrap_or(0) + all_resolve_delta;
let u = existing.get("u").and_then(|v| v.as_u64()).unwrap_or(0) + all_update_delta;
batch.insert(timeseries_ks, all_key, &serde_json::json!({"r": r, "u": u}))?;
}
batch.commit().await?;
if let Ok(dids) = dids_ks.prefix_iter_raw("did:").await {
collector.set_total_dids(dids.len() as u64);
}
debug!(count = deltas.len(), "flushed stats deltas to store");
Ok(())
}
async fn send_health_ping_trust_task(
svc: &DIDCommService,
registry_ks: &KeyspaceHandle,
control_did: &str,
server_did: &str,
inst: ®istry::ServiceInstance,
fallback: &did_hosting_common::server::didcomm_profile::TransportFallback,
did_resolver: Option<&DIDCacheClient>,
) {
use did_hosting_common::server::trust_tasks::send::{build_request, send_trust_task};
let doc = match build_request(
did_hosting_common::didcomm_types::MSG_HEALTH_PING,
control_did,
server_did,
serde_json::json!({}),
) {
Ok(d) => d,
Err(e) => {
warn!(error = %e, "health ping: MSG_HEALTH_PING is not a valid Type URI");
return;
}
};
match send_trust_task(
svc,
"control",
control_did,
server_did,
&doc,
fallback,
did_resolver,
)
.await
{
Ok(transport) => {
debug!(
instance_id = %inst.instance_id,
server_did,
?transport,
"health ping sent as trust task"
);
registry::record_outbound_transport(
registry_ks,
&inst.instance_id,
transport.into(),
crate::auth::session::now_epoch(),
)
.await;
}
Err(e) => debug!(
instance_id = %inst.instance_id,
server_did,
error = %e,
"failed to send health ping (trust task)"
),
}
}
pub async fn run_health_checks(
registry_ks: &KeyspaceHandle,
didcomm: &std::sync::OnceLock<DIDCommService>,
control_did: Option<&str>,
health_interval_secs: u64,
fallback: &did_hosting_common::server::didcomm_profile::TransportFallback,
did_resolver: Option<&DIDCacheClient>,
) -> Result<(), AppError> {
let instances = registry::list_instances(registry_ks).await?;
let now = crate::auth::session::now_epoch();
if let (Some(svc), Some(ctrl_did)) = (didcomm.get(), control_did) {
for inst in &instances {
let Some(server_did) = inst.did() else {
continue;
};
if inst.trust_task_capable {
send_health_ping_trust_task(
svc,
registry_ks,
ctrl_did,
server_did,
inst,
fallback,
did_resolver,
)
.await;
continue;
}
let msg = affinidi_messaging_didcomm::Message::build(
uuid::Uuid::new_v4().to_string(),
did_hosting_common::didcomm_types::MSG_HEALTH_PING.to_string(),
serde_json::json!({}),
)
.from(ctrl_did.to_string())
.to(server_did.to_string())
.created_time(now)
.finalize();
match svc.send_message("control", msg, server_did).await {
Ok(()) => {
registry::record_outbound_transport(
registry_ks,
&inst.instance_id,
did_hosting_common::server::didcomm_profile::ObservedTransport::Didcomm,
now,
)
.await;
}
Err(e) => debug!(
instance_id = %inst.instance_id,
server_did,
error = %e,
"failed to send legacy health ping"
),
}
}
}
for inst in &instances {
let new_status = registry::health_status_from_timestamp(inst, now, health_interval_secs);
if new_status != inst.status {
info!(
instance_id = %inst.instance_id,
old_status = ?inst.status,
new_status = ?new_status,
"instance status changed"
);
registry::update_instance_status(registry_ks, &inst.instance_id, new_status, now)
.await?;
}
}
for inst in &instances {
if inst.did().is_none() {
continue;
}
if let Err(e) =
registry::refresh_advertised_services(registry_ks, &inst.instance_id, did_resolver, now)
.await
{
debug!(
instance_id = %inst.instance_id,
error = %e,
"failed to refresh advertised services"
);
}
}
Ok(())
}