timeseries_table_format/storage/
output.rs1#[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
44struct 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 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 }
138
139pub struct OutputSink {
146 inner: OutputSinkInner,
147}
148
149impl OutputSink {
150 pub fn writer(&mut self) -> &mut dyn Write {
152 match &mut self.inner {
153 OutputSinkInner::Local(s) => s.writer(),
154 }
155 }
156
157 pub async fn finish(self) -> StorageResult<()> {
159 match self.inner {
160 OutputSinkInner::Local(mut s) => s.finish().await,
161 }
162 }
163}
164
165pub(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
185pub 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#[derive(Debug, Clone)]
208pub struct OutputLocation {
209 pub storage: StorageLocation,
211 pub rel_path: PathBuf,
213}
214
215impl OutputLocation {
216 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}