memstead_base/engine/
roster.rs1use std::collections::{BTreeSet, HashSet};
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use serde::{Deserialize, Serialize};
24
25use super::{Engine, EngineError};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RosterFingerprint {
31 len: u64,
32 modified: Option<std::time::SystemTime>,
33 hash: u64,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct RosterChange {
39 pub added: Vec<String>,
41 pub removed: Vec<String>,
44 pub quarantined: Vec<String>,
48 pub failures: Vec<crate::ops::RefreshFailure>,
52}
53
54impl RosterChange {
55 pub fn is_empty(&self) -> bool {
57 self.added.is_empty()
58 && self.removed.is_empty()
59 && self.quarantined.is_empty()
60 && self.failures.is_empty()
61 }
62}
63
64pub type RosterChangedEvent = RosterChange;
66
67pub type RosterCallback = Arc<dyn Fn(&RosterChangedEvent) + Send + Sync + 'static>;
70
71pub(crate) type RosterSubscribers = std::sync::Mutex<(u64, Vec<(u64, RosterCallback)>)>;
73
74fn hash_bytes(bytes: &[u8]) -> u64 {
75 use std::hash::{Hash, Hasher};
76 let mut h = std::collections::hash_map::DefaultHasher::new();
77 bytes.hash(&mut h);
78 h.finish()
79}
80
81impl Engine {
82 fn roster_path(&self) -> Option<PathBuf> {
85 self.workspace_root.as_ref().map(|root| {
86 root.join(crate::workspace_store::WORKSPACE_STORE_DIR)
87 .join("state")
88 .join("mounts.json")
89 })
90 }
91
92 fn roster_fingerprint_now(&self) -> Result<Option<RosterFingerprint>, std::io::Error> {
97 let Some(path) = self.roster_path() else {
98 return Ok(None);
99 };
100 let meta = match std::fs::metadata(&path) {
101 Ok(m) => m,
102 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
103 Err(e) => return Err(e),
104 };
105 let len = meta.len();
106 let modified = meta.modified().ok();
107 if let Some(cached) = &self.roster_fingerprint
108 && cached.len == len
109 && cached.modified == modified
110 {
111 return Ok(Some(cached.clone()));
112 }
113 let bytes = std::fs::read(&path)?;
114 Ok(Some(RosterFingerprint {
115 len,
116 modified,
117 hash: hash_bytes(&bytes),
118 }))
119 }
120
121 pub(crate) fn capture_roster_fingerprint(&mut self) {
124 self.roster_fingerprint = self.roster_fingerprint_now().ok().flatten();
125 }
126
127 pub fn reconcile_roster(&mut self) -> Result<Option<RosterChange>, EngineError> {
140 let now = self.roster_fingerprint_now().map_err(|e| {
141 EngineError::Backend(crate::backend::BackendError::Other(format!(
142 "roster unreadable: {e}"
143 )))
144 })?;
145 let Some(now) = now else {
146 return Ok(None);
147 };
148 let Some(cached) = self.roster_fingerprint.clone() else {
149 self.roster_fingerprint = Some(now);
150 return Ok(None);
151 };
152 if cached.hash == now.hash {
153 self.roster_fingerprint = Some(now);
154 return Ok(None);
155 }
156 self.apply_roster(now).map(Some)
157 }
158
159 pub(crate) fn reconcile_roster_forced(&mut self) -> Result<RosterChange, EngineError> {
164 let now = self.roster_fingerprint_now().map_err(|e| {
165 EngineError::Backend(crate::backend::BackendError::Other(format!(
166 "roster unreadable: {e}"
167 )))
168 })?;
169 match now {
170 Some(now) => self.apply_roster(now),
171 None => Ok(RosterChange::default()),
172 }
173 }
174
175 fn apply_roster(&mut self, now: RosterFingerprint) -> Result<RosterChange, EngineError> {
176 let root = self
177 .workspace_root
178 .clone()
179 .expect("a roster fingerprint implies a workspace root");
180 let workspace = crate::workspace_store::WorkspaceStoreAdapter::load(
181 &crate::workspace_store::FileWorkspaceStore::new(),
182 &root,
183 )
184 .map_err(|e| {
185 EngineError::Backend(crate::backend::BackendError::Other(format!(
186 "roster under {} unreadable: {e}",
187 root.display()
188 )))
189 })?;
190
191 let manifest: BTreeSet<String> = workspace
192 .mounts
193 .iter()
194 .filter(|m| m.capability == crate::workspace::MountCapability::Write)
195 .map(|m| m.mem.clone())
196 .collect();
197 let mounted: BTreeSet<String> = self
198 .mounts
199 .iter()
200 .filter(|m| m.mount.capability == crate::workspace::MountCapability::Write)
201 .map(|m| m.mount.mem.clone())
202 .collect();
203 let quarantined_now: BTreeSet<String> = self
204 .quarantined
205 .iter()
206 .map(|q| q.mount.mem.clone())
207 .collect();
208
209 let mut change = RosterChange::default();
210 let mut all_applied = true;
211
212 let mut schema_report = crate::ops::FullRefreshReport::default();
216 self.refresh_schema_sources(&mut schema_report);
217 change.failures.extend(schema_report.failures);
218
219 for name in mounted.difference(&manifest) {
222 match self.unmount_mem(name) {
223 Ok(()) => change.removed.push(name.clone()),
224 Err(e) => {
225 all_applied = false;
226 change.failures.push(crate::ops::RefreshFailure {
227 item: format!("unmount:{name}"),
228 error: e.to_string(),
229 });
230 }
231 }
232 }
233 let gone_quarantined: Vec<String> =
234 quarantined_now.difference(&manifest).cloned().collect();
235 if !gone_quarantined.is_empty() {
236 self.quarantined
237 .retain(|q| !gone_quarantined.contains(&q.mount.mem));
238 change.removed.extend(gone_quarantined);
239 }
240
241 let mut any_mounted = false;
243 for mount in workspace.mounts {
244 if mount.capability != crate::workspace::MountCapability::Write
245 || mounted.contains(&mount.mem)
246 || quarantined_now.contains(&mount.mem)
247 {
248 continue;
249 }
250 let name = mount.mem.clone();
251 let backend = match (self.backend_factory)(&mount) {
252 Ok(b) => b,
253 Err(e) => {
254 self.quarantine_mount(mount, e.code(), e.to_string());
255 change.quarantined.push(name);
256 continue;
257 }
258 };
259 if let Some(crate::ops::WarningHint::MountUnbacked { reason, .. }) =
263 super::boot::unbacked_mount_warning(&mount, backend.as_ref(), None)
264 && reason != crate::ops::MountUnbackedReason::Empty
265 {
266 let location = match &mount.storage {
267 crate::workspace::MountStorage::GitBranch { branch, .. } => branch.clone(),
268 crate::workspace::MountStorage::Folder { path }
269 | crate::workspace::MountStorage::Archive { path } => {
270 path.display().to_string()
271 }
272 crate::workspace::MountStorage::InMemory => String::new(),
273 };
274 self.quarantine_mount(
275 mount,
276 "MOUNT_UNBACKED",
277 format!(
278 "the mount's storage is gone ({location}); it is configured but cannot \
279 serve, so it is held out of the roster rather than answering reads \
280 with an empty graph"
281 ),
282 );
283 change.quarantined.push(name);
284 continue;
285 }
286 match self.register_writable_mem_batched(
287 mount.clone(),
288 backend,
289 crate::mem::MemOrigin::ExplicitToml,
290 ) {
291 Ok(()) => {
292 any_mounted = true;
293 self.recently_unmounted.remove(&name);
294 change.added.push(name);
295 }
296 Err(e) => {
297 self.quarantine_mount(mount, e.code(), e.to_string());
298 change.quarantined.push(name);
299 }
300 }
301 }
302 if any_mounted {
303 self.finish_batched_registrations();
304 }
305
306 if all_applied {
307 self.roster_fingerprint = Some(now);
308 }
309 self.invalidate_communities();
310 self.invalidate_search_indexes();
311 self.emit_roster_changed(&change);
312 Ok(change)
313 }
314
315 fn quarantine_mount(&mut self, mount: crate::workspace::Mount, code: &str, message: String) {
316 self.quarantined.push(super::QuarantinedMem {
317 mount,
318 reason_code: code.to_string(),
319 reason_message: message,
320 });
321 }
322
323 pub(crate) fn unmount_mem(&mut self, mem: &str) -> Result<(), EngineError> {
330 #[cfg(test)]
331 if self.inject_unmount_failure.as_deref() == Some(mem) {
332 return Err(EngineError::Backend(crate::backend::BackendError::Other(
333 format!("injected unmount failure for mem `{mem}`"),
334 )));
335 }
336 let removed = self.unregister_writable_mem(mem)?;
337 if removed.is_none() {
338 self.quarantined.retain(|q| q.mount.mem != mem);
340 }
341 self.pending_mem_changed.retain(|n| n.mem != mem);
342 self.labelling_memo = std::cell::OnceCell::new();
343 self.recently_unmounted.insert(mem.to_string());
344 Ok(())
345 }
346
347 pub fn recently_unmounted(&self, mem: &str) -> bool {
350 self.recently_unmounted.contains(mem)
351 }
352
353 pub fn subscribe_roster_changes(&self, callback: RosterCallback) -> u64 {
356 let mut subs = self
357 .roster_subscribers
358 .lock()
359 .expect("roster subscriber registry mutex must not be poisoned");
360 let id = subs.0 + 1;
361 subs.0 = id;
362 subs.1.push((id, callback));
363 id
364 }
365
366 pub fn unsubscribe_roster_changes(&self, id: u64) {
368 let mut subs = self
369 .roster_subscribers
370 .lock()
371 .expect("roster subscriber registry mutex must not be poisoned");
372 subs.1.retain(|(slot, _)| *slot != id);
373 }
374
375 fn emit_roster_changed(&self, change: &RosterChange) {
376 if change.is_empty() {
377 return;
378 }
379 let callbacks: Vec<RosterCallback> = self
380 .roster_subscribers
381 .lock()
382 .expect("roster subscriber registry mutex must not be poisoned")
383 .1
384 .iter()
385 .map(|(_, cb)| cb.clone())
386 .collect();
387 for cb in callbacks {
388 cb(change);
389 }
390 }
391
392 pub fn writable_mem_set(&self) -> HashSet<String> {
395 self.mounts
396 .iter()
397 .filter(|m| m.mount.capability == crate::workspace::MountCapability::Write)
398 .map(|m| m.mount.mem.clone())
399 .collect()
400 }
401}