mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
refactor(blob): add NFS-safe file I/O layer
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use std::fs::{File, OpenOptions};
|
use std::fs::OpenOptions;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -218,17 +218,13 @@ impl BucketFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the bucket file with a sorted, deduplicated set of records.
|
/// Rewrite the bucket file with a sorted, deduplicated set of records.
|
||||||
|
/// Uses atomic temp+rename to be safe on NFS.
|
||||||
pub fn rewrite(&self, records: &[IndexRecord]) -> Result<()> {
|
pub fn rewrite(&self, records: &[IndexRecord]) -> Result<()> {
|
||||||
let temp_path = self.path.with_extension("idx.tmp");
|
let mut buf = Vec::with_capacity(records.len() * INDEX_RECORD_SIZE);
|
||||||
{
|
|
||||||
let mut file = File::create(&temp_path)?;
|
|
||||||
for r in records {
|
for r in records {
|
||||||
file.write_all(&r.encode())?;
|
buf.extend_from_slice(&r.encode());
|
||||||
}
|
}
|
||||||
file.sync_all()?;
|
crate::fs::create_atomic(&self.path, &buf)
|
||||||
}
|
|
||||||
std::fs::rename(&temp_path, &self.path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the bucket file.
|
/// Delete the bucket file.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::path::Path;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::fs as fs_util;
|
||||||
|
|
||||||
/// Simple LRU pool of open file handles, keyed by segment_id.
|
/// Simple LRU pool of open file handles, keyed by segment_id.
|
||||||
/// Uses Arc<Mutex<File>> to allow safe concurrent reads from the same segment.
|
/// Uses Arc<Mutex<File>> to allow safe concurrent reads from the same segment.
|
||||||
@@ -34,7 +35,7 @@ impl FilePool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open new file
|
// Open new file
|
||||||
let file = Arc::new(Mutex::new(File::open(path)?));
|
let file = Arc::new(Mutex::new(fs_util::open_read(path)?));
|
||||||
|
|
||||||
// Evict oldest if full
|
// Evict oldest if full
|
||||||
if entries.len() >= self.max_entries {
|
if entries.len() >= self.max_entries {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Max retries for transient filesystem errors (NFS ESTALE, CIFS sharing violations, etc.)
|
||||||
|
const MAX_RETRIES: u32 = 5;
|
||||||
|
const RETRY_DELAY: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
/// Check if an I/O error is transient (retryable).
|
||||||
|
fn is_transient(err: &io::Error) -> bool {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
matches!(
|
||||||
|
err.kind(),
|
||||||
|
ErrorKind::TimedOut
|
||||||
|
| ErrorKind::Interrupted
|
||||||
|
| ErrorKind::WouldBlock
|
||||||
|
| ErrorKind::UnexpectedEof
|
||||||
|
) || err.raw_os_error() == Some(116) // ESTALE on Linux
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an existing file for reading, with retry on transient errors (NFS ESTALE etc.).
|
||||||
|
pub fn open_read(path: &Path) -> Result<File> {
|
||||||
|
let mut last_err = None;
|
||||||
|
for attempt in 0..MAX_RETRIES {
|
||||||
|
match File::open(path) {
|
||||||
|
Ok(f) => return Ok(f),
|
||||||
|
Err(e) if is_transient(&e) => {
|
||||||
|
last_err = Some(e);
|
||||||
|
if attempt > 0 {
|
||||||
|
std::thread::sleep(RETRY_DELAY * attempt);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(crate::error::Error::Io(last_err.unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an existing file for writing, with retry on transient errors.
|
||||||
|
pub fn open_write(path: &Path) -> Result<File> {
|
||||||
|
let mut last_err = None;
|
||||||
|
for attempt in 0..MAX_RETRIES {
|
||||||
|
match OpenOptions::new().write(true).open(path) {
|
||||||
|
Ok(f) => return Ok(f),
|
||||||
|
Err(e) if is_transient(&e) => {
|
||||||
|
last_err = Some(e);
|
||||||
|
if attempt > 0 {
|
||||||
|
std::thread::sleep(RETRY_DELAY * attempt);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(crate::error::Error::Io(last_err.unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new file atomically: write content to a temp file, fsync, then rename.
|
||||||
|
/// Avoids `create_new(true)` which is racy on NFS.
|
||||||
|
pub fn create_atomic(path: &Path, content: &[u8]) -> Result<()> {
|
||||||
|
let tmp = path.with_extension(
|
||||||
|
path.extension()
|
||||||
|
.map(|e| format!("{}.tmp", e.to_string_lossy()))
|
||||||
|
.unwrap_or_else(|| "tmp".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut f = File::create(&tmp)?;
|
||||||
|
f.write_all(content)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate an existing file to the given size, with retry.
|
||||||
|
pub fn truncate(path: &Path, size: u64) -> Result<()> {
|
||||||
|
let f = open_write(path)?;
|
||||||
|
f.set_len(size)?;
|
||||||
|
f.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_read_existing() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("test.txt");
|
||||||
|
std::fs::write(&path, b"hello").unwrap();
|
||||||
|
|
||||||
|
let mut f = open_read(&path).unwrap();
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut f, &mut s).unwrap();
|
||||||
|
assert_eq!(s, "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_read_missing() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let result = open_read(&dir.path().join("nope.txt"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_atomic_success() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("data.bin");
|
||||||
|
create_atomic(&path, b"hello world").unwrap();
|
||||||
|
|
||||||
|
let content = std::fs::read(&path).unwrap();
|
||||||
|
assert_eq!(content, b"hello world");
|
||||||
|
// Temp file should not exist
|
||||||
|
assert!(!dir.path().join("data.bin.tmp").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_atomic_overwrites() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("data.bin");
|
||||||
|
create_atomic(&path, b"first").unwrap();
|
||||||
|
create_atomic(&path, b"second").unwrap();
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"second");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = dir.path().join("trunc.bin");
|
||||||
|
std::fs::write(&path, b"1234567890").unwrap();
|
||||||
|
truncate(&path, 5).unwrap();
|
||||||
|
assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod compress;
|
|||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod file_pool;
|
pub mod file_pool;
|
||||||
|
pub mod fs;
|
||||||
pub mod gc;
|
pub mod gc;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
|
|||||||
@@ -19,14 +19,7 @@ fn write_bin<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
|||||||
buf.extend_from_slice(&META_VERSION.to_le_bytes());
|
buf.extend_from_slice(&META_VERSION.to_le_bytes());
|
||||||
buf.extend_from_slice(&payload);
|
buf.extend_from_slice(&payload);
|
||||||
|
|
||||||
let tmp = path.with_extension("bin.tmp");
|
crate::fs::create_atomic(path, &buf)?;
|
||||||
{
|
|
||||||
use std::io::Write;
|
|
||||||
let mut f = std::fs::File::create(&tmp)?;
|
|
||||||
f.write_all(&buf)?;
|
|
||||||
f.sync_all()?;
|
|
||||||
}
|
|
||||||
std::fs::rename(&tmp, path)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File};
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::checksum;
|
use crate::checksum;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::fs as fs_util;
|
||||||
use crate::types::{Codec, ENTRY_HEADER_SIZE, ENTRY_MAGIC, SEGMENT_MAX_SIZE};
|
use crate::types::{Codec, ENTRY_HEADER_SIZE, ENTRY_MAGIC, SEGMENT_MAX_SIZE};
|
||||||
|
|
||||||
/// In-memory representation of a stored entry.
|
/// In-memory representation of a stored entry.
|
||||||
@@ -60,10 +61,8 @@ pub struct SegmentWriter {
|
|||||||
|
|
||||||
impl SegmentWriter {
|
impl SegmentWriter {
|
||||||
pub fn create(path: PathBuf, id: u32) -> Result<Self> {
|
pub fn create(path: PathBuf, id: u32) -> Result<Self> {
|
||||||
let file = OpenOptions::new()
|
// Use create+truncate instead of create_new to avoid NFS O_EXCL issues.
|
||||||
.create_new(true)
|
let file = File::create(&path)?;
|
||||||
.write(true)
|
|
||||||
.open(&path)?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
file,
|
file,
|
||||||
path,
|
path,
|
||||||
@@ -73,7 +72,7 @@ impl SegmentWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_append(path: PathBuf, id: u32) -> Result<Self> {
|
pub fn open_append(path: PathBuf, id: u32) -> Result<Self> {
|
||||||
let mut file = OpenOptions::new().write(true).open(&path)?;
|
let mut file = fs_util::open_write(&path)?;
|
||||||
file.seek(SeekFrom::End(0))?;
|
file.seek(SeekFrom::End(0))?;
|
||||||
let bytes_written = file.stream_position()?;
|
let bytes_written = file.stream_position()?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -188,7 +187,7 @@ impl SegmentReader {
|
|||||||
|
|
||||||
/// Read a single entry at the given offset. Returns the entry and the offset of the next entry.
|
/// Read a single entry at the given offset. Returns the entry and the offset of the next entry.
|
||||||
pub fn read_entry_at(&self, offset: u64) -> Result<(Entry, u64)> {
|
pub fn read_entry_at(&self, offset: u64) -> Result<(Entry, u64)> {
|
||||||
let mut file = File::open(&self.path)?;
|
let mut file = fs_util::open_read(&self.path)?;
|
||||||
file.seek(SeekFrom::Start(offset))?;
|
file.seek(SeekFrom::Start(offset))?;
|
||||||
|
|
||||||
// Read magic
|
// Read magic
|
||||||
@@ -359,7 +358,7 @@ impl SegmentReader {
|
|||||||
|
|
||||||
/// Read data portion of an entry (for pread-style reads when you already know offset + data_size).
|
/// Read data portion of an entry (for pread-style reads when you already know offset + data_size).
|
||||||
pub fn read_data(&self, offset: u64, data_size: u32) -> Result<Vec<u8>> {
|
pub fn read_data(&self, offset: u64, data_size: u32) -> Result<Vec<u8>> {
|
||||||
let mut file = File::open(&self.path)?;
|
let mut file = fs_util::open_read(&self.path)?;
|
||||||
// Skip magic(4) + crc32(4) + flags(1) + codec(1) + key(32) + raw_size(4) + data_size(4) = 50 bytes
|
// Skip magic(4) + crc32(4) + flags(1) + codec(1) + key(32) + raw_size(4) + data_size(4) = 50 bytes
|
||||||
let data_start = offset + ENTRY_HEADER_SIZE as u64;
|
let data_start = offset + ENTRY_HEADER_SIZE as u64;
|
||||||
file.seek(SeekFrom::Start(data_start))?;
|
file.seek(SeekFrom::Start(data_start))?;
|
||||||
@@ -370,7 +369,7 @@ impl SegmentReader {
|
|||||||
|
|
||||||
/// Read the full entry header + data for verification (used by recovery and GC).
|
/// Read the full entry header + data for verification (used by recovery and GC).
|
||||||
pub fn read_full_entry(&self, offset: u64, data_size: u32) -> Result<Vec<u8>> {
|
pub fn read_full_entry(&self, offset: u64, data_size: u32) -> Result<Vec<u8>> {
|
||||||
let mut file = File::open(&self.path)?;
|
let mut file = fs_util::open_read(&self.path)?;
|
||||||
file.seek(SeekFrom::Start(offset))?;
|
file.seek(SeekFrom::Start(offset))?;
|
||||||
let total = ENTRY_HEADER_SIZE + data_size as usize;
|
let total = ENTRY_HEADER_SIZE + data_size as usize;
|
||||||
let mut buf = vec![0u8; total];
|
let mut buf = vec![0u8; total];
|
||||||
@@ -416,9 +415,7 @@ impl SegmentReader {
|
|||||||
|
|
||||||
/// Truncate a segment file to the given size.
|
/// Truncate a segment file to the given size.
|
||||||
pub fn truncate_segment(path: &Path, size: u64) -> Result<()> {
|
pub fn truncate_segment(path: &Path, size: u64) -> Result<()> {
|
||||||
let file = OpenOptions::new().write(true).open(path)?;
|
fs_util::truncate(path, size)
|
||||||
file.set_len(size)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map an Error, converting Io(StorageFull) to DiskFull with path context.
|
/// Map an Error, converting Io(StorageFull) to DiskFull with path context.
|
||||||
|
|||||||
Reference in New Issue
Block a user