Skip to main content

flowlog_runtime/
io.rs

1//! I/O and partition helpers used by the generated engine code.
2//!
3//! - [`partition`]: split an owned `Vec` into per-worker slices for the
4//!   library-mode batch engine's ingest path.
5//! - [`byte_range_reader`]: split a CSV file across timely workers so each
6//!   reads its own byte slice (binary mode).
7//! - [`shard_int`] / [`shard_str`] / [`shard_spur`]: pick the owning worker
8//!   for a tuple based on its first column (binary mode).
9//! - [`write_atomic`]: write a file via a temp sibling and rename so a
10//!   reader never sees a half-written file.
11
12use std::fs::File;
13use std::io;
14use std::io::BufRead;
15use std::io::BufReader;
16use std::io::BufWriter;
17use std::io::Read;
18use std::io::Seek;
19use std::io::SeekFrom;
20use std::io::Write;
21use std::path::Path;
22
23use lasso::Spur;
24use tempfile::NamedTempFile;
25
26// =========================================================================
27// Per-worker partitioning
28// =========================================================================
29
30/// Split `v` into `n` roughly-equal owned partitions, in order.
31///
32/// Each element moves by value into its partition (no `Arc` sharing, no
33/// per-tuple clone), so a consumer takes ownership of its slice directly.
34///
35/// `n.max(1)` partitions are produced; if `v.len() < n` some partitions
36/// are empty. The last partition absorbs any remainder when the division
37/// doesn't come out evenly.
38pub fn partition<T>(v: Vec<T>, n: usize) -> Vec<Vec<T>> {
39    let n = n.max(1);
40    let chunk = v.len() / n;
41    let mut iter = v.into_iter();
42    (0..n)
43        .map(|i| {
44            let take = if i + 1 == n { iter.len() } else { chunk };
45            iter.by_ref().take(take).collect()
46        })
47        .collect()
48}
49
50// =========================================================================
51// Byte-range file reader
52// =========================================================================
53
54/// Open a byte-range slice of `path` for worker `index` out of `peers`.
55///
56/// Returns `Some((reader, bytes_to_read))` on success. The reader is
57/// pre-seeked to the start of the worker's range (aligned to the next
58/// line boundary for non-zero workers). The caller should read up to
59/// `bytes_to_read` bytes, stopping at the first complete line beyond
60/// that budget.
61///
62/// Returns `None` on I/O error (logged to stderr).
63pub fn byte_range_reader(
64    path: &Path,
65    index: usize,
66    peers: usize,
67) -> Option<(BufReader<File>, u64)> {
68    let mut file = File::open(path)
69        .inspect_err(|e| {
70            eprintln!(
71                "[flowlog-runtime::io] failed to open {}: {e}",
72                path.display()
73            );
74        })
75        .ok()?;
76
77    let file_size = file
78        .metadata()
79        .inspect_err(|e| {
80            eprintln!(
81                "[flowlog-runtime::io] failed to stat {}: {e}",
82                path.display()
83            );
84        })
85        .ok()?
86        .len();
87
88    let chunk = file_size / peers as u64;
89    let start = chunk * index as u64;
90    let end = if index == peers - 1 {
91        file_size
92    } else {
93        chunk * (index + 1) as u64
94    };
95
96    // Nothing to read for this worker.
97    if start >= end {
98        return Some((BufReader::new(file), 0));
99    }
100
101    // Any worker whose range begins at byte 0 reads from the start with no
102    // alignment skip; there's no previous byte to peek at. Worker 0 always
103    // hits this; others hit it when `chunk == 0` (peers > file_size), which
104    // puts the whole file on the last worker.
105    if start == 0 {
106        return Some((BufReader::new(file), end));
107    }
108
109    // Non-zero start: seek to `start - 1` and peek the byte just before our
110    // range. If it's a newline we're on a line boundary; otherwise skip the
111    // rest of the partial line.
112    if file.seek(SeekFrom::Start(start - 1)).is_err() {
113        return Some((BufReader::new(file), 0));
114    }
115
116    let mut reader = BufReader::new(file);
117    let mut peek = [0u8; 1];
118    if reader.read_exact(&mut peek).is_err() {
119        return Some((reader, 0));
120    }
121
122    if peek[0] == b'\n' {
123        // Exactly on a line boundary.
124        return Some((reader, end - start));
125    }
126
127    // Mid-line: skip the rest of this partial line.
128    let mut discard = Vec::new();
129    let skipped = reader.read_until(b'\n', &mut discard).unwrap_or(0);
130    Some((reader, (end - start).saturating_sub(skipped as u64)))
131}
132
133// =========================================================================
134// First-column sharding
135// =========================================================================
136
137/// Shard an integer-typed first column across `peers` workers.
138///
139/// Returns `true` if worker `index` owns this tuple.
140#[inline]
141pub fn shard_int(first: i64, peers: usize, index: usize) -> bool {
142    first.rem_euclid(peers as i64) as usize == index
143}
144
145/// Shard a string-typed first column across `peers` workers.
146///
147/// Returns `true` if worker `index` owns this tuple, hashing with 32-bit
148/// FNV-1a for a uniform distribution.
149#[inline]
150pub fn shard_str(first: &str, peers: usize, index: usize) -> bool {
151    let mut hash: u32 = 0x811c9dc5;
152    for &b in first.as_bytes() {
153        hash ^= b as u32;
154        hash = hash.wrapping_mul(0x01000193);
155    }
156    (hash as usize) % peers == index
157}
158
159/// Shard an interned-string first column ([`lasso::Spur`]) across `peers`.
160///
161/// Returns `true` if worker `index` owns this tuple.
162#[inline]
163pub fn shard_spur(first: Spur, peers: usize, index: usize) -> bool {
164    (first.into_inner().get() as usize) % peers == index
165}
166
167// =========================================================================
168// Atomic file write
169// =========================================================================
170
171/// Write `path` atomically: stream through `write` into a temp file in the
172/// same directory, then persist it over `path` in a single rename. A failed
173/// or interrupted write leaves `path` untouched, so a concurrent reader never
174/// observes a half-written file. Delegates the platform-specific atomic
175/// replace to `tempfile`, which handles the Unix and Windows differences.
176///
177/// The temp file is a sibling of `path` so the rename stays within one
178/// filesystem (a metadata move, not a copy). `path` must have a parent or be
179/// relative to the current directory.
180pub fn write_atomic(
181    path: impl AsRef<Path>,
182    write: impl FnOnce(&mut dyn Write) -> io::Result<()>,
183) -> io::Result<()> {
184    let path = path.as_ref();
185    let mut tmp = match path.parent().filter(|p| !p.as_os_str().is_empty()) {
186        Some(dir) => NamedTempFile::new_in(dir)?,
187        None => NamedTempFile::new()?,
188    };
189    {
190        let mut buf = BufWriter::new(&mut tmp);
191        write(&mut buf)?;
192        buf.flush()?;
193    }
194    tmp.persist(path).map_err(|e| e.error)?;
195    Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    /// A completed write leaves the destination with exactly the bytes
203    /// written and no leftover temp sibling in the directory.
204    #[test]
205    fn write_atomic_persists_content_and_leaves_no_temp() {
206        let dir = tempfile::tempdir().expect("temp dir");
207        let path = dir.path().join("out.log");
208        write_atomic(&path, |w| write!(w, "hello")).expect("write");
209
210        assert_eq!(std::fs::read_to_string(&path).expect("read"), "hello");
211        let names: Vec<_> = std::fs::read_dir(dir.path())
212            .expect("read dir")
213            .map(|e| e.expect("entry").file_name())
214            .collect();
215        assert_eq!(
216            names.len(),
217            1,
218            "only the persisted file should remain: {names:?}"
219        );
220    }
221
222    /// A second write replaces the destination rather than appending or
223    /// erroring on the existing file.
224    #[test]
225    fn write_atomic_overwrites_existing() {
226        let dir = tempfile::tempdir().expect("temp dir");
227        let path = dir.path().join("out.log");
228        write_atomic(&path, |w| write!(w, "first")).expect("first");
229        write_atomic(&path, |w| write!(w, "second")).expect("second");
230
231        assert_eq!(std::fs::read_to_string(&path).expect("read"), "second");
232    }
233
234    /// The atomicity guarantee: a closure error propagates, the existing
235    /// destination keeps its old contents (the write never clobbers the
236    /// target), and the temp sibling is cleaned up rather than left behind.
237    #[test]
238    fn write_atomic_failed_write_preserves_existing() {
239        let dir = tempfile::tempdir().expect("temp dir");
240        let path = dir.path().join("out.log");
241        write_atomic(&path, |w| write!(w, "original")).expect("seed");
242
243        let err = write_atomic(&path, |w| {
244            write!(w, "partial")?;
245            Err(io::Error::other("boom"))
246        })
247        .expect_err("closure error must propagate");
248        assert_eq!(err.to_string(), "boom");
249
250        assert_eq!(std::fs::read_to_string(&path).expect("read"), "original");
251        let names: Vec<_> = std::fs::read_dir(dir.path())
252            .expect("read dir")
253            .map(|e| e.expect("entry").file_name())
254            .collect();
255        assert_eq!(
256            names.len(),
257            1,
258            "temp sibling should be cleaned up: {names:?}"
259        );
260    }
261}