1use 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 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: 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 pub device_activity: Vec<DeviceActivity>,
66 pub data_changed: bool,
67 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 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 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 assert!(success.alerts.asset_downloads_failed);
232 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}