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 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
84struct 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 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 }
186
187pub struct OutputSink {
194 inner: OutputSinkInner,
195}
196
197impl OutputSink {
198 pub fn writer(&mut self) -> &mut dyn Write {
200 match &mut self.inner {
201 OutputSinkInner::Local(s) => s.writer(),
202 }
203 }
204
205 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
225pub(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
245pub 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#[derive(Debug, Clone)]
268pub struct OutputLocation {
269 pub storage: StorageLocation,
271 pub rel_path: PathBuf,
273}
274
275impl OutputLocation {
276 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}