1use std::{
4 fs::{self, File, OpenOptions},
5 io::{self, Read, Write},
6 path::{Path, PathBuf},
7};
8
9use fs4::{FileExt, TryLockError};
10use hyphae_core::{DISK_FORMAT_VERSION, MIN_DISK_FORMAT_VERSION};
11use thiserror::Error;
12
13use crate::{
14 DurableLog, LogError, ManifestError, OpenedLog, RecoveryLimits, StorageLimitError,
15 limits::{OperationDeadline, limit_io_error},
16 manifest::StorageManifest,
17};
18
19const FORMAT_PREFIX: &str = "hyphae-disk-format=";
20const MAX_FORMAT_MARKER_BYTES: usize = 64;
21const FORMAT_MARKER_READ_LIMIT: u64 = 65;
22const REQUIRED_DIRECTORIES: [&str; 6] = ["manifest", "log", "snapshots", "indexes", "blobs", "tmp"];
23
24#[derive(Debug, Error)]
26pub enum DataDirectoryError {
27 #[error("data directory is already locked by another writer: {0}")]
29 AlreadyLocked(PathBuf),
30
31 #[error("malformed data format marker: {0}")]
33 MalformedFormat(PathBuf),
34
35 #[error("unsupported disk format {found}; this binary supports {supported}")]
37 UnsupportedFormat {
38 found: u16,
40 supported: u16,
42 },
43
44 #[error(transparent)]
46 Manifest(#[from] ManifestError),
47
48 #[error("failed to {action} {path}: {source}")]
50 Io {
51 action: &'static str,
53 path: PathBuf,
55 #[source]
57 source: io::Error,
58 },
59}
60
61impl From<StorageLimitError> for DataDirectoryError {
62 fn from(source: StorageLimitError) -> Self {
63 Self::Manifest(ManifestError::Io(limit_io_error(source)))
64 }
65}
66
67#[derive(Debug)]
73pub struct DataDirectory {
74 root: PathBuf,
75 lock: File,
76 manifest: StorageManifest,
77 disk_format_version: u16,
78 #[cfg(test)]
79 fail_next_manifest_commit_after_write: bool,
80}
81
82impl DataDirectory {
83 pub fn open(path: impl AsRef<Path>) -> Result<Self, DataDirectoryError> {
90 let limits = RecoveryLimits::compatibility();
91 let deadline = OperationDeadline::new(limits.timeout);
92 Self::open_with_limits_and_deadline(path, &limits, &deadline)
93 }
94
95 pub fn open_with_limits(
102 path: impl AsRef<Path>,
103 limits: &RecoveryLimits,
104 ) -> Result<Self, DataDirectoryError> {
105 limits.validate()?;
106 let deadline = OperationDeadline::new(limits.timeout);
107 Self::open_with_limits_and_deadline(path, limits, &deadline)
108 }
109
110 pub(crate) fn open_with_limits_and_deadline(
111 path: impl AsRef<Path>,
112 limits: &RecoveryLimits,
113 deadline: &OperationDeadline,
114 ) -> Result<Self, DataDirectoryError> {
115 deadline.check()?;
116 let root = path.as_ref().to_path_buf();
117 fs::create_dir_all(&root).map_err(|source| DataDirectoryError::Io {
118 action: "create data directory",
119 path: root.clone(),
120 source,
121 })?;
122
123 let lock_path = root.join("LOCK");
124 let lock = OpenOptions::new()
125 .create(true)
126 .read(true)
127 .write(true)
128 .truncate(false)
129 .open(&lock_path)
130 .map_err(|source| DataDirectoryError::Io {
131 action: "open lock file",
132 path: lock_path.clone(),
133 source,
134 })?;
135
136 match FileExt::try_lock(&lock) {
137 Ok(()) => {}
138 Err(TryLockError::WouldBlock) => {
139 return Err(DataDirectoryError::AlreadyLocked(root));
140 }
141 Err(TryLockError::Error(source)) => {
142 return Err(DataDirectoryError::Io {
143 action: "lock data directory",
144 path: lock_path,
145 source,
146 });
147 }
148 }
149
150 let opened_format = initialize_or_validate_format(&root)?;
151 for name in REQUIRED_DIRECTORIES {
152 let directory = root.join(name);
153 fs::create_dir_all(&directory).map_err(|source| DataDirectoryError::Io {
154 action: "create data subdirectory",
155 path: directory,
156 source,
157 })?;
158 }
159
160 let manifest = StorageManifest::load_or_initialize_with_limits(
161 &root,
162 limits.max_directory_entries,
163 deadline,
164 )?;
165
166 Ok(Self {
167 root,
168 lock,
169 manifest,
170 disk_format_version: opened_format,
171 #[cfg(test)]
172 fail_next_manifest_commit_after_write: false,
173 })
174 }
175
176 pub fn path(&self) -> &Path {
178 &self.root
179 }
180
181 pub fn disk_format_version(&self) -> u16 {
183 self.disk_format_version
184 }
185
186 pub fn active_log_path(&self) -> PathBuf {
188 self.log_path(self.manifest.active_segment)
189 }
190
191 pub fn open_log(&self) -> Result<OpenedLog<'_>, LogError> {
199 let limits = RecoveryLimits::compatibility();
200 let deadline = OperationDeadline::new(limits.timeout);
201 self.open_log_with_limits(&limits, &deadline)
202 }
203
204 pub(crate) fn open_log_with_limits(
205 &self,
206 limits: &RecoveryLimits,
207 deadline: &OperationDeadline,
208 ) -> Result<OpenedLog<'_>, LogError> {
209 let (log, recovery) = DurableLog::open_file_at_version_with_limits(
210 self.active_log_path(),
211 self.manifest.base_sequence,
212 self.manifest.base_digest,
213 self.disk_format_version,
214 limits,
215 deadline,
216 )?;
217 Ok(OpenedLog::new(log, recovery))
218 }
219
220 pub(crate) fn log_anchor(&self) -> (u64, [u8; 32]) {
221 (self.manifest.base_sequence, self.manifest.base_digest)
222 }
223
224 pub(crate) fn manifest(&self) -> StorageManifest {
225 self.manifest
226 }
227
228 pub(crate) fn log_path(&self, segment: u64) -> PathBuf {
229 self.root.join("log").join(format!("{segment:020}.hylog"))
230 }
231
232 pub(crate) fn snapshot_path(&self, sequence: u64) -> PathBuf {
233 self.root
234 .join("snapshots")
235 .join(format!("snapshot-{sequence:020}.hysnap"))
236 }
237
238 pub(crate) fn commit_manifest(
239 &mut self,
240 manifest: StorageManifest,
241 ) -> Result<(), DataDirectoryError> {
242 let limits = RecoveryLimits::default();
243 let deadline = OperationDeadline::new(limits.timeout);
244 self.commit_manifest_with_limits(manifest, &limits, &deadline)
245 }
246
247 pub(crate) fn commit_manifest_with_limits(
248 &mut self,
249 manifest: StorageManifest,
250 limits: &RecoveryLimits,
251 deadline: &OperationDeadline,
252 ) -> Result<(), DataDirectoryError> {
253 let target = manifest.path(&self.root);
254 let target_is_new = self.reserve_target_entry("manifest", &target, limits, deadline)?;
255 if target_is_new {
256 self.reserve_directory_entries("tmp", 1, limits, deadline)?;
257 }
258 manifest.write_new(&self.root)?;
259 #[cfg(test)]
260 if std::mem::take(&mut self.fail_next_manifest_commit_after_write) {
261 return Err(DataDirectoryError::Io {
262 action: "complete injected manifest commit",
263 path: target,
264 source: io::Error::other("injected post-write manifest failure"),
265 });
266 }
267 self.manifest = manifest;
268 Ok(())
269 }
270
271 #[cfg(test)]
272 pub(crate) fn inject_manifest_commit_failure_after_write(&mut self) {
273 self.fail_next_manifest_commit_after_write = true;
274 }
275
276 pub(crate) fn reserve_target_entry(
277 &self,
278 directory_name: &'static str,
279 target: &Path,
280 limits: &RecoveryLimits,
281 deadline: &OperationDeadline,
282 ) -> Result<bool, DataDirectoryError> {
283 deadline.check()?;
284 let target_is_new = !target
285 .try_exists()
286 .map_err(|source| DataDirectoryError::Io {
287 action: "inspect prospective storage entry",
288 path: target.to_path_buf(),
289 source,
290 })?;
291 self.reserve_directory_entries(directory_name, u64::from(target_is_new), limits, deadline)?;
292 Ok(target_is_new)
293 }
294
295 pub(crate) fn reserve_directory_entries(
296 &self,
297 directory_name: &'static str,
298 additional_entries: u64,
299 limits: &RecoveryLimits,
300 deadline: &OperationDeadline,
301 ) -> Result<(), DataDirectoryError> {
302 deadline.check()?;
303 let directory = self.root.join(directory_name);
304 let entries = fs::read_dir(&directory).map_err(|source| DataDirectoryError::Io {
305 action: "inspect storage directory capacity",
306 path: directory.clone(),
307 source,
308 })?;
309 let mut entry_count = 0_u64;
310 for entry in entries {
311 deadline.check()?;
312 entry.map_err(|source| DataDirectoryError::Io {
313 action: "inspect storage directory entry",
314 path: directory.clone(),
315 source,
316 })?;
317 entry_count =
318 entry_count
319 .checked_add(1)
320 .ok_or(StorageLimitError::DirectoryEntriesExceeded {
321 maximum: limits.max_directory_entries,
322 })?;
323 if entry_count > limits.max_directory_entries {
324 return Err(StorageLimitError::DirectoryEntriesExceeded {
325 maximum: limits.max_directory_entries,
326 }
327 .into());
328 }
329 }
330 let required = entry_count.checked_add(additional_entries).ok_or(
331 StorageLimitError::DirectoryEntriesExceeded {
332 maximum: limits.max_directory_entries,
333 },
334 )?;
335 if required > limits.max_directory_entries {
336 return Err(StorageLimitError::DirectoryEntriesExceeded {
337 maximum: limits.max_directory_entries,
338 }
339 .into());
340 }
341 Ok(())
342 }
343
344 pub(crate) fn promote_format(&mut self) -> Result<(), DataDirectoryError> {
345 if self.disk_format_version == DISK_FORMAT_VERSION {
346 return Ok(());
347 }
348 write_format_marker(&self.root, DISK_FORMAT_VERSION)?;
349 self.disk_format_version = DISK_FORMAT_VERSION;
350 Ok(())
351 }
352
353 pub(crate) fn cleanup_retired_logs_with_limits(
354 &self,
355 limits: &RecoveryLimits,
356 deadline: &OperationDeadline,
357 ) -> bool {
358 let log_directory = self.root.join("log");
359 let Ok(entries) = fs::read_dir(&log_directory) else {
360 return false;
361 };
362 #[cfg(unix)]
363 let mut removed = false;
364 let mut entry_count = 0_u64;
365 for entry in entries.flatten() {
366 if deadline.check().is_err() {
367 return false;
368 }
369 entry_count = match entry_count.checked_add(1) {
370 Some(count) if count <= limits.max_directory_entries => count,
371 _ => return false,
372 };
373 let path = entry.path();
374 let Some(segment) = segment_from_path(&path) else {
375 continue;
376 };
377 if segment < self.manifest.active_segment {
378 match fs::remove_file(&path) {
379 Ok(()) => {
380 #[cfg(unix)]
381 {
382 removed = true;
383 }
384 }
385 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
386 Err(_) => return false,
387 }
388 }
389 }
390 #[cfg(unix)]
391 if removed && sync_directory(&log_directory).is_err() {
392 return false;
393 }
394 true
395 }
396}
397
398impl Drop for DataDirectory {
399 fn drop(&mut self) {
400 let _ignored = FileExt::unlock(&self.lock);
401 }
402}
403
404fn initialize_or_validate_format(root: &Path) -> Result<u16, DataDirectoryError> {
405 let format_path = root.join("FORMAT");
406 if format_path.exists() {
407 return validate_format(&format_path);
408 }
409 write_format_marker(root, DISK_FORMAT_VERSION)?;
410 Ok(DISK_FORMAT_VERSION)
411}
412
413fn write_format_marker(root: &Path, version: u16) -> Result<(), DataDirectoryError> {
414 let format_path = root.join("FORMAT");
415 let temporary_path = root.join("FORMAT.new");
416 let mut file = OpenOptions::new()
417 .create(true)
418 .write(true)
419 .truncate(true)
420 .open(&temporary_path)
421 .map_err(|source| DataDirectoryError::Io {
422 action: "create temporary format marker",
423 path: temporary_path.clone(),
424 source,
425 })?;
426 let marker = format!("{FORMAT_PREFIX}{version}\n");
427 file.write_all(marker.as_bytes())
428 .and_then(|()| file.sync_all())
429 .map_err(|source| DataDirectoryError::Io {
430 action: "initialize temporary format marker",
431 path: temporary_path.clone(),
432 source,
433 })?;
434 drop(file);
435 fs::rename(&temporary_path, &format_path).map_err(|source| DataDirectoryError::Io {
436 action: "promote format marker",
437 path: format_path,
438 source,
439 })?;
440 #[cfg(unix)]
441 sync_directory(root)?;
442 Ok(())
443}
444
445#[cfg(unix)]
446fn sync_directory(path: &Path) -> Result<(), DataDirectoryError> {
447 File::open(path)
448 .and_then(|directory| directory.sync_all())
449 .map_err(|source| DataDirectoryError::Io {
450 action: "synchronize data directory",
451 path: path.to_path_buf(),
452 source,
453 })
454}
455
456fn validate_format(path: &Path) -> Result<u16, DataDirectoryError> {
457 let file = File::open(path).map_err(|source| DataDirectoryError::Io {
458 action: "open format marker",
459 path: path.to_path_buf(),
460 source,
461 })?;
462 let mut marker_bytes = Vec::with_capacity(MAX_FORMAT_MARKER_BYTES + 1);
463 file.take(FORMAT_MARKER_READ_LIMIT)
464 .read_to_end(&mut marker_bytes)
465 .map_err(|source| DataDirectoryError::Io {
466 action: "read format marker",
467 path: path.to_path_buf(),
468 source,
469 })?;
470 if marker_bytes.len() > MAX_FORMAT_MARKER_BYTES {
471 return Err(DataDirectoryError::MalformedFormat(path.to_path_buf()));
472 }
473 let marker = String::from_utf8(marker_bytes)
474 .map_err(|_| DataDirectoryError::MalformedFormat(path.to_path_buf()))?;
475
476 let Some(raw_version) = marker
477 .strip_prefix(FORMAT_PREFIX)
478 .and_then(|value| value.strip_suffix('\n'))
479 else {
480 return Err(DataDirectoryError::MalformedFormat(path.to_path_buf()));
481 };
482 let version = raw_version
483 .parse::<u16>()
484 .map_err(|_| DataDirectoryError::MalformedFormat(path.to_path_buf()))?;
485 if version > DISK_FORMAT_VERSION {
486 return Err(DataDirectoryError::UnsupportedFormat {
487 found: version,
488 supported: DISK_FORMAT_VERSION,
489 });
490 }
491 if version < MIN_DISK_FORMAT_VERSION {
492 return Err(DataDirectoryError::MalformedFormat(path.to_path_buf()));
493 }
494 Ok(version)
495}
496
497fn segment_from_path(path: &Path) -> Option<u64> {
498 let filename = path.file_name()?.to_str()?;
499 let raw_segment = filename.strip_suffix(".hylog")?;
500 if raw_segment.len() != 20 || !raw_segment.bytes().all(|byte| byte.is_ascii_digit()) {
501 return None;
502 }
503 let segment = raw_segment.parse().ok()?;
504 (format!("{segment:020}.hylog") == filename).then_some(segment)
505}
506
507#[cfg(test)]
508mod tests {
509 use std::{error::Error, fs};
510
511 use uuid::Uuid;
512
513 use super::{DataDirectory, DataDirectoryError, REQUIRED_DIRECTORIES};
514 use crate::{DurableLog, test_support::TestDirectory};
515
516 #[test]
517 fn initializes_canonical_layout() -> Result<(), Box<dyn Error>> {
518 let temporary = TestDirectory::new("data-layout")?;
519 let root = temporary.path().join("data");
520 let directory = DataDirectory::open(&root)?;
521
522 assert_eq!(directory.path(), root);
523 assert_eq!(
524 fs::read_to_string(root.join("FORMAT"))?,
525 "hyphae-disk-format=2\n"
526 );
527 assert!(root.join("LOCK").is_file());
528 for name in REQUIRED_DIRECTORIES {
529 assert!(root.join(name).is_dir());
530 }
531 Ok(())
532 }
533
534 #[test]
535 fn rejects_a_second_writer() -> Result<(), Box<dyn Error>> {
536 let temporary = TestDirectory::new("data-lock")?;
537 let first = DataDirectory::open(temporary.path())?;
538 let second = DataDirectory::open(temporary.path());
539
540 assert!(matches!(second, Err(DataDirectoryError::AlreadyLocked(_))));
541 drop(first);
542 DataDirectory::open(temporary.path())?;
543 Ok(())
544 }
545
546 #[test]
547 fn rejects_future_format() -> Result<(), Box<dyn Error>> {
548 let temporary = TestDirectory::new("future-format")?;
549 fs::write(temporary.path().join("FORMAT"), "hyphae-disk-format=3\n")?;
550
551 let result = DataDirectory::open(temporary.path());
552 assert!(matches!(
553 result,
554 Err(DataDirectoryError::UnsupportedFormat {
555 found: 3,
556 supported: 2
557 })
558 ));
559 Ok(())
560 }
561
562 #[test]
563 fn format_marker_reads_are_bounded_at_sixty_four_bytes() -> Result<(), Box<dyn Error>> {
564 for length in [64_usize, 65] {
565 let temporary = TestDirectory::new(&format!("bounded-format-{length}"))?;
566 fs::write(temporary.path().join("FORMAT"), vec![b'x'; length])?;
567 assert!(matches!(
568 DataDirectory::open(temporary.path()),
569 Err(DataDirectoryError::MalformedFormat(_))
570 ));
571 }
572 Ok(())
573 }
574
575 #[test]
576 fn durable_log_cannot_outlive_directory_lock() -> Result<(), Box<dyn Error>> {
577 let temporary = TestDirectory::new("locked-log")?;
578 let directory = DataDirectory::open(temporary.path())?;
579 let mut opened = directory.open_log()?;
580 opened
581 .log
582 .append_transaction(Uuid::now_v7(), &[b"operation".to_vec()])?;
583
584 assert_eq!(opened.recovery.transactions.len(), 0);
585 assert!(matches!(
586 DataDirectory::open(temporary.path()),
587 Err(DataDirectoryError::AlreadyLocked(_))
588 ));
589 Ok(())
590 }
591
592 #[test]
593 fn migrates_an_existing_format_one_directory_without_a_manifest() -> Result<(), Box<dyn Error>>
594 {
595 let temporary = TestDirectory::new("data-manifest-migration")?;
596 let root = temporary.path().join("data");
597 fs::create_dir_all(root.join("log"))?;
598 fs::write(root.join("FORMAT"), "hyphae-disk-format=1\n")?;
599 let log_path = root.join("log/00000000000000000001.hylog");
600 let (mut legacy_log, _) = DurableLog::open_file_at_version(&log_path, 0, [0; 32], 1)?;
601 legacy_log.append_transaction(Uuid::now_v7(), &[b"preserved".to_vec()])?;
602 drop(legacy_log);
603
604 let directory = DataDirectory::open(&root)?;
605 assert!(
606 root.join("manifest/00000000000000000001.hymanifest")
607 .is_file()
608 );
609 let opened = directory.open_log()?;
610 assert_eq!(opened.recovery.transactions.len(), 1);
611 assert_eq!(opened.recovery.transactions[0].operations[0], b"preserved");
612 Ok(())
613 }
614}