Skip to content

Commit 16dd81e

Browse files
committed
sync: converge inherited catalog tombstones
1 parent be0a343 commit 16dd81e

8 files changed

Lines changed: 444 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ EXPERIMENTAL, so on-disk formats and the CLI may change without notice.
88

99
### Added
1010

11+
- **Truthful sync status.** `fabric sync ls` now reports logical Present,
12+
Tombstone, and observed-on-disk counts plus explicit missing, unexpected, and
13+
content-mismatch drift. `fabric sync ls --json` provides the same state as a
14+
stable machine-readable array.
15+
1116
- **Safe st2 catalog sync recipe.** The README now provides a copy-pasteable,
1217
two-entry positive allow-list for declarations and bus data, with explicit
1318
machine-local PTY/runtime exclusions and an ordinary resource/message
@@ -55,6 +60,12 @@ EXPERIMENTAL, so on-disk formats and the CLI may change without notice.
5560

5661
### Fixed
5762

63+
- **Inherited catalog tombstones no longer leave folders divergent.** Under
64+
catalog policy, any surviving physical copy now advances an inherited
65+
Tombstone to a higher Present version and supplies its content over the wire,
66+
converging every materialized folder and persisted restart. Bus policy keeps
67+
its existing delete semantics and removes the same stale bytes.
68+
5869
- **macOS startup no longer blocks forever in CoreWLAN.** The optional Wi-Fi
5970
transmit-rate lookup is now bounded to 250 ms and cached per interface; a
6071
wedged synchronous CoreWLAN XPC request falls back to unknown speed without

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,16 @@ sync only ever touches already-trusted peers — it adds no new trust surface.
764764
Peers that receive the tombstone remove the path. Tombstones are retained;
765765
automatic sweeping is not implemented.
766766

