Skip to content

Commit 0e71855

Browse files
committed
sync: persist observed disk receipts
1 parent d1ebcdd commit 0e71855

3 files changed

Lines changed: 150 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ EXPERIMENTAL, so on-disk formats and the CLI may change without notice.
3535
carry an explicit last-observed disk receipt across reconciliation, durably
3636
record local changes before merging, and guard final materialization. This
3737
preserves deletes in both pre-merge and post-scan races while still accepting
38-
genuinely new remote files, without deadlocking simultaneous peer syncs.
38+
genuinely new remote files, without deadlocking simultaneous peer syncs. The
39+
manifest and observed-disk receipt are committed together in one authoritative
40+
state file, so the same guarantee survives daemon restarts and time spent with
41+
a sync entry disabled.
3942

4043
- **Peer-config split when the daemon runs with `--home <default-root>`.** The
4144
managed service always launches the daemon as

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,14 @@ sync only ever touches already-trusted peers — it adds no new trust surface.
695695
Conflicts resolve newer-wins by a logical version with a deterministic tie-break,
696696
not by filesystem mtime (which is unreliable across machines).
697697

698+
Fabric stores each entry's internal recovery data under
699+
`<home>/sync/<sanitized-name>/`. `state.json` is the authoritative atomic record
700+
of both the converged manifest and the files last observed on local disk;
701+
`manifest.json` is only a compatibility and inspection projection. Do not edit
702+
either file independently. A rollback or downgrade must restore the entire
703+
per-entry state directory together with the matching Fabric binary and config,
704+
not only `manifest.json`.
705+
698706
### Sync Commands
699707

