use std::collections::{BTreeSet, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use super::{Engine, EngineError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RosterFingerprint {
len: u64,
modified: Option<std::time::SystemTime>,
hash: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RosterChange {
pub added: Vec<String>,
pub removed: Vec<String>,
pub quarantined: Vec<String>,
pub failures: Vec<crate::ops::RefreshFailure>,
}
impl RosterChange {
pub fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.removed.is_empty()
&& self.quarantined.is_empty()
&& self.failures.is_empty()
}
}
pub type RosterChangedEvent = RosterChange;
pub type RosterCallback = Arc<dyn Fn(&RosterChangedEvent) + Send + Sync + 'static>;
pub(crate) type RosterSubscribers = std::sync::Mutex<(u64, Vec<(u64, RosterCallback)>)>;
fn hash_bytes(bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
impl Engine {
fn roster_path(&self) -> Option<PathBuf> {
self.workspace_root.as_ref().map(|root| {
root.join(crate::workspace_store::WORKSPACE_STORE_DIR)
.join("state")
.join("mounts.json")
})
}
fn roster_fingerprint_now(&self) -> Result<Option<RosterFingerprint>, std::io::Error> {
let Some(path) = self.roster_path() else {
return Ok(None);
};
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let len = meta.len();
let modified = meta.modified().ok();
if let Some(cached) = &self.roster_fingerprint
&& cached.len == len
&& cached.modified == modified
{
return Ok(Some(cached.clone()));
}
let bytes = std::fs::read(&path)?;
Ok(Some(RosterFingerprint {
len,
modified,
hash: hash_bytes(&bytes),
}))
}
pub(crate) fn capture_roster_fingerprint(&mut self) {
self.roster_fingerprint = self.roster_fingerprint_now().ok().flatten();
}
pub fn reconcile_roster(&mut self) -> Result<Option<RosterChange>, EngineError> {
let now = self.roster_fingerprint_now().map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"roster unreadable: {e}"
)))
})?;
let Some(now) = now else {
return Ok(None);
};
let Some(cached) = self.roster_fingerprint.clone() else {
self.roster_fingerprint = Some(now);
return Ok(None);
};
if cached.hash == now.hash {
self.roster_fingerprint = Some(now);
return Ok(None);
}
self.apply_roster(now).map(Some)
}
pub(crate) fn reconcile_roster_forced(&mut self) -> Result<RosterChange, EngineError> {
let now = self.roster_fingerprint_now().map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"roster unreadable: {e}"
)))
})?;
match now {
Some(now) => self.apply_roster(now),
None => Ok(RosterChange::default()),
}
}
fn apply_roster(&mut self, now: RosterFingerprint) -> Result<RosterChange, EngineError> {
let root = self
.workspace_root
.clone()
.expect("a roster fingerprint implies a workspace root");
let workspace = crate::workspace_store::WorkspaceStoreAdapter::load(
&crate::workspace_store::FileWorkspaceStore::new(),
&root,
)
.map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"roster under {} unreadable: {e}",
root.display()
)))
})?;
let manifest: BTreeSet<String> = workspace
.mounts
.iter()
.filter(|m| m.capability == crate::workspace::MountCapability::Write)
.map(|m| m.mem.clone())
.collect();
let mounted: BTreeSet<String> = self
.mounts
.iter()
.filter(|m| m.mount.capability == crate::workspace::MountCapability::Write)
.map(|m| m.mount.mem.clone())
.collect();
let quarantined_now: BTreeSet<String> = self
.quarantined
.iter()
.map(|q| q.mount.mem.clone())
.collect();
let mut change = RosterChange::default();
let mut all_applied = true;
let mut schema_report = crate::ops::FullRefreshReport::default();
self.refresh_schema_sources(&mut schema_report);
change.failures.extend(schema_report.failures);
for name in mounted.difference(&manifest) {
match self.unmount_mem(name) {
Ok(()) => change.removed.push(name.clone()),
Err(e) => {
all_applied = false;
change.failures.push(crate::ops::RefreshFailure {
item: format!("unmount:{name}"),
error: e.to_string(),
});
}
}
}
let gone_quarantined: Vec<String> =
quarantined_now.difference(&manifest).cloned().collect();
if !gone_quarantined.is_empty() {
self.quarantined
.retain(|q| !gone_quarantined.contains(&q.mount.mem));
change.removed.extend(gone_quarantined);
}
let mut any_mounted = false;
for mount in workspace.mounts {
if mount.capability != crate::workspace::MountCapability::Write
|| mounted.contains(&mount.mem)
|| quarantined_now.contains(&mount.mem)
{
continue;
}
let name = mount.mem.clone();
let backend = match (self.backend_factory)(&mount) {
Ok(b) => b,
Err(e) => {
self.quarantine_mount(mount, e.code(), e.to_string());
change.quarantined.push(name);
continue;
}
};
if let Some(crate::ops::WarningHint::MountUnbacked { reason, .. }) =
super::boot::unbacked_mount_warning(&mount, backend.as_ref(), None)
&& reason != crate::ops::MountUnbackedReason::Empty
{
let location = match &mount.storage {
crate::workspace::MountStorage::GitBranch { branch, .. } => branch.clone(),
crate::workspace::MountStorage::Folder { path }
| crate::workspace::MountStorage::Archive { path } => {
path.display().to_string()
}
crate::workspace::MountStorage::InMemory => String::new(),
};
self.quarantine_mount(
mount,
"MOUNT_UNBACKED",
format!(
"the mount's storage is gone ({location}); it is configured but cannot \
serve, so it is held out of the roster rather than answering reads \
with an empty graph"
),
);
change.quarantined.push(name);
continue;
}
match self.register_writable_mem_batched(
mount.clone(),
backend,
crate::mem::MemOrigin::ExplicitToml,
) {
Ok(()) => {
any_mounted = true;
self.recently_unmounted.remove(&name);
change.added.push(name);
}
Err(e) => {
self.quarantine_mount(mount, e.code(), e.to_string());
change.quarantined.push(name);
}
}
}
if any_mounted {
self.finish_batched_registrations();
}
if all_applied {
self.roster_fingerprint = Some(now);
}
self.invalidate_communities();
self.invalidate_search_indexes();
self.emit_roster_changed(&change);
Ok(change)
}
fn quarantine_mount(&mut self, mount: crate::workspace::Mount, code: &str, message: String) {
self.quarantined.push(super::QuarantinedMem {
mount,
reason_code: code.to_string(),
reason_message: message,
});
}
pub(crate) fn unmount_mem(&mut self, mem: &str) -> Result<(), EngineError> {
#[cfg(test)]
if self.inject_unmount_failure.as_deref() == Some(mem) {
return Err(EngineError::Backend(crate::backend::BackendError::Other(
format!("injected unmount failure for mem `{mem}`"),
)));
}
let removed = self.unregister_writable_mem(mem)?;
if removed.is_none() {
self.quarantined.retain(|q| q.mount.mem != mem);
}
self.pending_mem_changed.retain(|n| n.mem != mem);
self.labelling_memo = std::cell::OnceCell::new();
self.recently_unmounted.insert(mem.to_string());
Ok(())
}
pub fn recently_unmounted(&self, mem: &str) -> bool {
self.recently_unmounted.contains(mem)
}
pub fn subscribe_roster_changes(&self, callback: RosterCallback) -> u64 {
let mut subs = self
.roster_subscribers
.lock()
.expect("roster subscriber registry mutex must not be poisoned");
let id = subs.0 + 1;
subs.0 = id;
subs.1.push((id, callback));
id
}
pub fn unsubscribe_roster_changes(&self, id: u64) {
let mut subs = self
.roster_subscribers
.lock()
.expect("roster subscriber registry mutex must not be poisoned");
subs.1.retain(|(slot, _)| *slot != id);
}
fn emit_roster_changed(&self, change: &RosterChange) {
if change.is_empty() {
return;
}
let callbacks: Vec<RosterCallback> = self
.roster_subscribers
.lock()
.expect("roster subscriber registry mutex must not be poisoned")
.1
.iter()
.map(|(_, cb)| cb.clone())
.collect();
for cb in callbacks {
cb(change);
}
}
pub fn writable_mem_set(&self) -> HashSet<String> {
self.mounts
.iter()
.filter(|m| m.mount.capability == crate::workspace::MountCapability::Write)
.map(|m| m.mount.mem.clone())
.collect()
}
}