767+
Catalog never originates tombstones, but persisted or wire state may inherit one
768+
from an older bus configuration. Fabric migrates that state using
769+
union-of-presence: if unchanged in-scope bytes still exist on any catalog node,
770+
that node advances them to a higher logical Present version and publishes their
771+
content, so every catalog manifest and materialized folder converges. Bus keeps
772+
the same tombstone authoritative and removes the stale bytes. If no catalog
773+
node retains a copy, Fabric cannot reconstruct deleted content; the inherited
774+
tombstone remains visible in `fabric sync ls` until an operator deliberately
775+
recreates the canonical file.
776+
767777
Publication tools must treat every watcher-visible, included path as a durable
768778
logical sync key. Stage temporary, backup, and partial files **outside the
769779
configured sync folder** (on the same filesystem when an atomic rename is
@@ -799,6 +809,7 @@ rollback on the next reconcile.
799809
```sh
800810
fabric sync add <folder> --name <name> [--peers "*"|a,b] [--policy catalog|bus] [--include "*.toml"]
801811
fabric sync ls
812+
fabric sync ls --json
802813
fabric sync rm <name-or-folder>
803814
fabric sync reload
804815
```
@@ -809,6 +820,15 @@ the file to a running daemon, mirroring `reload-peers`. The daemon serves and
809820
dials sync over the reserved `fabric/sync/1` ALPN, gated by the same peer
810821
allow-list as every other fabric protocol.
811822

823+
`fabric sync ls` reports `present` (logical files in the manifest),
824+
`tombstones` (retained logical deletions), and `observed` (included paths in the
825+
last durable local-disk receipt). `drift=clean` means the logical Present paths
826+
and observed bytes agree. A `drift=WARNING` names `missing` Present paths,
827+
`unexpected` observed paths whose manifest is tombstoned or absent, and
828+
`mismatched` paths whose observed content hash differs from the logical Present.
829+
`fabric sync ls --json` emits a stable array with those fields plus a Boolean
830+
`drift` for automation.
831+
812832
### Sync an st2 catalog safely
813833

814834
An st2 catalog mixes declarative fleet data, durable bus data, and strictly
@@ -865,9 +885,11 @@ fabric status
865885
fabric ping <peer-name>
866886
```
867887

868-
`fabric sync ls` must show the same two logical names on every host, with the
869-
local catalog paths and intended peer selectors. `fabric status` and
870-
`fabric ping` must show the selected peers reachable.
888+
`fabric sync ls` must show the same two logical names and the same logical
889+
Present/Tombstone counts on every host, with the local catalog paths and
890+
intended peer selectors. In steady state `observed` equals `present` and
891+
`drift=clean`. `fabric status` and `fabric ping` must show the selected peers
892+
reachable.
871893

872894
For the default Fabric home, inspect the effective include lists and fail if
873895
machine-local paths were added:

src/control.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,21 @@ pub struct SyncEntryStatus {
131131
pub folder: String,
132132
pub policy: String,
133133
pub peers: String,
134+
/// Legacy logical-Present count retained on the local control wire so an
135+
/// older client can still decode a newer daemon response.
134136
pub files: usize,
137+
#[serde(default)]
138+
pub present: usize,
139+
#[serde(default)]
140+
pub tombstones: usize,
141+
#[serde(default)]
142+
pub observed: usize,
143+
#[serde(default)]
144+
pub missing: usize,
145+
#[serde(default)]
146+
pub unexpected: usize,
147+
#[serde(default)]
148+
pub mismatched: usize,
135149
}
136150

137151
#[derive(Debug, Clone, Serialize, Deserialize)]

src/daemon.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2444,7 +2444,13 @@ async fn process_control_request(
24442444
folder: status.folder.display().to_string(),
24452445
policy: status.policy.to_string(),
24462446
peers: peers_display(&status.peers),
2447-
files: status.files,
2447+
files: status.present,
2448+
present: status.present,
2449+
tombstones: status.tombstones,
2450+
observed: status.observed,
2451+
missing: status.missing,
2452+
unexpected: status.unexpected,
2453+
mismatched: status.mismatched,
24482454
})
24492455
.collect(),
24502456
None => Vec::new(),

src/main.rs

Lines changed: 128 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,11 @@ enum SyncCommands {
249249
include: Option<String>,
250250
},
251251
/// List configured sync entries and their live state.
252-
Ls,
252+
Ls {
253+
/// Emit a stable JSON array for scripts.
254+
#[arg(long)]
255+
json: bool,
256+
},
253257
/// Remove a sync entry by name or folder and reload the daemon.
254258
Rm { name_or_folder: String },
255259
/// Re-read syncs.toml into the running daemon (like reload-peers).
@@ -690,16 +694,42 @@ async fn run_sync(home: &FabricHome, command: SyncCommands) -> Result<()> {
690694
let _ = send_control(home, ControlRequest::SyncReload).await;
691695
println!("sync {name:?} written to {}", home.syncs_path().display());
692696
}
693-
SyncCommands::Ls => match send_control(home, ControlRequest::SyncStatus).await? {
697+
SyncCommands::Ls { json } => match send_control(home, ControlRequest::SyncStatus).await? {
694698
ControlResponse::SyncStatus { entries } => {
699+
if json {
700+
let entries: Vec<_> = entries.iter().map(SyncLsJsonEntry::from).collect();
701+
println!("{}", serde_json::to_string_pretty(&entries)?);
702+
return Ok(());
703+
}
695704
if entries.is_empty() {
696705
println!("no sync entries");
697706
}
698707
for entry in entries {
699-
println!(
700-
"{}\t{}\t{}\tpeers={}\t{} files",
701-
entry.name, entry.folder, entry.policy, entry.peers, entry.files
702-
);
708+
let present = logical_present(&entry);
709+
if entry.missing == 0 && entry.unexpected == 0 && entry.mismatched == 0 {
710+
println!(
711+
"{}\t{}\t{}\tpeers={}\tpresent={present}\ttombstones={}\tobserved={}\tdrift=clean",
712+
entry.name,
713+
entry.folder,
714+
entry.policy,
715+
entry.peers,
716+
entry.tombstones,
717+
entry.observed,
718+
);
719+
} else {
720+
println!(
721+
"{}\t{}\t{}\tpeers={}\tpresent={present}\ttombstones={}\tobserved={}\tdrift=WARNING missing={} unexpected={} mismatched={}",
722+
entry.name,
723+
entry.folder,
724+
entry.policy,
725+
entry.peers,
726+
entry.tombstones,
727+
entry.observed,
728+
entry.missing,
729+
entry.unexpected,
730+
entry.mismatched,
731+
);
732+
}
703733
}
704734
}
705735
response => bail!("unexpected daemon response: {response:?}"),
@@ -721,6 +751,98 @@ async fn run_sync(home: &FabricHome, command: SyncCommands) -> Result<()> {
721751
Ok(())
722752
}
723753

754+
#[derive(serde::Serialize)]
755+
struct SyncLsJsonEntry<'a> {
756+
name: &'a str,
757+
folder: &'a str,
758+
policy: &'a str,
759+
peers: &'a str,
760+
present: usize,
761+
tombstones: usize,
762+
observed: usize,
763+
drift: bool,
764+
missing: usize,
765+
unexpected: usize,
766+
mismatched: usize,
767+
}
768+
769+
impl<'a> From<&'a fabric::control::SyncEntryStatus> for SyncLsJsonEntry<'a> {
770+
fn from(entry: &'a fabric::control::SyncEntryStatus) -> Self {
771+
Self {
772+
name: &entry.name,
773+
folder: &entry.folder,
774+
policy: &entry.policy,
775+
peers: &entry.peers,
776+
present: logical_present(entry),
777+
tombstones: entry.tombstones,
778+
observed: entry.observed,
779+
drift: entry.missing != 0 || entry.unexpected != 0 || entry.mismatched != 0,
780+
missing: entry.missing,
781+
unexpected: entry.unexpected,
782+
mismatched: entry.mismatched,
783+
}
784+
}
785+
}
786+
787+
fn logical_present(entry: &fabric::control::SyncEntryStatus) -> usize {
788+
if entry.present == 0 {
789+
entry.files
790+
} else {
791+
entry.present
792+
}
793+
}
794+
795+
#[cfg(test)]
796+
mod sync_ls_tests {
797+
use super::*;
798+
use fabric::control::SyncEntryStatus;
799+
800+
fn status() -> SyncEntryStatus {
801+
SyncEntryStatus {
802+
name: "catalog".to_string(),
803+
folder: "/catalog".to_string(),
804+
policy: "catalog".to_string(),
805+
peers: "*".to_string(),
806+
files: 40,
807+
present: 40,
808+
tombstones: 3,
809+
observed: 42,
810+
missing: 0,
811+
unexpected: 2,
812+
mismatched: 0,
813+
}
814+
}
815+
816+
#[test]
817+
fn sync_ls_json_schema_exposes_counts_and_drift() {
818+
let status = status();
819+
let json = serde_json::to_value(SyncLsJsonEntry::from(&status)).unwrap();
820+
assert_eq!(
821+
json,
822+
serde_json::json!({
823+
"name": "catalog",
824+
"folder": "/catalog",
825+
"policy": "catalog",
826+
"peers": "*",
827+
"present": 40,
828+
"tombstones": 3,
829+
"observed": 42,
830+
"drift": true,
831+
"missing": 0,
832+
"unexpected": 2,
833+
"mismatched": 0
834+
})
835+
);
836+
}
837+
838+
#[test]
839+
fn sync_ls_accepts_legacy_control_present_count() {
840+
let mut status = status();
841+
status.present = 0;
842+
assert_eq!(logical_present(&status), 40);
843+
}
844+
}
845+
724846
fn absolutize(folder: &str) -> Result<PathBuf> {
725847
let path = PathBuf::from(folder);
726848
if path.is_absolute() {

src/sync/config.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ use crate::config::FabricHome;
2525
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2626
#[serde(rename_all = "snake_case")]
2727
pub enum SyncPolicy {
28-
/// Union + newer-wins + NEVER delete on a peer + no sweep + no tombstones.
28+
/// Union + newer-wins + NEVER originate a delete + no sweep.
2929
///
3030
/// Safe for a job catalog: a file present on any peer is present on all
31-
/// peers, and nothing ever removes a file. Decommissioning is expressed as
32-
/// an edit (e.g. `retired = true`), never a file deletion.
31+
/// peers, and nothing ever removes a file. An inherited tombstone is
32+
/// superseded when any physical copy survives. Decommissioning is expressed
33+
/// as an edit (e.g. `retired = true`), never a file deletion.
3334
Catalog,
3435
/// Union + newer-wins + no delete + tombstone sweep. Reserved for the
3536
/// smalltalk bus; not yet fully implemented (see [`PolicyRules`]).

0 commit comments

Comments
 (0)