700708
```sh

src/sync/engine.rs

Lines changed: 138 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use std::{
2525

2626
use anyhow::{Context, Result};
2727
use iroh::EndpointAddr;
28+
use serde::{Deserialize, Serialize};
2829
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock, mpsc};
2930
use tokio_util::sync::CancellationToken;
3031

@@ -80,6 +81,16 @@ struct EntryState {
8081
observed: Arc<StdMutex<HashMap<String, ContentHash>>>,
8182
}
8283

84+
/// Atomically persisted authoritative state for one sync entry. `manifest.json`
85+
/// remains a compatibility/inspection projection, but restart recovery reads
86+
/// this combined file so manifest transitions and their observed-disk receipt
87+
/// cannot be torn apart by a crash.
88+
#[derive(Debug, Serialize, Deserialize)]
89+
struct PersistedEntryState {
90+
manifest: Manifest,
91+
observed: HashMap<String, ContentHash>,
92+
}
93+
8394
/// State captured immediately before an inbound merge. The operation guard
8495
/// keeps every engine-driven scan/materialize for this entry out of the middle
8596
/// of the wire session; `baseline` is the pre-merge observed-disk receipt that
@@ -152,8 +163,7 @@ impl<T: SyncTransport> SyncEngine<T> {
152163
existing.observed.clone(),
153164
),
154165
_ => {
155-
let node = self.load_node(cfg).await?;
156-
let observed = observed_from_disk(node.manifest(), cfg)?;
166+
let (node, observed) = self.load_node_and_observed(cfg).await?;
157167
(
158168
Arc::new(Mutex::new(node)),
159169
Arc::new(Mutex::new(())),
@@ -176,12 +186,20 @@ impl<T: SyncTransport> SyncEngine<T> {
176186
Ok(())
177187
}
178188

179-
async fn load_node(&self, cfg: &SyncEntry) -> Result<SyncNode> {
189+
async fn load_node_and_observed(
190+
&self,
191+
cfg: &SyncEntry,
192+
) -> Result<(SyncNode, HashMap<String, ContentHash>)> {
180193
let mut node = SyncNode::new(self.author);
194+
if let Some(state) = self.read_state(&cfg.name)? {
195+
node.adopt(&state.manifest);
196+
return Ok((node, state.observed));
197+
}
181198
if let Some(manifest) = self.read_manifest(&cfg.name)? {
182199
node.adopt(&manifest);
183200
}
184-
Ok(node)
201+
let observed = observed_from_disk(node.manifest(), cfg)?;
202+
Ok((node, observed))
185203
}
186204

187205
/// Resolve a sync name to its node (used by the daemon's inbound accept).
@@ -208,11 +226,11 @@ impl<T: SyncTransport> SyncEngine<T> {
208226
return Ok(None);
209227
};
210228
let operation = entry.operation.clone().lock_owned().await;
211-
if self.scan_entry(&entry).await? {
212-
// Persist the local intent before accepting remote state so a crash
213-
// during the wire session cannot lose the tombstone either.
214-
self.persist_entry(&entry).await?;
215-
}
229+
self.scan_entry(&entry).await?;
230+
// Persist the receipt even when the manifest did not change: a legacy
231+
// entry may not have state.json yet, and a crash during this first wire
232+
// session must not lose knowledge of locally observed Present paths.
233+
self.persist_entry(&entry).await?;
216234
let baseline = entry.observed.lock().unwrap().clone();
217235
Ok(Some(PreparedInbound {
218236
entry,
@@ -342,7 +360,11 @@ impl<T: SyncTransport> SyncEngine<T> {
342360

343361
async fn persist_entry(&self, entry: &EntryState) -> Result<()> {
344362
let manifest = entry.node.lock().await.manifest().clone();
345-
self.write_manifest(&entry.config.name, &manifest)
363+
let observed = entry.observed.lock().unwrap().clone();
364+
self.write_state(
365+
&entry.config.name,
366+
&PersistedEntryState { manifest, observed },
367+
)
346368
}
347369

348370
fn manifest_path(&self, name: &str) -> PathBuf {
@@ -353,6 +375,26 @@ impl<T: SyncTransport> SyncEngine<T> {
353375
.join("manifest.json")
354376
}
355377

378+
fn state_path(&self, name: &str) -> PathBuf {
379+
self.home
380+
.root()
381+
.join("sync")
382+
.join(sanitize_name(name))
383+
.join("state.json")
384+
}
385+
386+
fn read_state(&self, name: &str) -> Result<Option<PersistedEntryState>> {
387+
let path = self.state_path(name);
388+
if !path.exists() {
389+
return Ok(None);
390+
}
391+
let raw = std::fs::read_to_string(&path)
392+
.with_context(|| format!("failed to read {}", path.display()))?;
393+
let state: PersistedEntryState = serde_json::from_str(&raw)
394+
.with_context(|| format!("failed to parse {}", path.display()))?;
395+
Ok(Some(state))
396+
}
397+
356398
fn read_manifest(&self, name: &str) -> Result<Option<Manifest>> {
357399
let path = self.manifest_path(name);
358400
if !path.exists() {
@@ -374,6 +416,20 @@ impl<T: SyncTransport> SyncEngine<T> {
374416
write_atomic(&path, raw.as_bytes())
375417
}
376418

419+
fn write_state(&self, name: &str, state: &PersistedEntryState) -> Result<()> {
420+
let path = self.state_path(name);
421+
if let Some(parent) = path.parent() {
422+
std::fs::create_dir_all(parent)?;
423+
}
424+
let raw = serde_json::to_string_pretty(state)?;
425+
// The combined state is authoritative and lands atomically first.
426+
write_atomic(&path, raw.as_bytes())?;
427+
// Keep the established manifest path current for operators and older
428+
// Fabric binaries. A crash between these writes still recovers from the
429+
// already-committed combined state above.
430+
self.write_manifest(name, &state.manifest)
431+
}
432+
377433
/// Start watching every configured entry's folder and syncing on change,
378434
/// then run until the cancellation token fires. Idempotent per entry.
379435
pub async fn run(self: &Arc<Self>) -> Result<()> {
@@ -1307,6 +1363,78 @@ mod tests {
13071363
));
13081364
}
13091365

1366+
#[tokio::test]
1367+
async fn inactive_entry_archive_uses_durable_observed_receipt_on_reload() {
1368+
let dir = tempfile::tempdir().unwrap();
1369+
let root = dir.path().join("resources");
1370+
std::fs::create_dir_all(root.join("inbox")).unwrap();
1371+
std::fs::write(root.join("inbox/archived.md"), b"archive offline").unwrap();
1372+
write_bus_sync(dir.path(), &root);
1373+
1374+
let engine = SyncEngine::new(
1375+
FabricHome::new(dir.path()),
1376+
Author([1; 32]),
1377+
Arc::new(LoopbackTransport::default()),
1378+
CancellationToken::new(),
1379+
)
1380+
.await
1381+
.unwrap();
1382+
engine.sync_once("bus").await.unwrap();
1383+
1384+
// Disable the entry while its inbox Present is durably observed, then
1385+
// perform the archive while no watcher/entry can see the rename.
1386+
std::fs::write(dir.path().join("syncs.toml"), "").unwrap();
1387+
engine.load_from_config().await.unwrap();
1388+
assert!(engine.node_for("bus").await.is_none());
1389+
std::fs::create_dir_all(root.join("archive")).unwrap();
1390+
std::fs::rename(
1391+
root.join("inbox/archived.md"),
1392+
root.join("archive/archived.md"),
1393+
)
1394+
.unwrap();
1395+
1396+
// Cross an actual daemon/engine restart boundary while the entry is
1397+
// still disabled. Remove the compatibility projection as well, proving
1398+
// that the authoritative combined state is the only recovery source.
1399+
let state_path = engine.state_path("bus");
1400+
let manifest_path = engine.manifest_path("bus");
1401+
assert!(state_path.exists());
1402+
drop(engine);
1403+
std::fs::remove_file(&manifest_path).unwrap();
1404+
1405+
let engine = SyncEngine::new(
1406+
FabricHome::new(dir.path()),
1407+
Author([1; 32]),
1408+
Arc::new(LoopbackTransport::default()),
1409+
CancellationToken::new(),
1410+
)
1411+
.await
1412+
.unwrap();
1413+
assert!(engine.node_for("bus").await.is_none());
1414+
assert!(!manifest_path.exists());
1415+
1416+
write_bus_sync(dir.path(), &root);
1417+
engine.load_from_config().await.unwrap();
1418+
engine.sync_once("bus").await.unwrap();
1419+
1420+
let node = engine.node_for("bus").await.unwrap();
1421+
let node = node.lock().await;
1422+
assert!(matches!(
1423+
node.manifest().get("inbox/archived.md"),
1424+
Some(Entry::Tombstone(_))
1425+
));
1426+
assert!(matches!(
1427+
node.manifest().get("archive/archived.md"),
1428+
Some(Entry::Present(_))
1429+
));
1430+
drop(node);
1431+
assert!(!root.join("inbox/archived.md").exists());
1432+
assert_eq!(
1433+
std::fs::read(root.join("archive/archived.md")).unwrap(),
1434+
b"archive offline"
1435+
);
1436+
}
1437+
13101438
#[tokio::test]
13111439
async fn local_edit_after_post_merge_scan_is_not_overwritten() {
13121440
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)