Skip to main content

subetha_cxc/
protocol_direct_file.rs

1//! `DirectFileRing`: non-mmap positioned-I/O ring that bypasses the OS
2//! page cache (cross-platform).
3//!
4//! Where the substrate's other ring primitives use mmap to share memory
5//! with peers, `DirectFileRing` opens a file in unbuffered mode and
6//! reads/writes via positioned I/O. The page cache is bypassed: every
7//! write goes directly to the underlying block device, every read comes
8//! directly from the device. Useful when the substrate IS the buffer
9//! (the caller does its own caching and does not want the kernel
10//! double-buffering) - common in database storage engines.
11//!
12//! # Cross-platform mechanism
13//!
14//! The unbuffered-I/O surface differs per OS; only that surface is
15//! gated, the ring layout and coordination are shared:
16//!
17//! - Unix: `O_DIRECT` open flag, `pwrite(2)` / `pread(2)`,
18//!   `posix_memalign(3)` aligned buffers.
19//! - Windows: `FILE_FLAG_NO_BUFFERING` + `FILE_FLAG_WRITE_THROUGH` open
20//!   flags, `WriteFile` / `ReadFile` with an `OVERLAPPED` offset for
21//!   positioned I/O, `VirtualAlloc` page-aligned buffers.
22//!
23//! # Alignment
24//!
25//! Both `O_DIRECT` and `FILE_FLAG_NO_BUFFERING` require that buffer
26//! addresses, file offsets, and transfer lengths be aligned to the
27//! device's logical block / sector size (typically 512 or 4096 bytes).
28//! This primitive fixes the slot size at 4096 bytes and uses
29//! page-aligned buffers, which satisfies both.
30//!
31//! # Coordination
32//!
33//! Head/tail counters live in a SEPARATE small MMF
34//! ([`SharedAtomicU64`]) because writing them through the unbuffered
35//! data path would defeat their purpose (atomic visibility across
36//! processes). The data file holds payload slots only; the control
37//! files hold head + tail. The data is device-resident (write-through /
38//! O_DIRECT), so an independent reader process sees the producer's
39//! writes once it observes the head counter advance.
40
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43use std::sync::atomic::Ordering;
44
45use crate::shared_atomic::SharedAtomicU64;
46
47/// Fixed slot size matching the most common modern 4K-sector alignment.
48/// All positioned reads/writes are exactly this size.
49pub const DIRECT_FILE_SLOT_SIZE: usize = 4096;
50
51/// Errors `DirectFileRing` operations can return.
52#[derive(Debug)]
53pub enum DirectFileError {
54    Io(std::io::Error),
55    LayoutMismatch,
56    Empty,
57    Full,
58    PayloadTooLarge,
59}
60
61impl std::fmt::Display for DirectFileError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::Io(e) => write!(f, "io: {e}"),
65            Self::LayoutMismatch => write!(f, "layout mismatch"),
66            Self::Empty => write!(f, "ring is empty"),
67            Self::Full => write!(f, "ring is full"),
68            Self::PayloadTooLarge => write!(f, "payload too large"),
69        }
70    }
71}
72
73impl std::error::Error for DirectFileError {}
74
75impl From<std::io::Error> for DirectFileError {
76    fn from(e: std::io::Error) -> Self { Self::Io(e) }
77}
78
79/// A page-aligned heap buffer suitable for unbuffered (O_DIRECT /
80/// FILE_FLAG_NO_BUFFERING) I/O. Allocated and freed via the platform's
81/// aligned allocator.
82struct AlignedBuf {
83    ptr: *mut u8,
84    len: usize,
85}
86
87unsafe impl Send for AlignedBuf {}
88unsafe impl Sync for AlignedBuf {}
89
90impl AlignedBuf {
91    #[cfg(unix)]
92    fn new(len: usize) -> std::io::Result<Self> {
93        assert_eq!(len % DIRECT_FILE_SLOT_SIZE, 0);
94        let mut ptr: *mut libc::c_void = std::ptr::null_mut();
95        let rc = unsafe { libc::posix_memalign(&mut ptr, DIRECT_FILE_SLOT_SIZE, len) };
96        if rc != 0 {
97            return Err(std::io::Error::from_raw_os_error(rc));
98        }
99        unsafe { std::ptr::write_bytes(ptr as *mut u8, 0, len) };
100        Ok(Self { ptr: ptr as *mut u8, len })
101    }
102
103    #[cfg(windows)]
104    fn new(len: usize) -> std::io::Result<Self> {
105        use windows_sys::Win32::System::Memory::{
106            VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_READWRITE,
107        };
108        assert_eq!(len % DIRECT_FILE_SLOT_SIZE, 0);
109        // VirtualAlloc returns page-aligned (>= 4096) memory, zero-filled.
110        let ptr = unsafe {
111            VirtualAlloc(std::ptr::null(), len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE)
112        };
113        if ptr.is_null() {
114            return Err(std::io::Error::last_os_error());
115        }
116        Ok(Self { ptr: ptr as *mut u8, len })
117    }
118
119    fn as_mut_slice(&mut self) -> &mut [u8] {
120        unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) }
121    }
122
123    fn as_slice(&self) -> &[u8] {
124        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
125    }
126}
127
128impl Drop for AlignedBuf {
129    #[cfg(unix)]
130    fn drop(&mut self) {
131        unsafe { libc::free(self.ptr as *mut libc::c_void) };
132    }
133
134    #[cfg(windows)]
135    fn drop(&mut self) {
136        use windows_sys::Win32::System::Memory::{VirtualFree, MEM_RELEASE};
137        unsafe { VirtualFree(self.ptr as *mut core::ffi::c_void, 0, MEM_RELEASE) };
138    }
139}
140
141/// Open `path` for unbuffered, page-cache-bypassing positioned I/O.
142fn open_unbuffered(path: &Path, create: bool) -> std::io::Result<std::fs::File> {
143    let mut opts = std::fs::OpenOptions::new();
144    opts.read(true).write(true);
145    if create {
146        opts.create(true).truncate(true);
147    }
148    #[cfg(all(unix, not(target_os = "macos")))]
149    {
150        // Linux / FreeBSD: O_DIRECT bypasses the page cache at open(2).
151        use std::os::unix::fs::OpenOptionsExt;
152        opts.custom_flags(libc::O_DIRECT);
153    }
154    #[cfg(windows)]
155    {
156        use std::os::windows::fs::OpenOptionsExt;
157        use windows_sys::Win32::Storage::FileSystem::{
158            FILE_FLAG_NO_BUFFERING, FILE_FLAG_WRITE_THROUGH,
159        };
160        // NO_BUFFERING bypasses the cache (the O_DIRECT analogue);
161        // WRITE_THROUGH forces each write to the device so an
162        // independent reader process sees it.
163        opts.custom_flags(FILE_FLAG_NO_BUFFERING | FILE_FLAG_WRITE_THROUGH);
164    }
165    let file = opts.open(path)?;
166    #[cfg(target_os = "macos")]
167    {
168        // macOS has no O_DIRECT open flag; `fcntl(fd, F_NOCACHE, 1)` is the
169        // descriptor-level page-cache bypass, applied after open(2). It still
170        // requires the page-aligned buffers the O_DIRECT path uses. Best-effort:
171        // if it fails the descriptor stays cached, which is still correct.
172        use std::os::unix::io::AsRawFd;
173        // SAFETY: `file` owns a valid open descriptor for the duration of the call.
174        unsafe {
175            libc::fcntl(file.as_raw_fd(), libc::F_NOCACHE, 1);
176        }
177    }
178    Ok(file)
179}
180
181/// Non-mmap positioned-I/O ring with page-cache bypass.
182pub struct DirectFileRing {
183    data_file: std::fs::File,
184    head: Arc<SharedAtomicU64>,
185    tail: Arc<SharedAtomicU64>,
186    capacity: usize,
187    base_path: PathBuf,
188}
189
190unsafe impl Send for DirectFileRing {}
191unsafe impl Sync for DirectFileRing {}
192
193impl DirectFileRing {
194    /// Construct a fresh ring at `base_path`. Creates three files:
195    /// `{base}.directfile.data.bin` (the slot array, unbuffered),
196    /// `{base}.directfile.head.bin` (head counter, MMF),
197    /// `{base}.directfile.tail.bin` (tail counter, MMF).
198    pub fn create(
199        base_path: impl AsRef<Path>,
200        capacity: usize,
201    ) -> Result<Self, DirectFileError> {
202        assert!(capacity.is_power_of_two() && capacity >= 2,
203                "capacity must be pow2 >= 2");
204        let base = base_path.as_ref().to_path_buf();
205        let data_path = with_suffix(&base, ".directfile.data.bin");
206        let head_path = with_suffix(&base, ".directfile.head.bin");
207        let tail_path = with_suffix(&base, ".directfile.tail.bin");
208
209        let data_file = open_unbuffered(&data_path, true)?;
210        data_file.set_len((capacity * DIRECT_FILE_SLOT_SIZE) as u64)?;
211
212        let head = Arc::new(SharedAtomicU64::create(&head_path, 0)
213            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
214        let tail = Arc::new(SharedAtomicU64::create(&tail_path, 0)
215            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
216
217        Ok(Self { data_file, head, tail, capacity, base_path: base })
218    }
219
220    /// Open an existing ring at `base_path` (a second process).
221    pub fn open(
222        base_path: impl AsRef<Path>,
223        expected_capacity: usize,
224    ) -> Result<Self, DirectFileError> {
225        let base = base_path.as_ref().to_path_buf();
226        let data_path = with_suffix(&base, ".directfile.data.bin");
227        let head_path = with_suffix(&base, ".directfile.head.bin");
228        let tail_path = with_suffix(&base, ".directfile.tail.bin");
229
230        let data_file = open_unbuffered(&data_path, false)?;
231        let actual_size = data_file.metadata()?.len();
232        let expected_size = (expected_capacity * DIRECT_FILE_SLOT_SIZE) as u64;
233        if actual_size < expected_size {
234            return Err(DirectFileError::LayoutMismatch);
235        }
236
237        let head = Arc::new(SharedAtomicU64::open(&head_path)
238            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
239        let tail = Arc::new(SharedAtomicU64::open(&tail_path)
240            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
241
242        Ok(Self {
243            data_file, head, tail,
244            capacity: expected_capacity,
245            base_path: base,
246        })
247    }
248
249    /// Capacity in slots.
250    pub fn capacity(&self) -> usize { self.capacity }
251
252    /// Current head index.
253    pub fn head(&self) -> u64 { self.head.load(Ordering::Acquire) }
254
255    /// Current tail index.
256    pub fn tail(&self) -> u64 { self.tail.load(Ordering::Acquire) }
257
258    /// Push a payload: copy it into a page-aligned buffer and write it at
259    /// the head's slot offset with the page cache bypassed. Returns
260    /// Err(Full) when head - tail == capacity.
261    pub fn try_push(&self, payload: &[u8]) -> Result<(), DirectFileError> {
262        if payload.len() > DIRECT_FILE_SLOT_SIZE {
263            return Err(DirectFileError::PayloadTooLarge);
264        }
265        let head = self.head.load(Ordering::Relaxed);
266        let tail = self.tail.load(Ordering::Acquire);
267        if head.wrapping_sub(tail) >= self.capacity as u64 {
268            return Err(DirectFileError::Full);
269        }
270        let slot_offset = ((head as usize) & (self.capacity - 1))
271            * DIRECT_FILE_SLOT_SIZE;
272        let mut buf = AlignedBuf::new(DIRECT_FILE_SLOT_SIZE)?;
273        buf.as_mut_slice()[..payload.len()].copy_from_slice(payload);
274        let n = pwrite_aligned(&self.data_file, buf.as_slice(), slot_offset)?;
275        if n != DIRECT_FILE_SLOT_SIZE {
276            return Err(DirectFileError::Io(std::io::Error::other(
277                format!("partial write: {n} != {DIRECT_FILE_SLOT_SIZE}")
278            )));
279        }
280        self.head.store(head + 1, Ordering::Release);
281        Ok(())
282    }
283
284    /// Pop the oldest payload: read the tail's slot into a page-aligned
285    /// buffer (page cache bypassed), copy the relevant bytes to `out`,
286    /// and advance the tail.
287    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, DirectFileError> {
288        let tail = self.tail.load(Ordering::Relaxed);
289        let head = self.head.load(Ordering::Acquire);
290        if tail == head {
291            return Err(DirectFileError::Empty);
292        }
293        let slot_offset = ((tail as usize) & (self.capacity - 1))
294            * DIRECT_FILE_SLOT_SIZE;
295        let mut buf = AlignedBuf::new(DIRECT_FILE_SLOT_SIZE)?;
296        let n = pread_aligned(&self.data_file, buf.as_mut_slice(), slot_offset)?;
297        if n != DIRECT_FILE_SLOT_SIZE {
298            return Err(DirectFileError::Io(std::io::Error::other(
299                format!("partial read: {n} != {DIRECT_FILE_SLOT_SIZE}")
300            )));
301        }
302        let copy_len = out.len().min(DIRECT_FILE_SLOT_SIZE);
303        out[..copy_len].copy_from_slice(&buf.as_slice()[..copy_len]);
304        self.tail.store(tail + 1, Ordering::Release);
305        Ok(copy_len)
306    }
307}
308
309impl Drop for DirectFileRing {
310    fn drop(&mut self) {
311        let data_path = with_suffix(&self.base_path, ".directfile.data.bin");
312        let head_path = with_suffix(&self.base_path, ".directfile.head.bin");
313        let tail_path = with_suffix(&self.base_path, ".directfile.tail.bin");
314        std::fs::remove_file(&data_path).ok();
315        std::fs::remove_file(&head_path).ok();
316        std::fs::remove_file(&tail_path).ok();
317    }
318}
319
320fn with_suffix(base: &Path, suffix: &str) -> PathBuf {
321    let mut s = base.as_os_str().to_owned();
322    s.push(suffix);
323    PathBuf::from(s)
324}
325
326#[cfg(unix)]
327fn pwrite_aligned(
328    file: &std::fs::File,
329    buf: &[u8],
330    offset: usize,
331) -> std::io::Result<usize> {
332    use std::os::unix::io::AsRawFd;
333    let n = unsafe {
334        libc::pwrite(
335            file.as_raw_fd(),
336            buf.as_ptr() as *const libc::c_void,
337            buf.len(),
338            offset as libc::off_t,
339        )
340    };
341    if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) }
342}
343
344#[cfg(unix)]
345fn pread_aligned(
346    file: &std::fs::File,
347    buf: &mut [u8],
348    offset: usize,
349) -> std::io::Result<usize> {
350    use std::os::unix::io::AsRawFd;
351    let n = unsafe {
352        libc::pread(
353            file.as_raw_fd(),
354            buf.as_mut_ptr() as *mut libc::c_void,
355            buf.len(),
356            offset as libc::off_t,
357        )
358    };
359    if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) }
360}
361
362#[cfg(windows)]
363fn pwrite_aligned(
364    file: &std::fs::File,
365    buf: &[u8],
366    offset: usize,
367) -> std::io::Result<usize> {
368    use std::os::windows::io::AsRawHandle;
369    use windows_sys::Win32::Storage::FileSystem::WriteFile;
370    use windows_sys::Win32::System::IO::OVERLAPPED;
371    let mut ov: OVERLAPPED = unsafe { std::mem::zeroed() };
372    ov.Anonymous.Anonymous.Offset = (offset as u64 & 0xFFFF_FFFF) as u32;
373    ov.Anonymous.Anonymous.OffsetHigh = ((offset as u64) >> 32) as u32;
374    let mut written: u32 = 0;
375    let ok = unsafe {
376        WriteFile(
377            file.as_raw_handle() as _,
378            buf.as_ptr(),
379            buf.len() as u32,
380            &mut written,
381            &mut ov,
382        )
383    };
384    if ok == 0 { Err(std::io::Error::last_os_error()) } else { Ok(written as usize) }
385}
386
387#[cfg(windows)]
388fn pread_aligned(
389    file: &std::fs::File,
390    buf: &mut [u8],
391    offset: usize,
392) -> std::io::Result<usize> {
393    use std::os::windows::io::AsRawHandle;
394    use windows_sys::Win32::Storage::FileSystem::ReadFile;
395    use windows_sys::Win32::System::IO::OVERLAPPED;
396    let mut ov: OVERLAPPED = unsafe { std::mem::zeroed() };
397    ov.Anonymous.Anonymous.Offset = (offset as u64 & 0xFFFF_FFFF) as u32;
398    ov.Anonymous.Anonymous.OffsetHigh = ((offset as u64) >> 32) as u32;
399    let mut read: u32 = 0;
400    let ok = unsafe {
401        ReadFile(
402            file.as_raw_handle() as _,
403            buf.as_mut_ptr(),
404            buf.len() as u32,
405            &mut read,
406            &mut ov,
407        )
408    };
409    if ok == 0 { Err(std::io::Error::last_os_error()) } else { Ok(read as usize) }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn tmp(name: &str) -> PathBuf {
417        let mut p = std::env::temp_dir();
418        let pid = std::process::id();
419        let nonce = std::time::SystemTime::now()
420            .duration_since(std::time::UNIX_EPOCH)
421            .map(|d| d.as_nanos())
422            .unwrap_or(0);
423        p.push(format!("dfring_{pid}_{nonce}_{name}"));
424        p
425    }
426
427    #[test]
428    fn create_then_push_pop_round_trip() {
429        let path = tmp("rt");
430        let ring = DirectFileRing::create(&path, 4).expect("create");
431        let payload = b"hello unbuffered world";
432        ring.try_push(payload).expect("push");
433        let mut out = [0u8; DIRECT_FILE_SLOT_SIZE];
434        let n = ring.try_pop(&mut out).expect("pop");
435        assert_eq!(n, DIRECT_FILE_SLOT_SIZE);
436        assert_eq!(&out[..payload.len()], payload);
437    }
438
439    #[test]
440    fn fills_to_capacity_then_full() {
441        let path = tmp("fills");
442        let ring = DirectFileRing::create(&path, 4).expect("create");
443        for i in 0u8..4 {
444            ring.try_push(&[i; 16]).expect("push within cap");
445        }
446        assert!(matches!(
447            ring.try_push(&[0u8; 16]),
448            Err(DirectFileError::Full)
449        ));
450    }
451
452    #[test]
453    fn payload_too_large_rejected() {
454        let path = tmp("oversize");
455        let ring = DirectFileRing::create(&path, 4).expect("create");
456        let big = vec![0u8; DIRECT_FILE_SLOT_SIZE + 1];
457        assert!(matches!(
458            ring.try_push(&big),
459            Err(DirectFileError::PayloadTooLarge)
460        ));
461    }
462
463    /// Interleaved push/pop of many items, each round-trip verified in
464    /// order - exercises the wrap and the positioned-I/O path repeatedly.
465    #[test]
466    fn many_items_round_trip_in_order() {
467        let path = tmp("many");
468        let ring = DirectFileRing::create(&path, 8).expect("create");
469        let mut buf = [0u8; DIRECT_FILE_SLOT_SIZE];
470        for i in 0u64..500 {
471            ring.try_push(&i.to_le_bytes()).expect("push");
472            ring.try_pop(&mut buf).expect("pop");
473            let v = u64::from_le_bytes(buf[..8].try_into().unwrap());
474            assert_eq!(v, i, "in-order round trip at {i}");
475        }
476    }
477}