1use std::fs;
2use std::io;
3use std::path::Path;
4
5use crate::metadata::{
6 now_unix_secs, read_replica_cursors_unlocked, replace_replica_cursors_unlocked,
7 with_cursor_metadata_lock,
8};
9use crate::segment::list_segment_files;
10use crate::{read_identity, retained_segments_dir, retained_tail_progress, ReplicaCursor};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SyncRepairAction {
14 None,
15 Pull,
16 AwaitArchive,
17 Rebootstrap,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ReplicaSyncStatus {
22 pub replica_id: String,
23 pub active: bool,
24 pub last_applied_lsn: Option<u64>,
25 pub remote_lsn: u64,
26 pub servable_lsn: Option<u64>,
31 pub unarchived_lsn: Option<u64>,
34 pub lag_lsn: Option<u64>,
35 pub lag_bytes: Option<u64>,
37 pub lag_ms: Option<u64>,
39 pub stale: bool,
40 pub repair_action: SyncRepairAction,
41 pub last_sync_error: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ReplicaApplyAckSummary {
46 pub replica_id: String,
47 pub previous_applied_lsn: u64,
48 pub applied_lsn: u64,
49 pub remote_lsn: u64,
50 pub advanced: bool,
51 pub status: ReplicaSyncStatus,
52}
53
54pub fn acknowledge_replica_apply(
63 data_dir: &Path,
64 replica_id: &str,
65 applied_lsn: u64,
66 remote_lsn: u64,
67) -> io::Result<ReplicaApplyAckSummary> {
68 acknowledge_replica_apply_at(
69 data_dir,
70 replica_id,
71 applied_lsn,
72 remote_lsn,
73 now_unix_secs(),
74 )
75}
76
77pub fn replica_sync_status(
79 data_dir: &Path,
80 replica_id: &str,
81 remote_lsn: u64,
82) -> io::Result<ReplicaSyncStatus> {
83 replica_sync_status_at(data_dir, replica_id, remote_lsn, now_unix_secs())
84}
85
86fn acknowledge_replica_apply_at(
87 data_dir: &Path,
88 replica_id: &str,
89 applied_lsn: u64,
90 remote_lsn: u64,
91 now: u64,
92) -> io::Result<ReplicaApplyAckSummary> {
93 if applied_lsn > remote_lsn {
94 return Err(invalid_input(format!(
95 "replica acknowledgement LSN {applied_lsn} is ahead of remote LSN {remote_lsn}"
96 )));
97 }
98
99 with_cursor_metadata_lock(data_dir, || {
100 let mut cursors = read_replica_cursors_unlocked(data_dir)?;
101 let Some(cursor) = cursors
102 .iter_mut()
103 .find(|cursor| cursor.replica_id == replica_id)
104 else {
105 return Err(io::Error::new(
106 io::ErrorKind::NotFound,
107 "replica cursor not found; rebootstrap required",
108 ));
109 };
110 if !cursor.active {
111 return Err(invalid_input(
112 "replica cursor is inactive; rebootstrap required",
113 ));
114 }
115 if applied_lsn < cursor.applied_lsn {
116 return Err(invalid_input(format!(
117 "replica acknowledgement LSN {applied_lsn} is behind primary cursor LSN {}",
118 cursor.applied_lsn
119 )));
120 }
121
122 let previous_applied_lsn = cursor.applied_lsn;
123 let mut acknowledged = cursor.clone();
124 acknowledged.applied_lsn = applied_lsn;
125 acknowledged.updated_unix_secs = now;
126 let status = status_for_cursor(data_dir, replica_id, Some(&acknowledged), remote_lsn, now)?;
127 *cursor = acknowledged;
128 replace_replica_cursors_unlocked(data_dir, cursors)?;
129
130 Ok(ReplicaApplyAckSummary {
131 replica_id: replica_id.to_string(),
132 previous_applied_lsn,
133 applied_lsn,
134 remote_lsn,
135 advanced: applied_lsn > previous_applied_lsn,
136 status,
137 })
138 })
139}
140
141fn replica_sync_status_at(
142 data_dir: &Path,
143 replica_id: &str,
144 remote_lsn: u64,
145 now: u64,
146) -> io::Result<ReplicaSyncStatus> {
147 let cursors = read_replica_cursors_unlocked(data_dir)?;
148 let cursor = cursors
149 .iter()
150 .find(|cursor| cursor.replica_id == replica_id);
151 status_for_cursor(data_dir, replica_id, cursor, remote_lsn, now)
152}
153
154fn status_for_cursor(
155 data_dir: &Path,
156 replica_id: &str,
157 cursor: Option<&ReplicaCursor>,
158 remote_lsn: u64,
159 now: u64,
160) -> io::Result<ReplicaSyncStatus> {
161 let Some(cursor) = cursor else {
162 return Ok(ReplicaSyncStatus {
163 replica_id: replica_id.to_string(),
164 active: false,
165 last_applied_lsn: None,
166 remote_lsn,
167 servable_lsn: None,
168 unarchived_lsn: None,
169 lag_lsn: None,
170 lag_bytes: None,
171 lag_ms: None,
172 stale: true,
173 repair_action: SyncRepairAction::Rebootstrap,
174 last_sync_error: Some("replica cursor not found; rebootstrap required".to_string()),
175 });
176 };
177
178 let lag_lsn = remote_lsn.checked_sub(cursor.applied_lsn);
179 if !cursor.active {
180 return Ok(ReplicaSyncStatus {
181 replica_id: replica_id.to_string(),
182 active: false,
183 last_applied_lsn: Some(cursor.applied_lsn),
184 remote_lsn,
185 servable_lsn: None,
186 unarchived_lsn: None,
187 lag_lsn,
188 lag_bytes: None,
189 lag_ms: None,
190 stale: true,
191 repair_action: SyncRepairAction::Rebootstrap,
192 last_sync_error: Some("replica cursor is inactive; rebootstrap required".to_string()),
193 });
194 }
195 if cursor.applied_lsn > remote_lsn {
196 return Ok(ReplicaSyncStatus {
197 replica_id: replica_id.to_string(),
198 active: true,
199 last_applied_lsn: Some(cursor.applied_lsn),
200 remote_lsn,
201 servable_lsn: None,
202 unarchived_lsn: None,
203 lag_lsn: None,
204 lag_bytes: None,
205 lag_ms: None,
206 stale: true,
207 repair_action: SyncRepairAction::Rebootstrap,
208 last_sync_error: Some(format!(
209 "replica cursor LSN {} is ahead of remote LSN {remote_lsn}",
210 cursor.applied_lsn
211 )),
212 });
213 }
214 if cursor.applied_lsn == remote_lsn {
215 return Ok(ReplicaSyncStatus {
216 replica_id: replica_id.to_string(),
217 active: true,
218 last_applied_lsn: Some(cursor.applied_lsn),
219 remote_lsn,
220 servable_lsn: Some(remote_lsn),
221 unarchived_lsn: Some(0),
222 lag_lsn: Some(0),
223 lag_bytes: Some(0),
224 lag_ms: Some(0),
225 stale: false,
226 repair_action: SyncRepairAction::None,
227 last_sync_error: None,
228 });
229 }
230
231 let lag_ms = now
232 .saturating_sub(cursor.updated_unix_secs)
233 .saturating_mul(1000);
234 let identity = read_identity(data_dir)?;
235 let segment_dir = retained_segments_dir(data_dir);
236 let progress = retained_tail_progress(
237 &segment_dir,
238 identity.segment_identity(),
239 cursor.applied_lsn,
240 remote_lsn,
241 );
242
243 match progress {
244 Ok(progress) => {
245 let servable_lsn = progress.contiguous_through_lsn;
246 let unarchived_lsn = remote_lsn.saturating_sub(servable_lsn);
247 let (repair_action, last_sync_error) = if servable_lsn > cursor.applied_lsn {
248 (SyncRepairAction::Pull, None)
249 } else {
250 (
251 SyncRepairAction::AwaitArchive,
252 Some("primary has advanced, but retained history is not yet archived; retry after archive".to_string()),
253 )
254 };
255 Ok(ReplicaSyncStatus {
256 replica_id: replica_id.to_string(),
257 active: true,
258 last_applied_lsn: Some(cursor.applied_lsn),
259 remote_lsn,
260 servable_lsn: Some(servable_lsn),
261 unarchived_lsn: Some(unarchived_lsn),
262 lag_lsn,
263 lag_bytes: Some(retained_lag_bytes(
264 &segment_dir,
265 cursor.applied_lsn,
266 servable_lsn,
267 )?),
268 lag_ms: Some(lag_ms),
269 stale: true,
270 repair_action,
271 last_sync_error,
272 })
273 }
274 Err(err)
275 if err.kind() == io::ErrorKind::InvalidData
276 && (err.to_string().contains("gap") || err.to_string().contains("missing")) =>
277 {
278 Ok(ReplicaSyncStatus {
279 replica_id: replica_id.to_string(),
280 active: true,
281 last_applied_lsn: Some(cursor.applied_lsn),
282 remote_lsn,
283 servable_lsn: None,
284 unarchived_lsn: None,
285 lag_lsn,
286 lag_bytes: None,
287 lag_ms: Some(lag_ms),
288 stale: true,
289 repair_action: SyncRepairAction::Rebootstrap,
290 last_sync_error: Some(format!(
291 "retained history is unavailable; rebootstrap required: {err}"
292 )),
293 })
294 }
295 Err(err) => Err(err),
296 }
297}
298
299fn retained_lag_bytes(dir: &Path, applied_lsn: u64, remote_lsn: u64) -> io::Result<u64> {
300 let mut total = 0u64;
301 for file in list_segment_files(dir)? {
302 if file.end_lsn <= applied_lsn || file.start_lsn > remote_lsn {
303 continue;
304 }
305 let len = fs::metadata(file.path)?.len();
306 total = total
307 .checked_add(len)
308 .ok_or_else(|| io::Error::other("retained lag byte count overflow"))?;
309 }
310 Ok(total)
311}
312
313fn invalid_input(message: impl Into<String>) -> io::Error {
314 io::Error::new(io::ErrorKind::InvalidInput, message.into())
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::{
321 minimum_retained_lsn, read_replica_cursors, write_identity_snapshot, write_segment_atomic,
322 DatabaseIdentity, IdentitySnapshot, RetainedSegment, RetainedUnit,
323 };
324
325 fn identity() -> DatabaseIdentity {
326 DatabaseIdentity {
327 database_id: *b"replica-status!!",
328 primary_generation: 1,
329 }
330 }
331
332 fn unit(lsn: u64) -> RetainedUnit {
333 RetainedUnit {
334 tx_id: 1,
335 record_type: 4,
336 lsn,
337 data: lsn.to_le_bytes().to_vec(),
338 }
339 }
340
341 fn write_identity(data_dir: &Path) {
342 let snapshot = IdentitySnapshot::from_identity(identity(), 1);
343 write_identity_snapshot(data_dir, &snapshot).unwrap();
344 }
345
346 fn write_segment(data_dir: &Path, start_lsn: u64, end_lsn: u64) {
347 let units = (start_lsn..=end_lsn).map(unit).collect();
348 let segment = RetainedSegment::new(identity().segment_identity(), units).unwrap();
349 write_segment_atomic(&retained_segments_dir(data_dir), &segment).unwrap();
350 }
351
352 fn active_cursor(replica_id: &str, applied_lsn: u64, updated_unix_secs: u64) -> ReplicaCursor {
353 ReplicaCursor {
354 replica_id: replica_id.to_string(),
355 applied_lsn,
356 updated_unix_secs,
357 active: true,
358 }
359 }
360
361 #[test]
362 fn acknowledgement_advances_cursor_and_clears_lag() {
363 let dir = tempfile::tempdir().unwrap();
364 write_identity(dir.path());
365 write_segment(dir.path(), 1, 5);
366 write_segment(dir.path(), 6, 10);
367 crate::upsert_replica_cursor(dir.path(), ReplicaCursor::active("replica-a", 0)).unwrap();
368
369 let ack = acknowledge_replica_apply_at(dir.path(), "replica-a", 10, 10, 200).unwrap();
370
371 assert_eq!(ack.previous_applied_lsn, 0);
372 assert_eq!(ack.applied_lsn, 10);
373 assert!(ack.advanced);
374 assert_eq!(ack.status.repair_action, SyncRepairAction::None);
375 assert!(!ack.status.stale);
376 assert_eq!(ack.status.lag_lsn, Some(0));
377 assert_eq!(ack.status.lag_bytes, Some(0));
378 assert_eq!(minimum_retained_lsn(dir.path()).unwrap(), Some(11));
379
380 let cursor = read_replica_cursors(dir.path())
381 .unwrap()
382 .into_iter()
383 .find(|cursor| cursor.replica_id == "replica-a")
384 .unwrap();
385 assert_eq!(cursor.applied_lsn, 10);
386 assert_eq!(cursor.updated_unix_secs, 200);
387 }
388
389 #[test]
390 fn stale_status_reports_pull_with_lag_bytes() {
391 let dir = tempfile::tempdir().unwrap();
392 write_identity(dir.path());
393 write_segment(dir.path(), 1, 5);
394 write_segment(dir.path(), 6, 10);
395 crate::upsert_replica_cursor(dir.path(), active_cursor("replica-a", 5, 100)).unwrap();
396
397 let status = replica_sync_status_at(dir.path(), "replica-a", 10, 500).unwrap();
398
399 assert!(status.stale);
400 assert_eq!(status.repair_action, SyncRepairAction::Pull);
401 assert_eq!(status.servable_lsn, Some(10));
402 assert_eq!(status.unarchived_lsn, Some(0));
403 assert_eq!(status.lag_lsn, Some(5));
404 assert!(status.lag_bytes.unwrap() > 0);
405 assert!(status.lag_ms.unwrap() > 0);
406 assert_eq!(status.last_sync_error, None);
407 }
408
409 #[test]
410 fn stale_status_reports_pull_for_contiguous_archived_prefix() {
411 let dir = tempfile::tempdir().unwrap();
412 write_identity(dir.path());
413 write_segment(dir.path(), 1, 8);
414 crate::upsert_replica_cursor(dir.path(), active_cursor("replica-a", 5, 100)).unwrap();
415
416 let status = replica_sync_status_at(dir.path(), "replica-a", 10, 500).unwrap();
417
418 assert!(status.stale);
419 assert_eq!(status.repair_action, SyncRepairAction::Pull);
420 assert_eq!(status.servable_lsn, Some(8));
421 assert_eq!(status.unarchived_lsn, Some(2));
422 assert_eq!(status.lag_lsn, Some(5));
423 assert!(status.lag_bytes.unwrap() > 0);
424 assert_eq!(status.last_sync_error, None);
425 }
426
427 #[test]
428 fn stale_status_reports_await_archive_when_primary_is_ahead_of_retained_tail() {
429 let dir = tempfile::tempdir().unwrap();
430 write_identity(dir.path());
431 crate::upsert_replica_cursor(dir.path(), active_cursor("replica-a", 5, 100)).unwrap();
432
433 let status = replica_sync_status_at(dir.path(), "replica-a", 10, 500).unwrap();
434
435 assert!(status.stale);
436 assert_eq!(status.repair_action, SyncRepairAction::AwaitArchive);
437 assert_eq!(status.servable_lsn, Some(5));
438 assert_eq!(status.unarchived_lsn, Some(5));
439 assert_eq!(status.lag_lsn, Some(5));
440 assert_eq!(status.lag_bytes, Some(0));
441 assert!(status.last_sync_error.unwrap().contains("not yet archived"));
442 }
443
444 #[test]
445 fn missing_retained_tail_reports_rebootstrap() {
446 let dir = tempfile::tempdir().unwrap();
447 write_identity(dir.path());
448 crate::upsert_replica_cursor(dir.path(), active_cursor("replica-a", 5, 100)).unwrap();
449 write_segment(dir.path(), 8, 10);
450
451 let status = replica_sync_status_at(dir.path(), "replica-a", 10, 500).unwrap();
452
453 assert!(status.stale);
454 assert_eq!(status.repair_action, SyncRepairAction::Rebootstrap);
455 assert_eq!(status.servable_lsn, None);
456 assert_eq!(status.unarchived_lsn, None);
457 assert_eq!(status.lag_bytes, None);
458 assert!(status
459 .last_sync_error
460 .unwrap()
461 .contains("rebootstrap required"));
462 }
463
464 #[test]
465 fn acknowledgement_rejects_stale_and_inactive_cursors() {
466 let dir = tempfile::tempdir().unwrap();
467 write_identity(dir.path());
468 write_segment(dir.path(), 1, 10);
469 crate::upsert_replica_cursor(dir.path(), ReplicaCursor::active("replica-a", 5)).unwrap();
470
471 let err = acknowledge_replica_apply_at(dir.path(), "replica-a", 4, 10, 100).unwrap_err();
472 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
473 assert!(err.to_string().contains("behind primary cursor"));
474
475 crate::retire_replica_cursor(dir.path(), "replica-a", 101).unwrap();
476 let err = acknowledge_replica_apply_at(dir.path(), "replica-a", 6, 10, 102).unwrap_err();
477 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
478 assert!(err.to_string().contains("rebootstrap required"));
479 }
480
481 #[test]
482 fn acknowledgement_status_error_leaves_cursor_unchanged() {
483 let dir = tempfile::tempdir().unwrap();
484 write_identity(dir.path());
485 write_segment(dir.path(), 1, 10);
486 crate::upsert_replica_cursor(dir.path(), active_cursor("replica-a", 5, 100)).unwrap();
487 fs::remove_file(crate::sync_state_dir(dir.path()).join(crate::IDENTITY_FILE)).unwrap();
488
489 let err = acknowledge_replica_apply_at(dir.path(), "replica-a", 6, 10, 200).unwrap_err();
490
491 assert_eq!(err.kind(), io::ErrorKind::NotFound);
492 let cursor = read_replica_cursors(dir.path())
493 .unwrap()
494 .into_iter()
495 .find(|cursor| cursor.replica_id == "replica-a")
496 .unwrap();
497 assert_eq!(cursor.applied_lsn, 5);
498 assert_eq!(cursor.updated_unix_secs, 100);
499 }
500}