use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use crate::operations::room_groups::{self, OpenError};
use crate::server::AppState;
use vti_common::auth::AuthClaims;
use super::room_group::{PresentingMember, not_served, send_as_member, send_as_member_typed};
const MAX_PAGES: usize = 32;
const REPAIR_WAIT: std::time::Duration = std::time::Duration::from_secs(20);
static LOCKS: LazyLock<std::sync::Mutex<HashMap<(String, String), Arc<tokio::sync::Mutex<()>>>>> =
LazyLock::new(Default::default);
fn lock_for(member: &room_groups::Member) -> Arc<tokio::sync::Mutex<()>> {
let mut locks = LOCKS.lock().unwrap_or_else(|p| p.into_inner());
locks
.entry((member.room_id().to_string(), member.persona().to_string()))
.or_default()
.clone()
}
pub(super) async fn repair(
state: &AppState,
auth: &AuthClaims,
member: &PresentingMember,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
host: &str,
refused: &OpenError,
) -> Result<(), String> {
let lock = lock_for(&member.member);
let Ok(_one_repair) = tokio::time::timeout(REPAIR_WAIT, lock.lock()).await else {
return Err("another read of this room is still fetching from the host".into());
};
let held = room_groups::held_room(&state.room_groups_ks, &member.member)
.await
.map_err(|e| e.to_string())?;
match *refused {
OpenError::NotDelivered { epoch, .. } if held.epoch >= epoch => Ok(()),
OpenError::NotDelivered { .. } => {
catch_up(state, auth, member, resolver, host, held.epoch).await
}
OpenError::BeyondChain { epoch, .. } if held.earliest_readable_epoch <= epoch => Ok(()),
OpenError::BeyondChain { epoch, .. } => {
extend_chain(
state,
auth,
member,
resolver,
host,
held.earliest_readable_epoch,
epoch,
)
.await
}
OpenError::Removed { .. } | OpenError::App(_) => Err("nothing to repair".into()),
}
}
async fn catch_up(
state: &AppState,
auth: &AuthClaims,
member: &PresentingMember,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
host: &str,
held: u32,
) -> Result<(), String> {
let room_id = member.member.room_id();
let mut at = held;
for _ in 0..MAX_PAGES {
let reply = send_as_member(
state,
auth,
member,
resolver,
host,
vti_rooms::wire::ROOMS_EPOCH_COMMITS_TYPE,
serde_json::json!({ "roomId": room_id, "sinceEpoch": at }),
)
.await
.map_err(|e| format!("fetching the room's commits: {e}"))?;
let page: vti_rooms::wire::CommitsResponse =
serde_json::from_value(reply).map_err(|e| {
format!("room host `{host}` served commits this agent cannot read: {e}")
})?;
if page.commits.is_empty() {
return if at < page.room_epoch {
Err(format!(
"the host holds no commit producing room epoch {}",
at + 1
))
} else {
Ok(())
};
}
let before = at;
for c in &page.commits {
use base64::Engine as _;
let commit = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(c.commit.trim())
.map_err(|e| format!("room host `{host}` served an undecodable commit: {e}"))?;
let Some(mls) = u64::from(c.epoch).checked_sub(1) else {
return Err(format!("room host `{host}` served a commit for epoch 0"));
};
let applied = room_groups::apply_commit(
&state.room_groups_ks,
&member.member,
&commit,
mls,
super::room_group::now(),
)
.await
.map_err(|e| format!("applying the commit for room epoch {}: {e}", c.epoch))?;
at = u32::try_from(applied + 1).unwrap_or(u32::MAX);
}
if at == before || at >= page.room_epoch {
return Ok(());
}
}
Ok(())
}
async fn extend_chain(
state: &AppState,
auth: &AuthClaims,
member: &PresentingMember,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
host: &str,
mut reach: u32,
target: u32,
) -> Result<(), String> {
let room_id = member.member.room_id();
for _ in 0..MAX_PAGES {
let payload = serde_json::json!({ "roomId": room_id, "fromEpoch": reach });
let reply = match send_as_member_typed(
state,
auth,
member,
resolver,
host,
vti_rooms::wire::ROOMS_EPOCH_CHAIN_V2_TYPE,
payload.clone(),
)
.await
{
Err(e) if not_served(&e) => {
send_as_member_typed(
state,
auth,
member,
resolver,
host,
vti_rooms::wire::ROOMS_EPOCH_CHAIN_TYPE,
payload,
)
.await
}
other => other,
}
.map_err(|e| format!("fetching the room's epoch links: {}", e.into_app_error()))?;
let floor = match reply.get("epochFloor").and_then(serde_json::Value::as_u64) {
Some(f) => room_groups::apply_epoch_floor(
&state.room_groups_ks,
&member.member,
u32::try_from(f).unwrap_or(u32::MAX),
super::room_group::now(),
)
.await
.map_err(|e| e.to_string())?,
None => 0,
};
if target < floor {
return Err(format!(
"room epoch {target} is below the room's epoch floor {floor}: the room has \
forgotten it"
));
}
let links: Vec<vti_rooms::wire::EpochLink> = serde_json::from_value(
reply.get("links").cloned().unwrap_or_default(),
)
.map_err(|e| format!("room host `{host}` served links this agent cannot read: {e}"))?;
let served = links.len();
let (earliest, _) = room_groups::store_links(
&state.room_groups_ks,
&member.member,
links,
super::room_group::now(),
)
.await
.map_err(|e| e.to_string())?;
if earliest <= target {
return Ok(());
}
if served == 0 || earliest >= reach {
return Err(format!(
"the host holds no epoch link below room epoch {earliest}"
));
}
reach = earliest;
}
Ok(())
}