Skip to main content

coven_replication/sync/
loop_policy.rs

1//! Shared sync-loop policy.
2//!
3//! A cycle resets or increments the failure count, surfaces integrity / schema /
4//! asset alerts, and chooses an immediate, idle, or backoff wait.
5
6use coven_foundation::changeset::RowChange;
7
8use super::cycle::SyncCycleResult;
9use super::status::DeviceActivity;
10use super::store::HeldStorePosition;
11use super::sync_loop::SyncLoopFailure;
12use coven_protocol::objects::RotationPending;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub(crate) enum LoopWait {
16    Immediate,
17    Idle,
18    BackoffSecs(u64),
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SyncLoopAlerts {
23    /// This device has not adopted a store-key rotation the cloud has already
24    /// committed. While set, this cycle sealed nothing new for the cloud — a
25    /// confidentiality invariant, so it takes priority over every other alert
26    /// below.
27    pub rotation_pending: Option<RotationPending>,
28    /// Changesets held after a validation/apply failure, with per-changeset
29    /// detail (device, seq, reason) — so a host can name which are stalled.
30    pub held_positions: Vec<HeldStorePosition>,
31    pub local_blob_cleanup_pending: bool,
32}
33
34impl SyncLoopAlerts {
35    pub fn primary_message(&self) -> Option<String> {
36        if let Some(pending) = &self.rotation_pending {
37            Some(format!(
38                "Sync is paused: store-key rotation work is incomplete ({:?}) while this device \
39                 is on generation {}. Retry the membership operation or reconnect with key custody.",
40                pending.state, pending.live_generation,
41            ))
42        } else if !self.held_positions.is_empty() {
43            Some(format!(
44                "Store object {}/{} is held: {:?}",
45                self.held_positions[0].coordinate.device_id(),
46                self.held_positions[0].coordinate.seq(),
47                self.held_positions[0].reason,
48            ))
49        } else if self.local_blob_cleanup_pending {
50            Some("Some obsolete local file copies are still pending cleanup.".to_string())
51        } else {
52            None
53        }
54    }
55}
56
57#[derive(Debug, Clone)]
58pub struct SyncLoopSuccess {
59    pub last_sync_time: String,
60    pub device_count: u32,
61    /// Per-device activity of the other devices — id, member key, latest seq,
62    /// last-sync time — for a host to render which devices synced and when.
63    pub device_activity: Vec<DeviceActivity>,
64    pub data_changed: bool,
65    /// Row changes from applied changesets, for the host to map to domain events.
66    /// `Some` when `data_changed`. A refresh *hint*, not a complete stream: a
67    /// lagged subscriber can miss it entirely, and several accepted changesets
68    /// can touch the same row, so a host re-reads affected rows by primary key
69    /// rather than trusting it as exhaustive.
70    pub row_changes: Option<Vec<RowChange>>,
71    pub alerts: SyncLoopAlerts,
72}
73
74#[derive(Debug, Clone)]
75pub(crate) enum SyncLoopReport {
76    Success(SyncLoopSuccess),
77    Failure(SyncLoopFailure),
78}
79
80#[derive(Debug, Clone)]
81pub(crate) struct SyncLoopDecision {
82    pub consecutive_failures: u32,
83    pub wait: LoopWait,
84    pub report: SyncLoopReport,
85}
86
87pub(crate) fn after_success(result: SyncCycleResult) -> SyncLoopDecision {
88    let data_changed = result.changesets_applied > 0;
89    let row_changes = if data_changed && !result.row_changes.is_empty() {
90        Some(result.row_changes)
91    } else {
92        None
93    };
94
95    SyncLoopDecision {
96        consecutive_failures: 0,
97        wait: if result.resume_drain_promptly {
98            LoopWait::Immediate
99        } else {
100            LoopWait::Idle
101        },
102        report: SyncLoopReport::Success(SyncLoopSuccess {
103            last_sync_time: result.sync_time,
104            // This device plus the others its heads reported.
105            device_count: (result.device_activity.len() + 1) as u32,
106            device_activity: result.device_activity,
107            data_changed,
108            row_changes,
109            alerts: SyncLoopAlerts {
110                rotation_pending: result.rotation_pending,
111                held_positions: result.held_positions,
112                local_blob_cleanup_pending: result.local_blob_cleanup_pending,
113            },
114        }),
115    }
116}
117
118pub(crate) fn after_failure(
119    error: SyncLoopFailure,
120    previous_failures: u32,
121    backoff_cap_secs: u64,
122) -> SyncLoopDecision {
123    let consecutive_failures = previous_failures.saturating_add(1);
124    SyncLoopDecision {
125        consecutive_failures,
126        wait: LoopWait::BackoffSecs(super::backoff::backoff_secs(
127            consecutive_failures,
128            backoff_cap_secs,
129        )),
130        report: SyncLoopReport::Failure(error),
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    use crate::sync::store::pull::HeldStorePositionReason;
139    use crate::sync::store::{HeldStoreCoordinate, HeldStorePosition};
140    use coven_protocol::causal_grants::AuthorStreamId;
141    use coven_protocol::objects::ExactObjectRef;
142    use coven_protocol::store_commit::{ObjectHash, StoreBatchCommitRef, StoreCommitCoord};
143
144    fn held(n: usize) -> Vec<HeldStorePosition> {
145        (0..n)
146            .map(|i| HeldStorePosition {
147                coordinate: HeldStoreCoordinate::Commit {
148                    device_id: format!("dev-{i}"),
149                    commit: StoreBatchCommitRef {
150                        coord: StoreCommitCoord {
151                            stream_id: AuthorStreamId::from_digest(ObjectHash::digest(
152                                format!("stream-{i}").as_bytes(),
153                            )),
154                            sequence: i as u64 + 1,
155                        },
156                        commit_hash: ObjectHash::digest(format!("commit-{i}").as_bytes()),
157                        object: ExactObjectRef::new(
158                            coven_protocol::objects::ObjectSlot::logical(format!(
159                                "test-commit-{i}"
160                            ))
161                            .expect("test commit slot"),
162                            0,
163                            ObjectHash::digest(&[]),
164                        ),
165                    },
166                },
167                reason: HeldStorePositionReason::InvalidChangeset("boom".to_string()),
168            })
169            .collect()
170    }
171
172    fn device_activity(n: usize) -> Vec<DeviceActivity> {
173        (0..n)
174            .map(|i| DeviceActivity {
175                device_id: format!("dev-{i}"),
176                author: format!("author-{i}"),
177                last_seq: i as u64,
178            })
179            .collect()
180    }
181
182    fn cycle_result() -> SyncCycleResult {
183        SyncCycleResult {
184            changesets_applied: 0,
185            held_positions: Vec::new(),
186            device_activity: device_activity(2),
187            sync_time: "2026-07-03T00:00:00Z".to_string(),
188            local_blob_cleanup_pending: false,
189            row_changes: vec![],
190            resume_drain_promptly: false,
191            rotation_pending: None,
192        }
193    }
194
195    #[test]
196    fn success_resets_failures_and_waits_idle() {
197        let decision = after_success(cycle_result());
198
199        assert_eq!(decision.consecutive_failures, 0);
200        assert_eq!(decision.wait, LoopWait::Idle);
201        match decision.report {
202            SyncLoopReport::Success(success) => {
203                assert_eq!(success.device_count, 3);
204                assert!(!success.data_changed);
205                assert!(success.row_changes.is_none());
206            }
207            SyncLoopReport::Failure(error) => panic!("expected success, got {error}"),
208        }
209    }
210
211    #[test]
212    fn success_carries_device_activity_and_all_alert_categories() {
213        let mut result = cycle_result();
214        result.device_activity = device_activity(2);
215        result.held_positions = held(3);
216
217        let decision = after_success(result);
218
219        match decision.report {
220            SyncLoopReport::Success(success) => {
221                // The per-device detail reaches the report, not just a count.
222                assert_eq!(success.device_activity.len(), 2);
223                assert_eq!(success.device_activity[0].author, "author-0");
224                assert_eq!(success.device_count, 3);
225                // Held changesets travel with device/seq/reason, not a bare count.
226                assert_eq!(success.alerts.held_positions.len(), 3);
227                assert_eq!(
228                    success.alerts.held_positions[0].coordinate.device_id(),
229                    "dev-0"
230                );
231            }
232            SyncLoopReport::Failure(error) => panic!("expected success, got {error}"),
233        }
234    }
235
236    #[test]
237    fn drain_success_waits_immediately() {
238        let mut result = cycle_result();
239        result.resume_drain_promptly = true;
240
241        let decision = after_success(result);
242
243        assert_eq!(decision.wait, LoopWait::Immediate);
244    }
245
246    #[test]
247    fn failure_increments_and_backs_off() {
248        let decision = after_failure(
249            SyncLoopFailure::Storage(std::sync::Arc::new(
250                coven_protocol::objects::StorageError::Storage("network".to_string()),
251            )),
252            1,
253            300,
254        );
255
256        assert_eq!(decision.consecutive_failures, 2);
257        assert_eq!(decision.wait, LoopWait::BackoffSecs(120));
258        match decision.report {
259            SyncLoopReport::Failure(error) => assert!(error.to_string().contains("network")),
260            SyncLoopReport::Success(_) => panic!("expected failure"),
261        }
262    }
263
264    #[test]
265    fn alert_message_priority_matches_sync_status() {
266        let alerts = SyncLoopAlerts {
267            rotation_pending: None,
268            held_positions: held(4),
269            local_blob_cleanup_pending: true,
270        };
271
272        assert_eq!(
273            alerts.primary_message().as_deref(),
274            Some("Store object dev-0/1 is held: InvalidChangeset(\"boom\")"),
275        );
276    }
277
278    #[test]
279    fn rotation_pending_alert_takes_priority_over_every_other_alert() {
280        let alerts = SyncLoopAlerts {
281            rotation_pending: Some(RotationPending {
282                state: coven_protocol::objects::RotationPendingState::LocalCommitted {
283                    generation: 2,
284                },
285                live_generation: 1,
286            }),
287            held_positions: held(4),
288            local_blob_cleanup_pending: true,
289        };
290
291        let message = alerts.primary_message().expect("rotation pending alert");
292        assert!(
293            message.contains("generation: 2") && message.contains("generation 1"),
294            "message names both generations: {message}",
295        );
296    }
297
298    #[test]
299    fn constraint_conflict_alert_is_reported() {
300        let alerts = SyncLoopAlerts {
301            rotation_pending: None,
302            held_positions: Vec::new(),
303            local_blob_cleanup_pending: false,
304        };
305
306        assert_eq!(alerts.primary_message(), None);
307    }
308
309    #[test]
310    fn post_commit_cleanup_has_its_own_alert() {
311        let mut result = cycle_result();
312        result.local_blob_cleanup_pending = true;
313
314        let decision = after_success(result);
315
316        let SyncLoopReport::Success(success) = decision.report else {
317            panic!("expected success report");
318        };
319        assert_eq!(
320            success.alerts.primary_message().as_deref(),
321            Some("Some obsolete local file copies are still pending cleanup."),
322        );
323        assert!(success.alerts.local_blob_cleanup_pending);
324    }
325}