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