Skip to main content

timeseries_table_format/storage/
output.rs

1#[cfg(test)]
2use std::{
3    collections::HashSet,
4    sync::{LazyLock, Mutex},
5};
6use std::{
7    io::{self, Write},
8    path::{Path, PathBuf},
9};
10
11use snafu::{IntoError, ResultExt};
12use tokio::fs;
13
14use crate::storage::{
15    FileCleanupGuard, OtherIoSnafu, StorageBackendError, StorageLocation, StorageResult,
16    create_new_file, create_parent_dir, join_local,
17};
18
19#[cfg(test)]
20static FINISH_FAILURES: LazyLock<Mutex<HashSet<PathBuf>>> =
21    LazyLock::new(|| Mutex::new(HashSet::new()));
22
23#[cfg(test)]
24static WRITE_FAILURES: LazyLock<Mutex<Vec<(PathBuf, usize)>>> =
25    LazyLock::new(|| Mutex::new(Vec::new()));
26
27#[cfg(test)]
28pub(crate) fn inject_output_finish_failure(path: PathBuf) {
29    FINISH_FAILURES
30        .lock()
31        .unwrap_or_else(|poisoned| poisoned.into_inner())
32        .insert(path);
33}
34
35#[cfg(test)]
36pub(crate) fn inject_output_write_failure(path_prefix: PathBuf, write_number: usize) {
37    assert!(write_number > 0);
38    WRITE_FAILURES
39        .lock()
40        .unwrap_or_else(|poisoned| poisoned.into_inner())
41        .push((path_prefix, write_number));
42}
43
44#[cfg(test)]
45fn take_output_finish_failure(path: &Path) -> bool {
46    let mut failures = FINISH_FAILURES
47        .lock()
48        .unwrap_or_else(|poisoned| poisoned.into_inner());
49    let Some(target) = failures
50        .iter()
51        .find(|target| path.starts_with(target))
52        .cloned()
53    else {
54        return false;
55    };
56    failures.remove(&target)
57}
58
59#[cfg(test)]
60fn take_output_write_failure(path: &Path) -> bool {
61    let mut failures = WRITE_FAILURES
62        .lock()
63        .unwrap_or_else(|poisoned| poisoned.into_inner());
64    let Some(index) = failures
65        .iter()
66        .position(|(prefix, _)| path.starts_with(prefix))
67    else {
68        return false;
69    };
70    if failures[index].1 == 1 {
71        failures.remove(index);
72        true
73    } else {
74        failures[index].1 -= 1;
75        false
76    }
77}
78
79enum LocalFinish {
80    Rename(PathBuf),
81    Keep,
82}
83
84/// Local filesystem sink that either renames or keeps its path on finish.
85struct LocalSink {
86    path: PathBuf,
87    finish: LocalFinish,
88    writer: io::BufWriter<std::fs::File>,
89    guard: FileCleanupGuard,
90}
91
92impl LocalSink {
93    async fn open(location: &StorageLocation, rel_path: &Path) -> StorageResult<Self> {
94        let final_path = join_local(location, rel_path)?;
95        create_parent_dir(&final_path).await?;
96
97        let tmp_path = final_path.with_extension("tmp");
98
99        // Use std::fs::File because Arrow writers require std::io::Write.
100        let file = std::fs::File::create(&tmp_path)
101            .map_err(StorageBackendError::from)
102            .context(OtherIoSnafu {
103                path: tmp_path.display().to_string(),
104            })?;
105
106        let writer = io::BufWriter::new(file);
107        let guard = FileCleanupGuard::new_armed(tmp_path.clone());
108
109        Ok(Self {
110            path: tmp_path,
111            finish: LocalFinish::Rename(final_path),
112            writer,
113            guard,
114        })
115    }
116
117    async fn open_new(location: &StorageLocation, rel_path: &Path) -> StorageResult<Self> {
118        let path = join_local(location, rel_path)?;
119        let file = create_new_file(&path).await?;
120        let writer = io::BufWriter::new(file);
121        let guard = FileCleanupGuard::new_armed(path.clone());
122        Ok(Self {
123            path,
124            finish: LocalFinish::Keep,
125            writer,
126            guard,
127        })
128    }
129
130    fn writer(&mut self) -> &mut dyn Write {
131        &mut self.writer
132    }
133
134    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
135        #[cfg(test)]
136        if take_output_write_failure(&self.path) {
137            return Err(io::Error::other("injected output write failure"));
138        }
139        self.writer.write(bytes)
140    }
141
142    async fn finish(&mut self) -> StorageResult<()> {
143        self.writer
144            .flush()
145            .map_err(StorageBackendError::from)
146            .context(OtherIoSnafu {
147                path: self.path.display().to_string(),
148            })?;
149
150        self.writer
151            .get_ref()
152            .sync_all()
153            .map_err(StorageBackendError::from)
154            .context(OtherIoSnafu {
155                path: self.path.display().to_string(),
156            })?;
157
158        #[cfg(test)]
159        if take_output_finish_failure(&self.path) {
160            return Err(OtherIoSnafu {
161                path: self.path.display().to_string(),
162            }
163            .into_error(StorageBackendError::from(io::Error::other(
164                "injected output finish failure",
165            ))));
166        }
167
168        if let LocalFinish::Rename(final_path) = &self.finish {
169            fs::rename(&self.path, final_path)
170                .await
171                .map_err(StorageBackendError::from)
172                .context(OtherIoSnafu {
173                    path: final_path.display().to_string(),
174                })?;
175        }
176
177        self.guard.disarm();
178        Ok(())
179    }
180}
181
182enum OutputSinkInner {
183    Local(LocalSink),
184    // S3(S3Sink),
185}
186
187/// A streaming output sink for writing bytes to a storage backend.
188///
189/// This type abstracts over backend-specific sink implementations. Callers
190/// obtain a sink via `open_output_sink` and then stream bytes through the
191/// `writer()` handle. Finalization is explicit via `finish()` to allow
192/// backend-specific commit semantics (e.g., atomic rename or multipart upload).
193pub struct OutputSink {
194    inner: OutputSinkInner,
195}
196
197impl OutputSink {
198    /// Return a mutable Write handle for streaming bytes.
199    pub fn writer(&mut self) -> &mut dyn Write {
200        match &mut self.inner {
201            OutputSinkInner::Local(s) => s.writer(),
202        }
203    }
204
205    /// Flush, fsync, and commit to final location.
206    pub async fn finish(self) -> StorageResult<()> {
207        match self.inner {
208            OutputSinkInner::Local(mut s) => s.finish().await,
209        }
210    }
211}
212
213impl Write for OutputSink {
214    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
215        match &mut self.inner {
216            OutputSinkInner::Local(s) => s.write(bytes),
217        }
218    }
219
220    fn flush(&mut self) -> io::Result<()> {
221        self.writer().flush()
222    }
223}
224
225/// Open a streaming output sink with exclusive creation.
226///
227/// The final path is created immediately and removed if the sink is dropped
228/// before [`OutputSink::finish`] succeeds.
229///
230/// # Errors
231///
232/// Returns [`crate::storage::StorageError::AlreadyExists`] when `rel_path`
233/// already exists, or another storage error when creation fails.
234pub(crate) async fn open_new_output_sink(
235    location: &StorageLocation,
236    rel_path: &Path,
237) -> StorageResult<OutputSink> {
238    match location {
239        StorageLocation::Local(_) => Ok(OutputSink {
240            inner: OutputSinkInner::Local(LocalSink::open_new(location, rel_path).await?),
241        }),
242    }
243}
244
245/// Open a streaming output sink at `location` + `rel_path`.
246///
247/// The `location` identifies the backend root, while `rel_path` identifies
248/// the object/key within that backend. For local filesystems this performs a
249/// temp-file write and atomic rename on `finish()`.
250///
251/// v0.1: only StorageLocation::Local is supported.
252pub async fn open_output_sink(
253    location: &StorageLocation,
254    rel_path: &Path,
255) -> StorageResult<OutputSink> {
256    match location {
257        StorageLocation::Local(_) => {
258            let sink = LocalSink::open(location, rel_path).await?;
259            Ok(OutputSink {
260                inner: OutputSinkInner::Local(sink),
261            })
262        }
263    }
264}
265
266/// Fully-qualified output target: backend + relative path/key.
267#[derive(Debug, Clone)]
268pub struct OutputLocation {
269    /// Backend where the output will be written.
270    pub storage: StorageLocation,
271    /// Path within the backend for the output object.
272    pub rel_path: PathBuf,
273}
274
275impl OutputLocation {
276    /// Parse a string specification into an `OutputLocation`, validating it is non-empty and supported.
277    pub fn parse(spec: &str) -> StorageResult<OutputLocation> {
278        let trimmed = spec.trim();
279        if trimmed.is_empty() {
280            return Err(OtherIoSnafu {
281                path: "<empty output location>".to_string(),
282            }
283            .into_error(StorageBackendError::from(std::io::Error::new(
284                io::ErrorKind::InvalidInput,
285                "output location is empty",
286            ))));
287        }
288
289        let storage = StorageLocation::parse(trimmed)?;
290
291        match &storage {
292            StorageLocation::Local(_) => {
293                let path = PathBuf::from(trimmed);
294                let rel_path = path.file_name().ok_or_else(|| {
295                    OtherIoSnafu {
296                        path: trimmed.to_string(),
297                    }
298                    .into_error(StorageBackendError::from(
299                        std::io::Error::new(
300                            io::ErrorKind::InvalidInput,
301                            "output location has no file name",
302                        ),
303                    ))
304                })?;
305                let base = path
306                    .parent()
307                    .filter(|parent| !parent.as_os_str().is_empty())
308                    .unwrap_or_else(|| Path::new("."));
309
310                Ok(OutputLocation {
311                    storage: StorageLocation::Local(base.to_path_buf()),
312                    rel_path: PathBuf::from(rel_path),
313                })
314            }
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::storage::StorageError;
323    use tempfile::TempDir;
324
325    type TestResult = Result<(), Box<dyn std::error::Error>>;
326
327    #[tokio::test]
328    async fn new_output_sink_creates_exclusively_and_finishes() -> TestResult {
329        let temp = TempDir::new()?;
330        let location = StorageLocation::local(temp.path());
331        let path = Path::new("staged/output.parquet");
332        let mut sink = open_new_output_sink(&location, path).await?;
333        sink.writer().write_all(b"parquet")?;
334        sink.finish().await?;
335
336        assert_eq!(tokio::fs::read(temp.path().join(path)).await?, b"parquet");
337        Ok(())
338    }
339
340    #[tokio::test]
341    async fn new_output_sink_preserves_an_existing_object() -> TestResult {
342        let temp = TempDir::new()?;
343        let location = StorageLocation::local(temp.path());
344        let path = Path::new("staged/existing.parquet");
345        crate::storage::write_new(&location, path, b"existing").await?;
346
347        let error = match open_new_output_sink(&location, path).await {
348            Ok(_) => panic!("existing output must not be replaced"),
349            Err(error) => error,
350        };
351        assert!(matches!(error, StorageError::AlreadyExists { .. }));
352        assert_eq!(tokio::fs::read(temp.path().join(path)).await?, b"existing");
353        Ok(())
354    }
355
356    #[tokio::test]
357    async fn dropping_unfinished_new_output_removes_it() -> TestResult {
358        let temp = TempDir::new()?;
359        let location = StorageLocation::local(temp.path());
360        let path = Path::new("staged/unfinished.parquet");
361        let mut sink = open_new_output_sink(&location, path).await?;
362        sink.writer().write_all(b"incomplete")?;
363        drop(sink);
364
365        assert!(!temp.path().join(path).exists());
366        Ok(())
367    }
368}