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) | StreamError::Read(e) => eyre!("Could not copy {url}: {e}"),
148 StreamError::Short { got, .. } => changed(object, got),
149 StreamError::Cut => eyre!(crate::analysis::sampling::CANCELLED),
150 }
151}
152
153fn changed(object: &RemoteObject, got: u64) -> color_eyre::Report {
155 eyre!(
156 "{} is {got} bytes, not the {} listed when it opened: \
157 it changed. Open the dataset again",
158 object.url,
159 object.size
160 )
161}
162
163pub fn same_etag(listed: &str, fetched: &str) -> bool {
166 let bare = |tag: &str| {
167 tag.trim()
168 .trim_start_matches("W/")
169 .trim_matches('"')
170 .to_string()
171 };
172 bare(listed) == bare(fetched)
173}
174
175fn hold(dir: &Path) -> Result<std::fs::File> {
177 use fs2::FileExt;
178 let file = std::fs::File::create(dir.join(HELD)).map_err(unwritable)?;
179 file.try_lock_exclusive().map_err(unwritable)?;
180 Ok(file)
181}
182
183pub fn sweep(root: &Path) {
187 use fs2::FileExt;
188 let Ok(entries) = std::fs::read_dir(root) else {
189 return;
190 };
191 for entry in entries.flatten() {
192 let path = entry.path();
193 let ours = path.is_dir()
194 && path
195 .file_name()
196 .and_then(|name| name.to_str())
197 .is_some_and(|name| name.starts_with("copy-"));
198 if !ours {
199 continue;
200 }
201 let old = |meta: std::io::Result<std::fs::Metadata>| {
202 meta.and_then(|meta| meta.modified())
203 .ok()
204 .and_then(|modified| modified.elapsed().ok())
205 .is_some_and(|age| age > UNHELD_GRACE)
206 };
207 let orphaned = match std::fs::File::open(path.join(HELD)) {
208 Ok(file) => {
209 let free = file.try_lock_exclusive().is_ok();
210 if free {
211 let _ = fs2::FileExt::unlock(&file);
212 }
213 free && old(file.metadata())
214 }
215 Err(_) => old(entry.metadata()),
216 };
217 if orphaned {
218 let _ = std::fs::remove_dir_all(&path);
219 }
220 }
221}
222
223pub fn free_space(dir: &Path) -> Option<u64> {
225 let existing = dir.ancestors().find(|path| path.exists())?;
226 fs2::available_space(existing).ok()
227}
228
229fn relative_path(url: &str) -> PathBuf {
233 let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
234 rest.split('/')
235 .filter(|part| !part.is_empty())
236 .map(|part| match part {
237 "." | ".." => "_".to_string(),
238 part => safe_component(part, cfg!(windows)),
239 })
240 .collect()
241}
242
243fn safe_component(part: &str, windows: bool) -> String {
247 if !windows {
248 return part.to_string();
249 }
250 part.chars()
251 .map(|c| match c {
252 '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*' => format!("%{:02X}", c as u32),
253 c => c.to_string(),
254 })
255 .collect()
256}
257
258fn is_remote(path: &str) -> bool {
259 crate::cloud::source::is_remote_url(Path::new(path))
260}
261
262#[cfg(test)]
264pub fn scan_paths(lf: &LazyFrame) -> Vec<String> {
265 let mut paths = Vec::new();
266 for node in &lf.logical_plan {
267 if let DslPlan::Scan {
268 sources: ScanSources::Paths(sources),
269 ..
270 } = node
271 {
272 paths.extend(sources.iter().map(|path| path.as_str().to_string()));
273 }
274 }
275 paths
276}
277
278fn reads_remote(plan: &DslPlan) -> bool {
279 plan.into_iter().any(|node| match node {
280 DslPlan::Scan {
281 sources: ScanSources::Paths(sources),
282 ..
283 } => sources.iter().any(|path| is_remote(path.as_str())),
284 _ => false,
285 })
286}
287
288fn redirect_plan(plan: &mut DslPlan, paths: &HashMap<String, PathBuf>) -> bool {
291 let into = |input: &mut Arc<DslPlan>| redirect_plan(Arc::make_mut(input), paths);
292 let each = |inputs: &mut [DslPlan]| inputs.iter_mut().all(|input| redirect_plan(input, paths));
293 match plan {
294 DslPlan::Scan {
295 sources,
296 unified_scan_args,
297 cached_ir,
298 ..
299 } => {
300 let ScanSources::Paths(urls) = sources else {
301 return true;
302 };
303 if !urls.iter().any(|url| is_remote(url.as_str())) {
304 return true;
305 }
306 let mut local = Vec::with_capacity(urls.len());
307 for url in urls.iter() {
308 let Some(path) = paths.get(url.as_str()).and_then(|path| path.to_str()) else {
309 return false;
310 };
311 local.push(PlRefPath::new(path));
312 }
313 *sources = ScanSources::Paths(local.into_iter().collect());
314 unified_scan_args.cloud_options = None;
315 unified_scan_args.glob = false;
317 *cached_ir = Default::default();
319 true
320 }
321 DslPlan::IR { dsl, .. } => {
324 let mut inner = Arc::unwrap_or_clone(dsl.clone());
325 let ok = redirect_plan(&mut inner, paths);
326 *plan = inner;
327 ok
328 }
329 DslPlan::Select { input, .. }
330 | DslPlan::GroupBy { input, .. }
331 | DslPlan::Filter { input, .. }
332 | DslPlan::Distinct { input, .. }
333 | DslPlan::Sort { input, .. }
334 | DslPlan::Slice { input, .. }
335 | DslPlan::HStack { input, .. }
336 | DslPlan::MatchToSchema { input, .. }
337 | DslPlan::MapFunction { input, .. }
338 | DslPlan::Sink { input, .. }
339 | DslPlan::Cache { input, .. }
340 | DslPlan::Pivot { input, .. } => into(input),
341 DslPlan::Union { inputs, .. }
342 | DslPlan::HConcat { inputs, .. }
343 | DslPlan::SinkMultiple { inputs } => each(inputs),
344 DslPlan::PipeWithSchema { input, .. } => {
345 let mut inputs = input.to_vec();
346 let ok = each(&mut inputs);
347 *input = inputs.into();
348 ok
349 }
350 DslPlan::Join {
351 input_left,
352 input_right,
353 ..
354 } => into(input_left) & into(input_right),
355 DslPlan::Gather { input, idxs, .. } => into(input) & into(idxs),
356 DslPlan::ExtContext { input, contexts } => into(input) & each(contexts),
357 _ => true,
360 }
361}
362
363#[cfg(test)]
364mod tests;