1use 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 pub rotation_pending: Option<RotationPending>,
27 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 pub device_activity: Vec<DeviceActivity>,
67 pub data_changed: bool,
68 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 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 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 assert!(success.alerts.asset_downloads_failed);
235 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}