1use crate::analysis::sampling::ReadWatch;
11use crate::cloud::download::{self, StreamError};
12use color_eyre::Result;
13use color_eyre::eyre::eyre;
14use polars::lazy::dsl::{DslPlan, ScanSources};
15use polars::prelude::{LazyFrame, PlRefPath};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20pub const COPIES_DIR: &str = "quality-copies";
22
23const HELD: &str = ".held";
25
26const UNHELD_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RemoteObject {
33 pub url: String,
34 pub size: u64,
35 pub etag: Option<String>,
37}
38
39#[derive(Debug)]
43pub struct LocalCopy {
44 _held: std::fs::File,
47 dir: tempfile::TempDir,
48 paths: HashMap<String, PathBuf>,
49 bytes: u64,
50}
51
52impl LocalCopy {
53 pub fn bytes(&self) -> u64 {
55 self.bytes
56 }
57
58 pub fn objects(&self) -> usize {
60 self.paths.len()
61 }
62
63 #[cfg(test)]
64 pub fn dir(&self) -> &Path {
65 self.dir.path()
66 }
67
68 pub fn covers(&self, url: &str) -> bool {
70 self.paths.contains_key(url)
71 }
72
73 pub fn fetch(
81 root: &Path,
82 objects: &[RemoteObject],
83 stop: &ReadWatch,
84 mut get: impl FnMut(
85 &RemoteObject,
86 &mut dyn FnMut(&[u8]) -> Result<()>,
87 ) -> std::result::Result<u64, StreamError>,
88 ) -> Result<LocalCopy> {
89 std::fs::create_dir_all(root).map_err(unwritable)?;
90 sweep(root);
91 let dir = tempfile::Builder::new()
92 .prefix("copy-")
93 .tempdir_in(root)
94 .map_err(unwritable)?;
95 let held = hold(dir.path())?;
96 let mut copy = LocalCopy {
97 _held: held,
98 dir,
99 paths: HashMap::with_capacity(objects.len()),
100 bytes: 0,
101 };
102 for object in objects {
103 stop.check()?;
104 let path = copy.dir.path().join(relative_path(&object.url));
105 if let Some(parent) = path.parent() {
106 std::fs::create_dir_all(parent).map_err(unwritable)?;
107 }
108 let mut file = std::fs::File::create_new(&path).map_err(unwritable)?;
111 let written = download::fill_file(&mut file, unwritable, |write| get(object, write))
112 .map_err(|error| not_copied(object, error))?;
113 if written != object.size {
114 return Err(changed(object, written));
115 }
116 copy.bytes += written;
117 copy.paths.insert(object.url.clone(), path);
118 }
119 Ok(copy)
120 }
121
122 pub fn redirect(&self, lf: &LazyFrame) -> Option<LazyFrame> {
126 let mut plan = lf.logical_plan.clone();
127 if !redirect_plan(&mut plan, &self.paths) || reads_remote(&plan) {
128 return None;
129 }
130 Some(LazyFrame::from(plan).with_optimizations(lf.get_current_optimizations()))
131 }
132}
133
134fn unwritable(error: std::io::Error) -> color_eyre::Report {
136 eyre!(
137 "Could not write the local copy: {error}. \
138 quality_local_copy = 0 reads the source instead"
139 )
140}
141
142fn not_copied(object: &RemoteObject, error: StreamError) -> color_eyre::Report {
144 let url = &object.url;
145 match error {
146 StreamError::Write(report) => report,
147 StreamError::Open(e) if crate::error_display::says_gone_since_opened(&e) => eyre!(e),
149 StreamError::Open(e) | StreamError::Read(e) => eyre!("Could not copy {url}: {e}"),
150 StreamError::Short { got, .. } => changed(object, got),
151 StreamError::Cut => eyre!(crate::analysis::sampling::CANCELLED),
152 }
153}
154
155fn changed(object: &RemoteObject, got: u64) -> color_eyre::Report {
157 eyre!(
158 "{} is {got} bytes, not the {} listed when it opened: \
159 it changed. Open the dataset again",
160 object.url,
161 object.size
162 )
163}
164
165pub fn same_etag(listed: &str, fetched: &str) -> bool {
168 let bare = |tag: &str| {
169 tag.trim()
170 .trim_start_matches("W/")
171 .trim_matches('"')
172 .to_string()
173 };
174 bare(listed) == bare(fetched)
175}
176
177fn hold(dir: &Path) -> Result<std::fs::File> {
179 use fs2::FileExt;
180 let file = std::fs::File::create(dir.join(HELD)).map_err(unwritable)?;
181 file.try_lock_exclusive().map_err(unwritable)?;
182 Ok(file)
183}
184
185pub fn sweep(root: &Path) {
189 use fs2::FileExt;
190 let Ok(entries) = std::fs::read_dir(root) else {
191 return;
192 };
193 for entry in entries.flatten() {
194 let path = entry.path();
195 let ours = path.is_dir()
196 && path
197 .file_name()
198 .and_then(|name| name.to_str())
199 .is_some_and(|name| name.starts_with("copy-"));
200 if !ours {
201 continue;
202 }
203 let old = |meta: std::io::Result<std::fs::Metadata>| {
204 meta.and_then(|meta| meta.modified())
205 .ok()
206 .and_then(|modified| modified.elapsed().ok())
207 .is_some_and(|age| age > UNHELD_GRACE)
208 };
209 let orphaned = match std::fs::File::open(path.join(HELD)) {
210 Ok(file) => {
211 let free = file.try_lock_exclusive().is_ok();
212 if free {
213 let _ = fs2::FileExt::unlock(&file);
214 }
215 free && old(file.metadata())
216 }
217 Err(_) => old(entry.metadata()),
218 };
219 if orphaned {
220 let _ = std::fs::remove_dir_all(&path);
221 }
222 }
223}
224
225pub fn free_space(dir: &Path) -> Option<u64> {
227 let existing = dir.ancestors().find(|path| path.exists())?;
228 fs2::available_space(existing).ok()
229}
230
231fn relative_path(url: &str) -> PathBuf {
235 let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
236 rest.split('/')
237 .filter(|part| !part.is_empty())
238 .map(|part| match part {
239 "." | ".." => "_".to_string(),
240 part => safe_component(part, cfg!(windows)),
241 })
242 .collect()
243}
244
245fn safe_component(part: &str, windows: bool) -> String {
249 if !windows {
250 return part.to_string();
251 }
252 part.chars()
253 .map(|c| match c {
254 '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*' => format!("%{:02X}", c as u32),
255 c => c.to_string(),
256 })
257 .collect()
258}
259
260fn is_remote(path: &str) -> bool {
261 crate::cloud::source::is_remote_url(Path::new(path))
262}
263
264#[cfg(test)]
266pub fn scan_paths(lf: &LazyFrame) -> Vec<String> {
267 let mut paths = Vec::new();
268 for node in &lf.logical_plan {
269 if let DslPlan::Scan {
270 sources: ScanSources::Paths(sources),
271 ..
272 } = node
273 {
274 paths.extend(sources.iter().map(|path| path.as_str().to_string()));
275 }
276 }
277 paths
278}
279
280fn reads_remote(plan: &DslPlan) -> bool {
281 plan.into_iter().any(|node| match node {
282 DslPlan::Scan {
283 sources: ScanSources::Paths(sources),
284 ..
285 } => sources.iter().any(|path| is_remote(path.as_str())),
286 _ => false,
287 })
288}
289
290fn redirect_plan(plan: &mut DslPlan, paths: &HashMap<String, PathBuf>) -> bool {
293 let into = |input: &mut Arc<DslPlan>| redirect_plan(Arc::make_mut(input), paths);
294 let each = |inputs: &mut [DslPlan]| inputs.iter_mut().all(|input| redirect_plan(input, paths));
295 match plan {
296 DslPlan::Scan {
297 sources,
298 unified_scan_args,
299 cached_ir,
300 ..
301 } => {
302 let ScanSources::Paths(urls) = sources else {
303 return true;
304 };
305 if !urls.iter().any(|url| is_remote(url.as_str())) {
306 return true;
307 }
308 let mut local = Vec::with_capacity(urls.len());
309 for url in urls.iter() {
310 let Some(path) = paths.get(url.as_str()).and_then(|path| path.to_str()) else {
311 return false;
312 };
313 local.push(PlRefPath::new(path));
314 }
315 *sources = ScanSources::Paths(local.into_iter().collect());
316 unified_scan_args.cloud_options = None;
317 unified_scan_args.glob = false;
319 *cached_ir = Default::default();
321 true
322 }
323 DslPlan::IR { dsl, .. } => {
326 let mut inner = Arc::unwrap_or_clone(dsl.clone());
327 let ok = redirect_plan(&mut inner, paths);
328 *plan = inner;
329 ok
330 }
331 DslPlan::Select { input, .. }
332 | DslPlan::GroupBy { input, .. }
333 | DslPlan::Filter { input, .. }
334 | DslPlan::Distinct { input, .. }
335 | DslPlan::Sort { input, .. }
336 | DslPlan::Slice { input, .. }
337 | DslPlan::HStack { input, .. }
338 | DslPlan::MatchToSchema { input, .. }
339 | DslPlan::MapFunction { input, .. }
340 | DslPlan::Sink { input, .. }
341 | DslPlan::Cache { input, .. }
342 | DslPlan::Pivot { input, .. } => into(input),
343 DslPlan::Union { inputs, .. }
344 | DslPlan::HConcat { inputs, .. }
345 | DslPlan::SinkMultiple { inputs } => each(inputs),
346 DslPlan::PipeWithSchema { input, .. } => {
347 let mut inputs = input.to_vec();
348 let ok = each(&mut inputs);
349 *input = inputs.into();
350 ok
351 }
352 DslPlan::Join {
353 input_left,
354 input_right,
355 ..
356 } => into(input_left) & into(input_right),
357 DslPlan::Gather { input, idxs, .. } => into(input) & into(idxs),
358 DslPlan::ExtContext { input, contexts } => into(input) & each(contexts),
359 _ => true,
362 }
363}
364
365#[cfg(test)]
366mod tests;