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}