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