Skip to main content

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