1use std::collections::HashSet;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use powdb_storage::create_data_dir_secure;
9use serde::{Deserialize, Serialize};
10
11use crate::segment::{read_units_since, SegmentIdentity};
12
13pub const SYNC_STATE_DIR: &str = ".powdb-sync";
14pub const IDENTITY_FILE: &str = "identity.json";
15pub const REPLICA_CURSORS_FILE: &str = "replica-cursors.json";
16pub const SYNC_METADATA_FORMAT_VERSION: u32 = 1;
17
18static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
19const CURSOR_LOCK_FILE: &str = ".replica-cursors.lock";
20const CURSOR_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
33const CURSOR_LOCK_RETRY: Duration = Duration::from_millis(5);
36const CURSOR_LOCK_RETRY_MAX: Duration = Duration::from_millis(50);
37const CURSOR_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct DatabaseIdentity {
41 pub database_id: [u8; 16],
42 pub primary_generation: u64,
43}
44
45impl DatabaseIdentity {
46 pub fn segment_identity(self) -> SegmentIdentity {
47 SegmentIdentity::current(self.database_id, self.primary_generation)
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ReplicaCursor {
53 pub replica_id: String,
54 pub applied_lsn: u64,
55 pub updated_unix_secs: u64,
56 pub active: bool,
57}
58
59impl ReplicaCursor {
60 pub fn active(replica_id: impl Into<String>, applied_lsn: u64) -> Self {
61 Self {
62 replica_id: replica_id.into(),
63 applied_lsn,
64 updated_unix_secs: now_unix_secs(),
65 active: true,
66 }
67 }
68
69 pub fn next_required_lsn(&self) -> io::Result<u64> {
70 self.applied_lsn
71 .checked_add(1)
72 .ok_or_else(|| invalid_data("replica cursor LSN overflow"))
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct IdentitySnapshot {
78 pub format_version: u32,
79 pub database_id: String,
80 pub primary_generation: u64,
81 pub created_unix_secs: u64,
82}
83
84impl IdentitySnapshot {
85 pub fn from_identity(identity: DatabaseIdentity, created_unix_secs: u64) -> Self {
86 Self {
87 format_version: SYNC_METADATA_FORMAT_VERSION,
88 database_id: encode_hex_16(identity.database_id),
89 primary_generation: identity.primary_generation,
90 created_unix_secs,
91 }
92 }
93
94 pub fn identity(&self) -> io::Result<DatabaseIdentity> {
95 if self.format_version != SYNC_METADATA_FORMAT_VERSION {
96 return Err(invalid_data(format!(
97 "unsupported sync identity format {}",
98 self.format_version
99 )));
100 }
101 let identity = DatabaseIdentity {
102 database_id: decode_hex_16(&self.database_id)?,
103 primary_generation: self.primary_generation,
104 };
105 validate_identity(identity)?;
106 Ok(identity)
107 }
108
109 pub fn validate(&self) -> io::Result<()> {
110 self.identity().map(|_| ())
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115struct CursorFile {
116 format_version: u32,
117 cursors: Vec<ReplicaCursor>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121struct CursorLockFile {
122 format_version: u32,
123 owner_pid: u32,
124 created_unix_secs: u64,
125}
126
127pub fn sync_state_dir(data_dir: &Path) -> PathBuf {
128 data_dir.join(SYNC_STATE_DIR)
129}
130
131pub fn open_or_create_identity(data_dir: &Path) -> io::Result<DatabaseIdentity> {
132 let state_dir = sync_state_dir(data_dir);
133 create_data_dir_secure(&state_dir)?;
134
135 match read_identity(data_dir) {
136 Ok(identity) => return Ok(identity),
137 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
138 Err(err) => return Err(err),
139 }
140
141 let identity = DatabaseIdentity {
142 database_id: generate_database_id()?,
143 primary_generation: 1,
144 };
145 let file = IdentitySnapshot::from_identity(identity, now_unix_secs());
146
147 match write_new_identity_file(&state_dir, &file) {
148 Ok(()) => Ok(identity),
149 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => read_identity(data_dir),
150 Err(err) => Err(err),
151 }
152}
153
154pub fn read_identity(data_dir: &Path) -> io::Result<DatabaseIdentity> {
155 let Some(snapshot) = read_identity_snapshot(data_dir)? else {
156 return Err(io::Error::new(
157 io::ErrorKind::NotFound,
158 "sync identity not found",
159 ));
160 };
161 snapshot.identity()
162}
163
164pub fn read_identity_snapshot(data_dir: &Path) -> io::Result<Option<IdentitySnapshot>> {
165 let bytes = fs::read(sync_state_dir(data_dir).join(IDENTITY_FILE))?;
166 let snapshot: IdentitySnapshot = serde_json::from_slice(&bytes).map_err(invalid_data)?;
167 snapshot.validate()?;
168 Ok(Some(snapshot))
169}
170
171pub fn read_identity_snapshot_if_exists(data_dir: &Path) -> io::Result<Option<IdentitySnapshot>> {
172 match read_identity_snapshot(data_dir) {
173 Ok(snapshot) => Ok(snapshot),
174 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
175 Err(err) => Err(err),
176 }
177}
178
179pub fn write_identity_snapshot(data_dir: &Path, snapshot: &IdentitySnapshot) -> io::Result<()> {
180 snapshot.validate()?;
181 let state_dir = sync_state_dir(data_dir);
182 create_data_dir_secure(&state_dir)?;
183 match write_new_identity_file(&state_dir, snapshot) {
184 Ok(()) => Ok(()),
185 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
186 let Some(existing) = read_identity_snapshot(data_dir)? else {
187 return Err(io::Error::new(
188 io::ErrorKind::AlreadyExists,
189 "sync identity already exists but could not be read",
190 ));
191 };
192 if existing == *snapshot {
193 Ok(())
194 } else {
195 Err(io::Error::new(
196 io::ErrorKind::AlreadyExists,
197 "sync identity already exists with different database history",
198 ))
199 }
200 }
201 Err(err) => Err(err),
202 }
203}
204
205pub fn read_replica_cursors(data_dir: &Path) -> io::Result<Vec<ReplicaCursor>> {
206 read_replica_cursors_unlocked(data_dir)
207}
208
209pub(crate) fn read_replica_cursors_unlocked(data_dir: &Path) -> io::Result<Vec<ReplicaCursor>> {
210 let path = sync_state_dir(data_dir).join(REPLICA_CURSORS_FILE);
211 let bytes = match fs::read(path) {
212 Ok(bytes) => bytes,
213 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
214 Err(err) => return Err(err),
215 };
216 let file: CursorFile = serde_json::from_slice(&bytes).map_err(invalid_data)?;
217 validate_cursor_file(&file)?;
218 Ok(file.cursors)
219}
220
221pub fn write_replica_cursors(data_dir: &Path, mut cursors: Vec<ReplicaCursor>) -> io::Result<()> {
222 validate_cursors_for_write(&cursors)?;
223 cursors.sort_by(|a, b| a.replica_id.cmp(&b.replica_id));
224 let state_dir = sync_state_dir(data_dir);
225 create_data_dir_secure(&state_dir)?;
226 let _lock = acquire_cursor_lock(&state_dir)?;
227 validate_active_cursors_against_retained_tail(data_dir, &cursors)?;
228 write_replica_cursors_unlocked(&state_dir, cursors)
229}
230
231fn write_replica_cursors_unlocked(state_dir: &Path, cursors: Vec<ReplicaCursor>) -> io::Result<()> {
232 let file = CursorFile {
233 format_version: SYNC_METADATA_FORMAT_VERSION,
234 cursors,
235 };
236 atomic_replace_json(state_dir, REPLICA_CURSORS_FILE, &file)
237}
238
239pub(crate) fn replace_replica_cursors_unlocked(
240 data_dir: &Path,
241 mut cursors: Vec<ReplicaCursor>,
242) -> io::Result<()> {
243 validate_cursors_for_write(&cursors)?;
244 cursors.sort_by(|a, b| a.replica_id.cmp(&b.replica_id));
245 let state_dir = sync_state_dir(data_dir);
246 create_data_dir_secure(&state_dir)?;
247 write_replica_cursors_unlocked(&state_dir, cursors)
248}
249
250pub fn upsert_replica_cursor(data_dir: &Path, cursor: ReplicaCursor) -> io::Result<()> {
251 validate_cursor_for_write(&cursor)?;
252 let state_dir = sync_state_dir(data_dir);
253 create_data_dir_secure(&state_dir)?;
254 let _lock = acquire_cursor_lock(&state_dir)?;
255 validate_active_cursor_against_retained_tail(data_dir, &cursor)?;
256 let mut cursors = read_replica_cursors_unlocked(data_dir)?;
257 if let Some(existing) = cursors
258 .iter_mut()
259 .find(|existing| existing.replica_id == cursor.replica_id)
260 {
261 *existing = cursor;
262 } else {
263 cursors.push(cursor);
264 }
265 validate_cursors_for_write(&cursors)?;
266 cursors.sort_by(|a, b| a.replica_id.cmp(&b.replica_id));
267 write_replica_cursors_unlocked(&state_dir, cursors)
268}
269
270pub fn register_bootstrap_cursor(
271 data_dir: &Path,
272 replica_id: &str,
273 snapshot_lsn: u64,
274 required_through_lsn: u64,
275) -> io::Result<ReplicaCursor> {
276 if required_through_lsn < snapshot_lsn {
277 return Err(invalid_input(format!(
278 "remote LSN {required_through_lsn} is behind snapshot LSN {snapshot_lsn}"
279 )));
280 }
281 validate_replica_id(replica_id).map_err(invalid_input)?;
282 let cursor = ReplicaCursor::active(replica_id, snapshot_lsn);
283 validate_cursor_for_write(&cursor)?;
284
285 let state_dir = sync_state_dir(data_dir);
286 create_data_dir_secure(&state_dir)?;
287 let _lock = acquire_cursor_lock(&state_dir)?;
288
289 let identity = read_identity(data_dir)?;
290 crate::validate_retained_tail_available(
291 &crate::retained_segments_dir(data_dir),
292 identity.segment_identity(),
293 snapshot_lsn,
294 required_through_lsn,
295 )
296 .map_err(|err| {
297 if matches!(err.kind(), io::ErrorKind::InvalidData)
298 && (err.to_string().contains("gap") || err.to_string().contains("missing"))
299 {
300 io::Error::new(
301 io::ErrorKind::InvalidInput,
302 format!("retained history is unavailable; rebootstrap required: {err}"),
303 )
304 } else {
305 err
306 }
307 })?;
308
309 let mut cursors = read_replica_cursors_unlocked(data_dir)?;
310 if cursors
311 .iter()
312 .any(|existing| existing.replica_id == replica_id && existing.active)
313 {
314 return Err(io::Error::new(
315 io::ErrorKind::AlreadyExists,
316 "replica cursor is already active; retire it before bootstrap",
317 ));
318 }
319 if let Some(existing) = cursors
320 .iter_mut()
321 .find(|existing| existing.replica_id == replica_id)
322 {
323 *existing = cursor.clone();
324 } else {
325 cursors.push(cursor.clone());
326 }
327 validate_cursors_for_write(&cursors)?;
328 cursors.sort_by(|a, b| a.replica_id.cmp(&b.replica_id));
329 write_replica_cursors_unlocked(&state_dir, cursors)?;
330 Ok(cursor)
331}
332
333pub(crate) fn with_cursor_metadata_lock<T>(
334 data_dir: &Path,
335 f: impl FnOnce() -> io::Result<T>,
336) -> io::Result<T> {
337 let state_dir = sync_state_dir(data_dir);
338 create_data_dir_secure(&state_dir)?;
339 let _lock = acquire_cursor_lock(&state_dir)?;
340 f()
341}
342
343pub fn retire_replica_cursor(
344 data_dir: &Path,
345 replica_id: &str,
346 updated_unix_secs: u64,
347) -> io::Result<()> {
348 validate_replica_id(replica_id).map_err(invalid_input)?;
349 let state_dir = sync_state_dir(data_dir);
350 create_data_dir_secure(&state_dir)?;
351 let _lock = acquire_cursor_lock(&state_dir)?;
352 let mut cursors = read_replica_cursors_unlocked(data_dir)?;
353 let Some(cursor) = cursors
354 .iter_mut()
355 .find(|cursor| cursor.replica_id == replica_id)
356 else {
357 return Err(io::Error::new(
358 io::ErrorKind::NotFound,
359 "replica cursor not found",
360 ));
361 };
362 cursor.active = false;
363 cursor.updated_unix_secs = updated_unix_secs;
364 write_replica_cursors_unlocked(&state_dir, cursors)
365}
366
367pub fn minimum_retained_lsn(data_dir: &Path) -> io::Result<Option<u64>> {
374 let mut min_lsn: Option<u64> = None;
375 for cursor in read_replica_cursors(data_dir)? {
376 if !cursor.active {
377 continue;
378 }
379 let next_lsn = cursor.next_required_lsn()?;
380 min_lsn = Some(match min_lsn {
381 Some(current) => current.min(next_lsn),
382 None => next_lsn,
383 });
384 }
385 Ok(min_lsn)
386}
387
388fn write_new_identity_file(state_dir: &Path, file: &IdentitySnapshot) -> io::Result<()> {
389 let final_path = state_dir.join(IDENTITY_FILE);
390 let temp_path = temp_path(state_dir, IDENTITY_FILE);
391 let bytes = serde_json::to_vec_pretty(file).map_err(io::Error::other)?;
392 let result: io::Result<()> = (|| {
393 let mut temp = OpenOptions::new()
394 .write(true)
395 .create_new(true)
396 .open(&temp_path)?;
397 temp.write_all(&bytes)?;
398 temp.sync_all()?;
399 drop(temp);
400 fs::hard_link(&temp_path, &final_path)?;
401 fsync_dir(state_dir)?;
402 let _ = fs::remove_file(&temp_path);
403 let _ = fsync_dir(state_dir);
404 Ok(())
405 })();
406 if result.is_err() {
407 let _ = fs::remove_file(&temp_path);
408 }
409 result
410}
411
412pub(crate) fn atomic_replace_json<T: Serialize>(
413 state_dir: &Path,
414 file_name: &str,
415 value: &T,
416) -> io::Result<()> {
417 let final_path = state_dir.join(file_name);
418 let temp_path = temp_path(state_dir, file_name);
419 let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?;
420 let result: io::Result<()> = (|| {
421 let mut temp = OpenOptions::new()
422 .write(true)
423 .create_new(true)
424 .open(&temp_path)?;
425 temp.write_all(&bytes)?;
426 temp.sync_all()?;
427 drop(temp);
428 fs::rename(&temp_path, &final_path)?;
429 fsync_dir(state_dir)?;
430 Ok(())
431 })();
432 if result.is_err() {
433 let _ = fs::remove_file(&temp_path);
434 }
435 result
436}
437
438struct CursorLock {
439 path: PathBuf,
440 dir: PathBuf,
441}
442
443impl Drop for CursorLock {
444 fn drop(&mut self) {
445 let _ = fs::remove_file(&self.path);
446 let _ = fsync_dir(&self.dir);
447 }
448}
449
450fn cursor_lock_backoff(attempt: u32) -> Duration {
461 let step = CURSOR_LOCK_RETRY
462 .saturating_mul(1u32 << attempt.min(5))
463 .min(CURSOR_LOCK_RETRY_MAX);
464 let spread = SystemTime::now()
468 .duration_since(UNIX_EPOCH)
469 .map(|d| u64::from(d.subsec_nanos()))
470 .unwrap_or(0);
471 let half = step.as_nanos() as u64 / 2;
472 let jitter = if half == 0 { 0 } else { spread % half };
473 step - Duration::from_nanos(jitter)
474}
475
476fn acquire_cursor_lock(state_dir: &Path) -> io::Result<CursorLock> {
477 let path = state_dir.join(CURSOR_LOCK_FILE);
478 let start = Instant::now();
479 let mut attempt: u32 = 0;
480 loop {
481 match OpenOptions::new().write(true).create_new(true).open(&path) {
482 Ok(mut file) => {
483 let result: io::Result<()> = (|| {
484 let owner = CursorLockFile {
485 format_version: SYNC_METADATA_FORMAT_VERSION,
486 owner_pid: std::process::id(),
487 created_unix_secs: now_unix_secs(),
488 };
489 let bytes = serde_json::to_vec_pretty(&owner).map_err(io::Error::other)?;
490 file.write_all(&bytes)?;
491 file.write_all(b"\n")?;
492 file.sync_all()?;
493 fsync_dir(state_dir)
494 })();
495 if let Err(err) = result {
496 let _ = fs::remove_file(&path);
497 let _ = fsync_dir(state_dir);
498 return Err(err);
499 }
500 return Ok(CursorLock {
501 path,
502 dir: state_dir.to_path_buf(),
503 });
504 }
505 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
506 if reclaim_stale_cursor_lock(&path, state_dir)? {
507 continue;
508 }
509 if start.elapsed() >= CURSOR_LOCK_TIMEOUT {
510 return Err(io::Error::new(
511 io::ErrorKind::WouldBlock,
512 "timed out waiting for sync cursor metadata lock",
513 ));
514 }
515 std::thread::sleep(cursor_lock_backoff(attempt));
516 attempt = attempt.saturating_add(1);
517 }
518 Err(err) => return Err(err),
519 }
520 }
521}
522
523fn reclaim_stale_cursor_lock(path: &Path, state_dir: &Path) -> io::Result<bool> {
524 let bytes = match fs::read(path) {
525 Ok(bytes) => bytes,
526 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(true),
527 Err(err) => return Err(err),
528 };
529 let stale = match serde_json::from_slice::<CursorLockFile>(&bytes) {
530 Ok(owner) => cursor_lock_owner_stale(&owner),
531 Err(_) => lock_file_age(path)?
532 .map(|age| age >= CURSOR_LOCK_STALE_AFTER)
533 .unwrap_or(false),
534 };
535 if !stale {
536 return Ok(false);
537 }
538 match fs::remove_file(path) {
539 Ok(()) => {
540 fsync_dir(state_dir)?;
541 Ok(true)
542 }
543 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(true),
544 Err(err) => Err(err),
545 }
546}
547
548fn cursor_lock_owner_stale(owner: &CursorLockFile) -> bool {
549 if owner.format_version != SYNC_METADATA_FORMAT_VERSION {
550 return lock_owner_age(owner)
551 .map(|age| age >= CURSOR_LOCK_STALE_AFTER)
552 .unwrap_or(false);
553 }
554 if owner.owner_pid == 0 {
555 return true;
556 }
557 if !owner_process_alive(owner.owner_pid) {
558 return true;
559 }
560 lock_owner_stale_without_process_probe(owner)
561}
562
563#[cfg(unix)]
564fn lock_owner_stale_without_process_probe(_owner: &CursorLockFile) -> bool {
565 false
566}
567
568#[cfg(not(unix))]
569fn lock_owner_stale_without_process_probe(owner: &CursorLockFile) -> bool {
570 lock_owner_age(owner)
571 .map(|age| age >= CURSOR_LOCK_STALE_AFTER)
572 .unwrap_or(false)
573}
574
575#[cfg(unix)]
576fn owner_process_alive(pid: u32) -> bool {
577 if pid > libc::pid_t::MAX as u32 {
578 return false;
579 }
580 let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
581 if rc == 0 {
582 return true;
583 }
584 let err = io::Error::last_os_error();
585 err.raw_os_error() != Some(libc::ESRCH)
586}
587
588#[cfg(not(unix))]
589fn owner_process_alive(_pid: u32) -> bool {
590 true
591}
592
593fn lock_owner_age(owner: &CursorLockFile) -> Option<Duration> {
594 Some(Duration::from_secs(
595 now_unix_secs().checked_sub(owner.created_unix_secs)?,
596 ))
597}
598
599fn lock_file_age(path: &Path) -> io::Result<Option<Duration>> {
600 let modified = fs::metadata(path)?.modified()?;
601 Ok(SystemTime::now().duration_since(modified).ok())
602}
603
604fn validate_identity(identity: DatabaseIdentity) -> io::Result<()> {
605 if identity.primary_generation == 0 {
606 return Err(invalid_data("primary generation must be non-zero"));
607 }
608 if identity.database_id == [0; 16] {
609 return Err(invalid_data("database id must be non-zero"));
610 }
611 Ok(())
612}
613
614fn validate_cursor_file(file: &CursorFile) -> io::Result<()> {
615 if file.format_version != SYNC_METADATA_FORMAT_VERSION {
616 return Err(invalid_data(format!(
617 "unsupported sync cursor format {}",
618 file.format_version
619 )));
620 }
621 validate_cursors(&file.cursors, io::ErrorKind::InvalidData)
622}
623
624fn validate_cursors_for_write(cursors: &[ReplicaCursor]) -> io::Result<()> {
625 validate_cursors(cursors, io::ErrorKind::InvalidInput)
626}
627
628fn validate_cursor_for_write(cursor: &ReplicaCursor) -> io::Result<()> {
629 validate_replica_id(&cursor.replica_id).map_err(invalid_input)?;
630 cursor
631 .next_required_lsn()
632 .map_err(|err| invalid_input(err.to_string()))?;
633 Ok(())
634}
635
636fn validate_active_cursors_against_retained_tail(
637 data_dir: &Path,
638 cursors: &[ReplicaCursor],
639) -> io::Result<()> {
640 for cursor in cursors {
641 validate_active_cursor_against_retained_tail(data_dir, cursor)?;
642 }
643 Ok(())
644}
645
646fn validate_active_cursor_against_retained_tail(
647 data_dir: &Path,
648 cursor: &ReplicaCursor,
649) -> io::Result<()> {
650 if !cursor.active {
651 return Ok(());
652 }
653 let identity = match read_identity(data_dir) {
654 Ok(identity) => identity,
655 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
656 Err(err) => return Err(err),
657 };
658 let segment_dir = crate::retained_segments_dir(data_dir);
659 if crate::list_segment_files(&segment_dir)?.is_empty() {
660 return Ok(());
661 }
662 match read_units_since(
663 &segment_dir,
664 identity.segment_identity(),
665 cursor.applied_lsn,
666 1,
667 ) {
668 Ok(_) => Ok(()),
669 Err(err) if err.to_string().contains("gap") => Err(io::Error::new(
670 io::ErrorKind::InvalidInput,
671 "replica cursor is behind retained history; rebootstrap required",
672 )),
673 Err(err) => Err(err),
674 }
675}
676
677fn validate_cursors(cursors: &[ReplicaCursor], kind: io::ErrorKind) -> io::Result<()> {
678 let mut seen = HashSet::new();
679 for cursor in cursors {
680 validate_replica_id(&cursor.replica_id)
681 .map_err(|err| io::Error::new(kind, err.to_string()))?;
682 cursor
683 .next_required_lsn()
684 .map_err(|err| io::Error::new(kind, err.to_string()))?;
685 if !seen.insert(cursor.replica_id.as_str()) {
686 return Err(io::Error::new(kind, "duplicate replica cursor id"));
687 }
688 }
689 Ok(())
690}
691
692fn validate_replica_id(replica_id: &str) -> Result<(), &'static str> {
693 if replica_id.is_empty() {
694 return Err("replica id must be non-empty");
695 }
696 if replica_id.len() > 128 {
697 return Err("replica id must be at most 128 bytes");
698 }
699 if !replica_id
700 .bytes()
701 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b':'))
702 {
703 return Err("replica id contains unsupported characters");
704 }
705 Ok(())
706}
707
708fn generate_database_id() -> io::Result<[u8; 16]> {
709 let mut id = [0u8; 16];
710 getrandom::fill(&mut id).map_err(|err| {
711 io::Error::other(format!(
712 "secure OS random source failed while creating sync database id: {err}"
713 ))
714 })?;
715 if id == [0; 16] {
716 return Err(io::Error::other(
717 "secure OS random source returned an all-zero sync database id",
718 ));
719 }
720 Ok(id)
721}
722
723fn encode_hex_16(bytes: [u8; 16]) -> String {
724 let mut out = String::with_capacity(32);
725 for byte in bytes {
726 out.push(hex_digit(byte >> 4));
727 out.push(hex_digit(byte & 0x0f));
728 }
729 out
730}
731
732fn decode_hex_16(s: &str) -> io::Result<[u8; 16]> {
733 if s.len() != 32 {
734 return Err(invalid_data("database id must be 32 hex characters"));
735 }
736 let mut out = [0u8; 16];
737 let bytes = s.as_bytes();
738 for i in 0..16 {
739 let hi = hex_value(bytes[i * 2])?;
740 let lo = hex_value(bytes[i * 2 + 1])?;
741 out[i] = (hi << 4) | lo;
742 }
743 Ok(out)
744}
745
746fn hex_digit(nibble: u8) -> char {
747 match nibble {
748 0..=9 => (b'0' + nibble) as char,
749 10..=15 => (b'a' + (nibble - 10)) as char,
750 _ => unreachable!("nibble is always four bits"),
751 }
752}
753
754fn hex_value(byte: u8) -> io::Result<u8> {
755 match byte {
756 b'0'..=b'9' => Ok(byte - b'0'),
757 b'a'..=b'f' => Ok(byte - b'a' + 10),
758 b'A'..=b'F' => Ok(byte - b'A' + 10),
759 _ => Err(invalid_data("database id contains non-hex byte")),
760 }
761}
762
763fn temp_path(dir: &Path, file_name: &str) -> PathBuf {
764 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
765 dir.join(format!(
766 ".{file_name}.tmp.{}.{}.{}",
767 std::process::id(),
768 now_nanos(),
769 counter
770 ))
771}
772
773pub(crate) fn now_unix_secs() -> u64 {
774 SystemTime::now()
775 .duration_since(UNIX_EPOCH)
776 .map(|duration| duration.as_secs())
777 .unwrap_or(0)
778}
779
780fn now_nanos() -> u128 {
781 SystemTime::now()
782 .duration_since(UNIX_EPOCH)
783 .map(|duration| duration.as_nanos())
784 .unwrap_or(0)
785}
786
787#[cfg(unix)]
788fn fsync_dir(dir: &Path) -> io::Result<()> {
789 File::open(dir)?.sync_all()
790}
791
792#[cfg(not(unix))]
793fn fsync_dir(_dir: &Path) -> io::Result<()> {
794 Ok(())
795}
796
797fn invalid_input(message: impl ToString) -> io::Error {
798 crate::SyncError::InvalidRequest(message.to_string()).into()
799}
800
801fn invalid_data(message: impl ToString) -> io::Error {
802 crate::SyncError::CorruptState(message.to_string()).into()
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use std::sync::{Arc, Barrier};
809
810 #[test]
811 fn identity_is_created_once_and_reused() {
812 let dir = tempfile::tempdir().unwrap();
813 let first = open_or_create_identity(dir.path()).unwrap();
814 let second = open_or_create_identity(dir.path()).unwrap();
815
816 assert_eq!(first, second);
817 assert_ne!(first.database_id, [0; 16]);
818 assert_eq!(first.primary_generation, 1);
819 assert_eq!(read_identity(dir.path()).unwrap(), first);
820 assert_eq!(
821 first.segment_identity(),
822 SegmentIdentity::current(first.database_id, 1)
823 );
824 }
825
826 #[test]
827 fn concurrent_identity_creation_uses_one_winner() {
828 let dir = tempfile::tempdir().unwrap();
829 let barrier = Arc::new(Barrier::new(8));
830 let mut handles = Vec::new();
831
832 for _ in 0..8 {
833 let data_dir = dir.path().to_path_buf();
834 let barrier = Arc::clone(&barrier);
835 handles.push(std::thread::spawn(move || {
836 barrier.wait();
837 open_or_create_identity(&data_dir)
838 }));
839 }
840
841 let mut identities = Vec::new();
842 for handle in handles {
843 identities.push(handle.join().unwrap().unwrap());
844 }
845
846 for identity in &identities[1..] {
847 assert_eq!(*identity, identities[0]);
848 }
849 }
850
851 #[test]
852 fn corrupt_identity_fails_closed() {
853 let dir = tempfile::tempdir().unwrap();
854 let state_dir = sync_state_dir(dir.path());
855 create_data_dir_secure(&state_dir).unwrap();
856 fs::write(
857 state_dir.join(IDENTITY_FILE),
858 br#"{"format_version":1,"database_id":"not-hex","primary_generation":1,"created_unix_secs":1}"#,
859 )
860 .unwrap();
861
862 let err = read_identity(dir.path()).unwrap_err();
863 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
864 }
865
866 #[test]
867 fn cursors_round_trip_and_minimum_retained_lsn_uses_active_replicas() {
868 let dir = tempfile::tempdir().unwrap();
869 write_replica_cursors(
870 dir.path(),
871 vec![
872 ReplicaCursor {
873 replica_id: "replica-b".into(),
874 applied_lsn: 40,
875 updated_unix_secs: 2,
876 active: true,
877 },
878 ReplicaCursor {
879 replica_id: "replica-a".into(),
880 applied_lsn: 10,
881 updated_unix_secs: 1,
882 active: true,
883 },
884 ReplicaCursor {
885 replica_id: "replica-retired".into(),
886 applied_lsn: 1,
887 updated_unix_secs: 3,
888 active: false,
889 },
890 ],
891 )
892 .unwrap();
893
894 let cursors = read_replica_cursors(dir.path()).unwrap();
895 assert_eq!(cursors[0].replica_id, "replica-a");
896 assert_eq!(cursors[1].replica_id, "replica-b");
897 assert_eq!(minimum_retained_lsn(dir.path()).unwrap(), Some(11));
898 }
899
900 #[test]
901 fn upsert_and_retire_cursor_update_minimum_lsn() {
902 let dir = tempfile::tempdir().unwrap();
903 upsert_replica_cursor(dir.path(), ReplicaCursor::active("replica-a", 5)).unwrap();
904 upsert_replica_cursor(dir.path(), ReplicaCursor::active("replica-b", 9)).unwrap();
905 assert_eq!(minimum_retained_lsn(dir.path()).unwrap(), Some(6));
906
907 upsert_replica_cursor(dir.path(), ReplicaCursor::active("replica-a", 12)).unwrap();
908 assert_eq!(minimum_retained_lsn(dir.path()).unwrap(), Some(10));
909
910 retire_replica_cursor(dir.path(), "replica-b", 100).unwrap();
911 assert_eq!(minimum_retained_lsn(dir.path()).unwrap(), Some(13));
912 }
913
914 #[test]
915 fn concurrent_upserts_keep_all_active_replicas() {
916 let dir = tempfile::tempdir().unwrap();
917 let barrier = Arc::new(Barrier::new(16));
918 let mut handles = Vec::new();
919
920 for i in 0..16 {
921 let data_dir = dir.path().to_path_buf();
922 let barrier = Arc::clone(&barrier);
923 handles.push(std::thread::spawn(move || {
924 barrier.wait();
925 upsert_replica_cursor(
926 &data_dir,
927 ReplicaCursor::active(format!("replica-{i:02}"), 100 + i),
928 )
929 }));
930 }
931
932 for handle in handles {
933 handle.join().unwrap().unwrap();
934 }
935
936 let cursors = read_replica_cursors(dir.path()).unwrap();
937 assert_eq!(cursors.len(), 16);
938 assert_eq!(minimum_retained_lsn(dir.path()).unwrap(), Some(101));
939 }
940
941 #[test]
942 fn cursor_lock_backoff_grows_and_stays_within_its_step() {
943 for attempt in 0..8u32 {
946 let nominal = CURSOR_LOCK_RETRY
947 .saturating_mul(1u32 << attempt.min(5))
948 .min(CURSOR_LOCK_RETRY_MAX);
949 let waited = cursor_lock_backoff(attempt);
950 assert!(
951 waited <= nominal && waited >= nominal / 2,
952 "attempt {attempt}: {waited:?} outside the half-open step below {nominal:?}"
953 );
954 }
955 assert!(cursor_lock_backoff(4) > cursor_lock_backoff(0));
957 assert!(cursor_lock_backoff(31) <= CURSOR_LOCK_RETRY_MAX);
960 }
961
962 #[test]
963 fn cursor_lock_timeout_is_not_shorter_than_the_staleness_window() {
964 assert!(
967 CURSOR_LOCK_TIMEOUT >= CURSOR_LOCK_STALE_AFTER,
968 "a caller must not give up before an abandoned lock becomes reclaimable"
969 );
970 }
971
972 #[test]
973 fn stale_cursor_lock_is_reclaimed() {
974 let dir = tempfile::tempdir().unwrap();
975 let state_dir = sync_state_dir(dir.path());
976 create_data_dir_secure(&state_dir).unwrap();
977 let stale_owner = CursorLockFile {
978 format_version: SYNC_METADATA_FORMAT_VERSION,
979 owner_pid: 0,
980 created_unix_secs: now_unix_secs(),
981 };
982 fs::write(
983 state_dir.join(CURSOR_LOCK_FILE),
984 serde_json::to_vec_pretty(&stale_owner).unwrap(),
985 )
986 .unwrap();
987 fsync_dir(&state_dir).unwrap();
988
989 upsert_replica_cursor(dir.path(), ReplicaCursor::active("replica-a", 7)).unwrap();
990
991 let cursors = read_replica_cursors(dir.path()).unwrap();
992 assert_eq!(cursors.len(), 1);
993 assert_eq!(cursors[0].replica_id, "replica-a");
994 assert!(!state_dir.join(CURSOR_LOCK_FILE).exists());
995 }
996
997 #[test]
998 fn cursor_validation_rejects_duplicate_bad_and_overflowing_ids() {
999 let dir = tempfile::tempdir().unwrap();
1000 let err = write_replica_cursors(
1001 dir.path(),
1002 vec![
1003 ReplicaCursor::active("replica-a", 1),
1004 ReplicaCursor::active("replica-a", 2),
1005 ],
1006 )
1007 .unwrap_err();
1008 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1009
1010 let err =
1011 upsert_replica_cursor(dir.path(), ReplicaCursor::active("../bad", 1)).unwrap_err();
1012 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1013
1014 let err = upsert_replica_cursor(dir.path(), ReplicaCursor::active("replica-c", u64::MAX))
1015 .unwrap_err();
1016 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1017 }
1018
1019 #[test]
1020 fn corrupt_cursor_file_fails_closed() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let state_dir = sync_state_dir(dir.path());
1023 create_data_dir_secure(&state_dir).unwrap();
1024 fs::write(
1025 state_dir.join(REPLICA_CURSORS_FILE),
1026 br#"{"format_version":999,"cursors":[]}"#,
1027 )
1028 .unwrap();
1029
1030 let err = read_replica_cursors(dir.path()).unwrap_err();
1031 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1032 }
1033}