1use 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 pub rotation_pending: Option<RotationPending>,
28 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 pub device_activity: Vec<DeviceActivity>,
64 pub data_changed: bool,
65 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 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 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 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}