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