Skip to main content

powdb_sync/
metadata.rs

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";
20/// How long a caller waits for the cursor lock before giving up.
21///
22/// Deliberately equal to [`CURSOR_LOCK_STALE_AFTER`]. The holder's critical
23/// section is two fsyncs, so its cost rises with the number of contenders and
24/// with whatever else is hitting the disk; the previous 5s could be exhausted
25/// by ordinary contention on a busy machine, and the caller then got a
26/// `WouldBlock` error for a lock that was simply in use rather than stuck. A
27/// genuinely abandoned lock is not the reason to wait less: a dead owner is
28/// detected and reclaimed by `reclaim_stale_cursor_lock` on every retry, and
29/// that only becomes possible at `CURSOR_LOCK_STALE_AFTER`. Giving up sooner
30/// than that means failing before the one mechanism that could have unblocked
31/// us has had a chance to run.
32const CURSOR_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
33/// First backoff step. Grows to [`CURSOR_LOCK_RETRY_MAX`]; see
34/// [`cursor_lock_backoff`] for why it grows and why it is jittered.
35const 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
367/// Return the smallest next-required LSN across active replica cursors.
368///
369/// This is an advisory snapshot. Do not use it by itself as authority to delete
370/// retained history because cursor publication can race an unlocked caller.
371/// Use `prune_retained_segments_for_cursors` for deletion; it holds the cursor
372/// metadata lock from cursor-floor read through segment removal.
373pub 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
450/// How long to wait before the next attempt at the cursor lock.
451///
452/// Doubles from [`CURSOR_LOCK_RETRY`] to [`CURSOR_LOCK_RETRY_MAX`], then stays
453/// there, and every result is jittered across the lower half of its step.
454///
455/// The jitter is the point. A fixed delay makes every waiter sleep the same
456/// amount and therefore wake together, so N contenders re-collide on the same
457/// `create_new` on every round and only the scheduler decides who wins. Nothing
458/// in that arrangement guarantees a given waiter ever wins. Spreading the wakeups
459/// turns the herd into a queue.
460fn 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    // Cheap decorrelation: the low bits of the clock differ between threads that
465    // reached this line at slightly different times, which is exactly the set of
466    // threads we are trying to separate. No RNG dependency for a sleep length.
467    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        // Each step must sit in the upper half of its nominal delay: jitter
944        // only ever shortens a wait, so backoff cannot collapse to a busy loop.
945        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        // And it must actually grow, or contention never spreads out.
956        assert!(cursor_lock_backoff(4) > cursor_lock_backoff(0));
957        // Capped, so a long wait never turns into a multi-second sleep that
958        // overshoots the timeout it is supposed to be polling within.
959        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        // Giving up before a dead owner can be declared stale means failing
965        // before reclaim -- the only thing that could unblock us -- can run.
966        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}