rings_core/dht/storage/repair.rs
1//! Formal storage-replication model.
2//!
3//! State variables:
4//! - `R = Z / 2^160`, represented by [`Did`].
5//! - `place(id(e), N) = [k_0, ..., k_{N-1}]`, computed by
6//! [`Did::rotate_affine`].
7//! - `sigma_n[k]` is the [`Entry`] stored by node `n` under placement key `k`.
8//! - `local_branch(n, k, view)` is true when `find_successor(k)` evaluated by
9//! node `n` under `view` returns `Some(_)`.
10//! - `vhint(k, view, cfg)` is the physical owner selected by storage virtual
11//! positions derived from the authenticated owner set in `view`.
12//! - `accepts(n, k, view, cfg) = vhint(k, view, cfg) == n` when virtual storage
13//! is enabled, otherwise `local_branch(n, k, view)`.
14//! - `handoff_proof(s, r, k, view_s)` holds only for non-virtual physical Chord
15//! handoff where sender `s` has a physical successor transition to receiver
16//! `r`. A `vhint` is not a handoff proof because `view_s` may omit a physical
17//! node that owns a closer virtual position.
18//!
19//! Invariant REPLICATED(e, N):
20//! `forall k in place(id(e), N), exists n. accepts(n, k, view_n, cfg) &&
21//! sigma_n[k] >= e_delta`, where `>=` is the partial order induced by
22//! [`crate::algebra::JoinSemilattice`].
23//! This is a view-relative invariant: every node evaluates `accepts` under its
24//! authenticated local view. Global convergence requires a quiescent window
25//! where those local views refine to the same acceptance relation. Before that
26//! refinement, `vhint` is only a copy target, never a source-delete authority.
27//!
28//! Liveness S4:
29//! In a quiescent window after local views refine to the same `accepts`
30//! relation, if at least one placement copy of `e` remains at the start of an
31//! anti-entropy period, one `republish_local_entries` round delivers the entry's
32//! join state to every refined current accepting node in `place(id(e), N)`.
33//! Before view refinement, republish targets the caller's local view. A
34//! receiver whose view disagrees may refuse the copy, and an accepting receiver
35//! still cannot authorize source cleanup unless the sync purpose carries a
36//! non-virtual `handoff_proof`.
37//!
38//! Safety:
39//! - S1 Additivity (#612): repair transitions in this module never call
40//! `storage.remove`; they only deliver additional joins.
41//! - S1' Ownership validation: receivers persist only placements they accept
42//! under their current `view`; stale senders keep local entries and retry in a
43//! later anti-entropy round.
44//! - S1'' Cleanup authority: for a sync message from sender `s` to receiver
45//! `r`, a delete-capable ack for key `k` can be emitted only after
46//! `accepts(r, k, view_r, cfg)`, `sigma_r[k] >= e_delta`, and
47//! `handoff_proof(s, r, k, view_s)`. Virtual-node copies never satisfy
48//! `handoff_proof`, so a local virtual-owner hint cannot delete source data.
49//! - S2' No-update-loss (#611/#614 cleanup): the only deletion transition is
50//! `acknowledge_synced_entries`; the finite model
51//! `test_storage_sync_model_preserves_no_update_loss` in `test_dht_stateright` checks
52//! that ack-delete removes a local value only when the receiver state contains
53//! the same storage-canonical joined value.
54//! - S3 Idempotence: duplicate repair delivery is observationally equivalent to
55//! one delivery because [`Entry::join`](crate::dht::entry::Entry::join) is
56//! idempotent.
57//!
58//! Read-repair:
59//! Given lookup observation `o : place(id(e), N) -> {Hit(e), Miss, Unknown}`,
60//! `repair_targets(o) = { k | o(k) = Miss }`. `read_repair_entry` validates
61//! `repair_targets(o) subseteq place(id(e), N)`, copies only those targets, and
62//! does not derive additional targets or evaluate `succ`. Transport keeps
63//! observations bounded by lookup round, TTL, and capacity, so a miss owner is a
64//! fresh lookup witness rather than persistent routing state.
65
66use async_trait::async_trait;
67
68use super::StorageSyncDestination;
69use super::StorageSyncTarget;
70use crate::dht::chord::PeerRing;
71use crate::dht::chord::PeerRingAction;
72use crate::dht::entry::Entry;
73use crate::dht::entry::PlacedEntry;
74use crate::dht::entry::PlacementMiss;
75use crate::dht::Chord;
76use crate::dht::ChordStorageRepair;
77use crate::dht::Did;
78use crate::error::Result;
79
80fn merge_actions(actions: Vec<PeerRingAction>) -> PeerRingAction {
81 if actions.is_empty() {
82 PeerRingAction::None
83 } else {
84 PeerRingAction::MultiActions(actions)
85 }
86}
87
88fn push_action(actions: &mut Vec<PeerRingAction>, action: PeerRingAction) {
89 match action {
90 PeerRingAction::None => {}
91 PeerRingAction::MultiActions(inner) => {
92 for action in inner {
93 push_action(actions, action);
94 }
95 }
96 action => actions.push(action),
97 }
98}
99
100impl PeerRing {
101 /// Returns whether a departed peer was near enough to local storage
102 /// responsibility that local entries should be republished after removing it.
103 pub(crate) async fn peer_may_share_storage_responsibility(
104 &self,
105 peer: Did,
106 redundancy: u16,
107 ) -> Result<bool> {
108 // Pre: peer is a terminal or departing DID under the caller's routing
109 // view.
110 // Post: true iff peer is observed in a routing position that can affect
111 // storage responsibility: predecessor, successor list, finger table, or
112 // successor witness for some locally held affine placement key.
113 // Preservation S1: this predicate performs no storage writes/removes.
114 if self.observed_storage_virtual_owner_registered(peer)? {
115 return Ok(true);
116 }
117 let topology = self.topology_state()?;
118 if topology.predecessor == Some(peer) {
119 return Ok(true);
120 }
121 if topology.successors.contains(&peer) {
122 return Ok(true);
123 }
124 if topology.fingers.contains(&Some(peer)) {
125 return Ok(true);
126 }
127
128 if redundancy <= 1 {
129 return Ok(false);
130 }
131
132 // Departure repair is only an accelerator; periodic anti-entropy is
133 // the authoritative backstop. This scan is O(entries * redundancy) and
134 // may race with another terminal-state trigger, but repair only
135 // delivers joins, so duplicate triggers preserve storage state.
136 for (_, entry) in self.storage.get_all().await? {
137 for placement_key in entry.did.rotate_affine(redundancy)? {
138 match self.find_successor(placement_key)? {
139 PeerRingAction::Some(owner) if owner == peer => return Ok(true),
140 PeerRingAction::RemoteAction(next, _) if next == peer => return Ok(true),
141 _ => {}
142 }
143 }
144 }
145 Ok(false)
146 }
147
148 async fn copy_entry_to_placement(
149 &self,
150 placement_key: Did,
151 entry: &Entry,
152 ) -> Result<PeerRingAction> {
153 // Pre: placement_key belongs to place(id(entry), redundancy) for the
154 // caller's anti-entropy or republish transition.
155 // Post S1: no local key is removed.
156 // Post S3: if self accepts placement_key under the local view,
157 // sigma_self[placement_key] is joined with entry after the transition;
158 // repeating the write preserves sigma by join idempotence.
159 // Post: otherwise, the returned action carries PlacedEntry {
160 // key: placement_key, entry } so placement identity is not recomputed by
161 // the receiver.
162 let placed = PlacedEntry::new(placement_key, entry.clone());
163 match self.storage_sync_target(placement_key)? {
164 StorageSyncTarget::Local => {
165 self.join_storage_entry(placement_key, entry.clone())
166 .await?;
167 Ok(PeerRingAction::None)
168 }
169 StorageSyncTarget::Remote(destination) => {
170 Ok(PeerRingAction::sync_entries_for_repair(destination, vec![
171 placed,
172 ]))
173 }
174 }
175 }
176
177 async fn copy_entry_to_observed_miss(
178 &self,
179 miss: PlacementMiss,
180 entry: &Entry,
181 redundancy: u16,
182 ) -> Result<PeerRingAction> {
183 // Pre: miss was produced by entry_lookup/SearchEntry and is still
184 // fresh under the transport observation TTL, so miss.owner was the
185 // responsible owner for miss.key under the lookup's routing view.
186 // Pre: redundancy is the lookup redundancy used to produce the miss.
187 // Post R1/R2: exactly miss.key is repaired; Hit and Unknown placements
188 // are not touched by this transition.
189 // Post R3: miss.key is proven to be in place(id(entry), redundancy)
190 // before any local write or remote copy action is emitted.
191 // Post R4: place(id(entry), redundancy) is used only as a membership
192 // predicate; this function does not derive new targets or recompute
193 // succ(miss.key). It reuses the owner observed by lookup.
194 let placed = PlacedEntry::new(miss.key, entry.clone());
195 placed.validate_placement(redundancy)?;
196 if miss.owner == self.did {
197 self.join_storage_entry(placed.key, placed.entry).await?;
198 Ok(PeerRingAction::None)
199 } else {
200 Ok(PeerRingAction::sync_entries_for_repair(
201 StorageSyncDestination::PhysicalOwner(miss.owner),
202 vec![placed],
203 ))
204 }
205 }
206
207 async fn republish_entry(&self, entry: Entry, redundancy: u16) -> Result<PeerRingAction> {
208 if redundancy <= 1 {
209 return Ok(PeerRingAction::None);
210 }
211
212 let entry = entry.try_into_storage_entry()?;
213 let mut actions = Vec::new();
214 for placement_key in entry.did.rotate_affine(redundancy)? {
215 let action = self.copy_entry_to_placement(placement_key, &entry).await?;
216 push_action(&mut actions, action);
217 }
218 Ok(merge_actions(actions))
219 }
220}
221
222#[cfg_attr(all(feature = "wasm", target_family = "wasm"), async_trait(?Send))]
223#[cfg_attr(not(all(feature = "wasm", target_family = "wasm")), async_trait)]
224impl ChordStorageRepair<PeerRingAction> for PeerRing {
225 async fn republish_local_entries(&self, redundancy: u16) -> Result<PeerRingAction> {
226 if redundancy <= 1 {
227 return Ok(PeerRingAction::None);
228 }
229
230 // Pre: redundancy > 1 and every local storage value is an Entry.
231 // Post S1: forall key in local_before, local_after[key] =
232 // local_before[key]. This transition only emits join deliveries.
233 // Post S3: repeating this transition produces the same sigma mapping as
234 // one application because storage writes are Entry::join deliveries.
235 // Post S4: for every local entry e, each key in place(id(e),
236 // redundancy) is either joined locally when self accepts it or emitted
237 // as a copy action toward the local view's storage-sync destination.
238 let mut actions = Vec::new();
239 for (_, entry) in self.storage.get_all().await? {
240 let action = self.republish_entry(entry, redundancy).await?;
241 push_action(&mut actions, action);
242 }
243 Ok(merge_actions(actions))
244 }
245
246 async fn read_repair_entry(
247 &self,
248 entry: Entry,
249 misses: &[PlacementMiss],
250 redundancy: u16,
251 ) -> Result<PeerRingAction> {
252 // Pre: misses = repair_targets(o) for the lookup observation that found
253 // entry, and each miss.owner was observed while querying miss.key.
254 // Pre: redundancy is the same redundancy that produced the lookup
255 // observation, so place(id(entry), redundancy) is the accepted replica set.
256 // Post R1: emitted copy actions are in one-to-one correspondence with
257 // misses whose owner is remote; self-owned misses are written locally.
258 // Post R2/R3: Hit and Unknown placements are absent from misses, so no
259 // action can target them. A local-hit short circuit has misses = [].
260 // Post R4: no successor is recomputed here. The placement vector is
261 // used only to validate observed misses, never to synthesize targets.
262 // Preservation S1/S3: this transition never removes and duplicate copy
263 // actions are duplicate Entry::join deliveries.
264 let mut actions = Vec::new();
265 for miss in misses.iter().copied() {
266 let action = self
267 .copy_entry_to_observed_miss(miss, &entry, redundancy)
268 .await?;
269 push_action(&mut actions, action);
270 }
271 Ok(merge_actions(actions))
272 }
273}