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.
142/// `create_new` opens exclusively, failing with `AlreadyExists` when
143/// the path is already there.
144fn open_unbuffered(path: &Path, create_new: bool) -> std::io::Result<std::fs::File> {
145    let mut opts = std::fs::OpenOptions::new();
146    opts.read(true).write(true);
147    if create_new {
148        opts.create_new(true);
149    }
150    #[cfg(all(unix, not(target_os = "macos")))]
151    {
152        // Linux / FreeBSD: O_DIRECT bypasses the page cache at open(2).
153        use std::os::unix::fs::OpenOptionsExt;
154        opts.custom_flags(libc::O_DIRECT);
155    }
156    #[cfg(windows)]
157    {
158        use std::os::windows::fs::OpenOptionsExt;
159        use windows_sys::Win32::Storage::FileSystem::{
160            FILE_FLAG_NO_BUFFERING, FILE_FLAG_WRITE_THROUGH,
161        };
162        // NO_BUFFERING bypasses the cache (the O_DIRECT analogue);
163        // WRITE_THROUGH forces each write to the device so an
164        // independent reader process sees it.
165        opts.custom_flags(FILE_FLAG_NO_BUFFERING | FILE_FLAG_WRITE_THROUGH);
166    }
167    let file = opts.open(path)?;
168    #[cfg(target_os = "macos")]
169    {
170        // macOS has no O_DIRECT open flag; `fcntl(fd, F_NOCACHE, 1)` is the
171        // descriptor-level page-cache bypass, applied after open(2). It still
172        // requires the page-aligned buffers the O_DIRECT path uses. Best-effort:
173        // if it fails the descriptor stays cached, which is still correct.
174        use std::os::unix::io::AsRawFd;
175        // SAFETY: `file` owns a valid open descriptor for the duration of the call.
176        unsafe {
177            libc::fcntl(file.as_raw_fd(), libc::F_NOCACHE, 1);
178        }
179    }
180    Ok(file)
181}
182
183/// Non-mmap positioned-I/O ring with page-cache bypass.
184pub struct DirectFileRing {
185    data_file: std::fs::File,
186    head: Arc<SharedAtomicU64>,
187    tail: Arc<SharedAtomicU64>,
188    capacity: usize,
189    base_path: PathBuf,
190}
191
192unsafe impl Send for DirectFileRing {}
193unsafe impl Sync for DirectFileRing {}
194
195impl DirectFileRing {
196    /// Obtain the ring at `base_path` over three files:
197    /// `{base}.directfile.data.bin` (the slot array, unbuffered),
198    /// `{base}.directfile.head.bin` (head counter, MMF),
199    /// `{base}.directfile.tail.bin` (tail counter, MMF).
200    /// Initializes them only where they do not yet exist, and
201    /// otherwise attaches with queued slots and both counters in
202    /// place. The data file carries no header, so capacity is checked
203    /// against its exact byte size; a different capacity is a
204    /// `LayoutMismatch`. [`reset`](Self::reset) reinitializes.
205    pub fn create(
206        base_path: impl AsRef<Path>,
207        capacity: usize,
208    ) -> Result<Self, DirectFileError> {
209        assert!(capacity.is_power_of_two() && capacity >= 2,
210                "capacity must be pow2 >= 2");
211        let base = base_path.as_ref().to_path_buf();
212        let data_path = with_suffix(&base, ".directfile.data.bin");
213        let head_path = with_suffix(&base, ".directfile.head.bin");
214        let tail_path = with_suffix(&base, ".directfile.tail.bin");
215
216        let expected_size = (capacity * DIRECT_FILE_SLOT_SIZE) as u64;
217        let data_file = match open_unbuffered(&data_path, true) {
218            Ok(f) => {
219                f.set_len(expected_size)?;
220                f
221            }
222            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
223                let f = open_unbuffered(&data_path, false)?;
224                // The size doubles as the ready signal: the creator's
225                // set_len publishes it, so a mid-init file reads short.
226                let deadline = std::time::Instant::now() + crate::mmf_attach::INIT_WAIT;
227                loop {
228                    let len = f.metadata()?.len();
229                    if len == expected_size {
230                        break;
231                    }
232                    if len > expected_size {
233                        return Err(DirectFileError::LayoutMismatch);
234                    }
235                    if std::time::Instant::now() >= deadline {
236                        return Err(std::io::Error::new(
237                            std::io::ErrorKind::TimedOut,
238                            "the ring's creator did not finish initializing it",
239                        ).into());
240                    }
241                    std::thread::yield_now();
242                }
243                f
244            }
245            Err(e) => return Err(e.into()),
246        };
247
248        let head = Arc::new(SharedAtomicU64::create(&head_path, 0)
249            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
250        let tail = Arc::new(SharedAtomicU64::create(&tail_path, 0)
251            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
252
253        Ok(Self { data_file, head, tail, capacity, base_path: base })
254    }
255
256    /// Truncate the three files at `base_path` and initialize an
257    /// empty ring, discarding queued slots live peers hold. For a
258    /// caller that knows it owns the base path.
259    pub fn reset(
260        base_path: impl AsRef<Path>,
261        capacity: usize,
262    ) -> Result<Self, DirectFileError> {
263        assert!(capacity.is_power_of_two() && capacity >= 2,
264                "capacity must be pow2 >= 2");
265        let base = base_path.as_ref().to_path_buf();
266        let data_path = with_suffix(&base, ".directfile.data.bin");
267        let head_path = with_suffix(&base, ".directfile.head.bin");
268        let tail_path = with_suffix(&base, ".directfile.tail.bin");
269
270        let data_file = match open_unbuffered(&data_path, true) {
271            Ok(f) => f,
272            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
273                open_unbuffered(&data_path, false)?
274            }
275            Err(e) => return Err(e.into()),
276        };
277        data_file.set_len(0)?;
278        data_file.set_len((capacity * DIRECT_FILE_SLOT_SIZE) as u64)?;
279
280        let head = Arc::new(SharedAtomicU64::reset(&head_path, 0)
281            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
282        let tail = Arc::new(SharedAtomicU64::reset(&tail_path, 0)
283            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
284
285        Ok(Self { data_file, head, tail, capacity, base_path: base })
286    }
287
288    /// Open an existing ring at `base_path` (a second process).
289    pub fn open(
290        base_path: impl AsRef<Path>,
291        expected_capacity: usize,
292    ) -> Result<Self, DirectFileError> {
293        let base = base_path.as_ref().to_path_buf();
294        let data_path = with_suffix(&base, ".directfile.data.bin");
295        let head_path = with_suffix(&base, ".directfile.head.bin");
296        let tail_path = with_suffix(&base, ".directfile.tail.bin");
297
298        let data_file = open_unbuffered(&data_path, false)?;
299        let actual_size = data_file.metadata()?.len();
300        let expected_size = (expected_capacity * DIRECT_FILE_SLOT_SIZE) as u64;
301        // The data file carries no header; its exact byte size is the
302        // capacity record. A merely-large-enough file would let two
303        // processes disagree on the slot modulo.
304        if actual_size != expected_size {
305            return Err(DirectFileError::LayoutMismatch);
306        }
307
308        let head = Arc::new(SharedAtomicU64::open(&head_path)
309            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
310        let tail = Arc::new(SharedAtomicU64::open(&tail_path)
311            .map_err(|e| std::io::Error::other(format!("{e:?}")))?);
312
313        Ok(Self {
314            data_file, head, tail,
315            capacity: expected_capacity,
316            base_path: base,
317        })
318    }
319
320    /// Capacity in slots.
321    pub fn capacity(&self) -> usize { self.capacity }
322
323    /// Current head index.
324    pub fn head(&self) -> u64 { self.head.load(Ordering::Acquire) }
325
326    /// Current tail index.
327    pub fn tail(&self) -> u64 { self.tail.load(Ordering::Acquire) }
328
329    /// Push a payload: copy it into a page-aligned buffer and write it at
330    /// the head's slot offset with the page cache bypassed. Returns
331    /// Err(Full) when head - tail == capacity.
332    pub fn try_push(&self, payload: &[u8]) -> Result<(), DirectFileError> {
333        if payload.len() > DIRECT_FILE_SLOT_SIZE {
334            return Err(DirectFileError::PayloadTooLarge);
335        }
336        let head = self.head.load(Ordering::Relaxed);
337        let tail = self.tail.load(Ordering::Acquire);
338        if head.wrapping_sub(tail) >= self.capacity as u64 {
339            return Err(DirectFileError::Full);
340        }
341        let slot_offset = ((head as usize) & (self.capacity - 1))
342            * DIRECT_FILE_SLOT_SIZE;
343        let mut buf = AlignedBuf::new(DIRECT_FILE_SLOT_SIZE)?;
344        buf.as_mut_slice()[..payload.len()].copy_from_slice(payload);
345        let n = pwrite_aligned(&self.data_file, buf.as_slice(), slot_offset)?;
346        if n != DIRECT_FILE_SLOT_SIZE {
347            return Err(DirectFileError::Io(std::io::Error::other(
348                format!("partial write: {n} != {DIRECT_FILE_SLOT_SIZE}")
349            )));
350        }
351        self.head.store(head + 1, Ordering::Release);
352        Ok(())
353    }
354
355    /// Pop the oldest payload: read the tail's slot into a page-aligned
356    /// buffer (page cache bypassed), copy the relevant bytes to `out`,
357    /// and advance the tail.
358    pub fn try_pop(&self, out: &mut [u8]) -> Result<usize, DirectFileError> {
359        let tail = self.tail.load(Ordering::Relaxed);
360        let head = self.head.load(Ordering::Acquire);
361        if tail == head {
362            return Err(DirectFileError::Empty);
363        }
364        let slot_offset = ((tail as usize) & (self.capacity - 1))
365            * DIRECT_FILE_SLOT_SIZE;
366        let mut buf = AlignedBuf::new(DIRECT_FILE_SLOT_SIZE)?;
367        let n = pread_aligned(&self.data_file, buf.as_mut_slice(), slot_offset)?;
368        if n != DIRECT_FILE_SLOT_SIZE {
369            return Err(DirectFileError::Io(std::io::Error::other(
370                format!("partial read: {n} != {DIRECT_FILE_SLOT_SIZE}")
371            )));
372        }
373        let copy_len = out.len().min(DIRECT_FILE_SLOT_SIZE);
374        out[..copy_len].copy_from_slice(&buf.as_slice()[..copy_len]);
375        self.tail.store(tail + 1, Ordering::Release);
376        Ok(copy_len)
377    }
378}
379
380impl Drop for DirectFileRing {
381    fn drop(&mut self) {
382        let data_path = with_suffix(&self.base_path, ".directfile.data.bin");
383        let head_path = with_suffix(&self.base_path, ".directfile.head.bin");
384        let tail_path = with_suffix(&self.base_path, ".directfile.tail.bin");
385        std::fs::remove_file(&data_path).ok();
386        std::fs::remove_file(&head_path).ok();
387        std::fs::remove_file(&tail_path).ok();
388    }
389}
390
391fn with_suffix(base: &Path, suffix: &str) -> PathBuf {
392    let mut s = base.as_os_str().to_owned();
393    s.push(suffix);
394    PathBuf::from(s)
395}
396
397#[cfg(unix)]
398fn pwrite_aligned(
399    file: &std::fs::File,
400    buf: &[u8],
401    offset: usize,
402) -> std::io::Result<usize> {
403    use std::os::unix::io::AsRawFd;
404    let n = unsafe {
405        libc::pwrite(
406            file.as_raw_fd(),
407            buf.as_ptr() as *const libc::c_void,
408            buf.len(),
409            offset as libc::off_t,
410        )
411    };
412    if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) }
413}
414
415#[cfg(unix)]
416fn pread_aligned(
417    file: &std::fs::File,
418    buf: &mut [u8],
419    offset: usize,
420) -> std::io::Result<usize> {
421    use std::os::unix::io::AsRawFd;
422    let n = unsafe {
423        libc::pread(
424            file.as_raw_fd(),
425            buf.as_mut_ptr() as *mut libc::c_void,
426            buf.len(),
427            offset as libc::off_t,
428        )
429    };
430    if n < 0 { Err(std::io::Error::last_os_error()) } else { Ok(n as usize) }
431}
432
433#[cfg(windows)]
434fn pwrite_aligned(
435    file: &std::fs::File,
436    buf: &[u8],
437    offset: usize,
438) -> std::io::Result<usize> {
439    use std::os::windows::io::AsRawHandle;
440    use windows_sys::Win32::Storage::FileSystem::WriteFile;
441    use windows_sys::Win32::System::IO::OVERLAPPED;
442    let mut ov: OVERLAPPED = unsafe { std::mem::zeroed() };
443    ov.Anonymous.Anonymous.Offset = (offset as u64 & 0xFFFF_FFFF) as u32;
444    ov.Anonymous.Anonymous.OffsetHigh = ((offset as u64) >> 32) as u32;
445    let mut written: u32 = 0;
446    let ok = unsafe {
447        WriteFile(
448            file.as_raw_handle() as _,
449            buf.as_ptr(),
450            buf.len() as u32,
451            &mut written,
452            &mut ov,
453        )
454    };
455    if ok == 0 { Err(std::io::Error::last_os_error()) } else { Ok(written as usize) }
456}
457
458#[cfg(windows)]
459fn pread_aligned(
460    file: &std::fs::File,
461    buf: &mut [u8],
462    offset: usize,
463) -> std::io::Result<usize> {
464    use std::os::windows::io::AsRawHandle;
465    use windows_sys::Win32::Storage::FileSystem::ReadFile;
466    use windows_sys::Win32::System::IO::OVERLAPPED;
467    let mut ov: OVERLAPPED = unsafe { std::mem::zeroed() };
468    ov.Anonymous.Anonymous.Offset = (offset as u64 & 0xFFFF_FFFF) as u32;
469    ov.Anonymous.Anonymous.OffsetHigh = ((offset as u64) >> 32) as u32;
470    let mut read: u32 = 0;
471    let ok = unsafe {
472        ReadFile(
473            file.as_raw_handle() as _,
474            buf.as_mut_ptr(),
475            buf.len() as u32,
476            &mut read,
477            &mut ov,
478        )
479    };
480    if ok == 0 { Err(std::io::Error::last_os_error()) } else { Ok(read as usize) }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    fn tmp(name: &str) -> PathBuf {
488        let mut p = std::env::temp_dir();
489        let pid = std::process::id();
490        let nonce = std::time::SystemTime::now()
491            .duration_since(std::time::UNIX_EPOCH)
492            .map(|d| d.as_nanos())
493            .unwrap_or(0);
494        p.push(format!("dfring_{pid}_{nonce}_{name}"));
495        p
496    }
497
498    #[test]
499    fn create_then_push_pop_round_trip() {
500        let path = tmp("rt");
501        let ring = DirectFileRing::create(&path, 4).expect("create");
502        let payload = b"hello unbuffered world";
503        ring.try_push(payload).expect("push");
504        let mut out = [0u8; DIRECT_FILE_SLOT_SIZE];
505        let n = ring.try_pop(&mut out).expect("pop");
506        assert_eq!(n, DIRECT_FILE_SLOT_SIZE);
507        assert_eq!(&out[..payload.len()], payload);
508    }
509
510    /// A second create attaches with queued slots in place; reset is
511    /// what strips them.
512    #[test]
513    fn second_create_attaches_and_keeps_slots() {
514        let path = tmp("attach");
515        let ring = DirectFileRing::create(&path, 4).expect("create");
516        let payload = b"slot survives attach";
517        ring.try_push(payload).expect("push");
518
519        let ring2 = DirectFileRing::create(&path, 4).expect("second create");
520        let mut out = [0u8; DIRECT_FILE_SLOT_SIZE];
521        let n = ring2.try_pop(&mut out).expect("pop after attach");
522        assert_eq!(n, DIRECT_FILE_SLOT_SIZE);
523        assert_eq!(&out[..payload.len()], payload, "attach lost a queued slot");
524        assert!(matches!(
525            DirectFileRing::create(&path, 2),
526            Err(DirectFileError::LayoutMismatch),
527        ));
528
529        drop(ring);
530        drop(ring2);
531        let fresh = DirectFileRing::reset(&path, 4).expect("reset");
532        assert!(matches!(
533            fresh.try_pop(&mut out),
534            Err(DirectFileError::Empty),
535        ), "reset kept a queued slot");
536    }
537
538    #[test]
539    fn fills_to_capacity_then_full() {
540        let path = tmp("fills");
541        let ring = DirectFileRing::create(&path, 4).expect("create");
542        for i in 0u8..4 {
543            ring.try_push(&[i; 16]).expect("push within cap");
544        }
545        assert!(matches!(
546            ring.try_push(&[0u8; 16]),
547            Err(DirectFileError::Full)
548        ));
549    }
550
551    #[test]
552    fn payload_too_large_rejected() {
553        let path = tmp("oversize");
554        let ring = DirectFileRing::create(&path, 4).expect("create");
555        let big = vec![0u8; DIRECT_FILE_SLOT_SIZE + 1];
556        assert!(matches!(
557            ring.try_push(&big),
558            Err(DirectFileError::PayloadTooLarge)
559        ));
560    }
561
562    /// Interleaved push/pop of many items, each round-trip verified in
563    /// order - exercises the wrap and the positioned-I/O path repeatedly.
564    #[test]
565    fn many_items_round_trip_in_order() {
566        let path = tmp("many");
567        let ring = DirectFileRing::create(&path, 8).expect("create");
568        let mut buf = [0u8; DIRECT_FILE_SLOT_SIZE];
569        for i in 0u64..500 {
570            ring.try_push(&i.to_le_bytes()).expect("push");
571            ring.try_pop(&mut buf).expect("pop");
572            let v = u64::from_le_bytes(buf[..8].try_into().unwrap());
573            assert_eq!(v, i, "in-order round trip at {i}");
574        }
575    }
576}