1use crate::sampling::ReadWatch;
11use color_eyre::Result;
12use color_eyre::eyre::eyre;
13use polars::lazy::dsl::{DslPlan, ScanSources};
14use polars::prelude::{LazyFrame, PlRefPath};
15use std::collections::HashMap;
16use std::io::Write;
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 pub fn dir(&self) -> &Path {
64 self.dir.path()
65 }
66
67 pub fn covers(&self, url: &str) -> bool {
69 self.paths.contains_key(url)
70 }
71
72 pub fn fetch(
80 root: &Path,
81 objects: &[RemoteObject],
82 stop: &ReadWatch,
83 mut get: impl FnMut(&RemoteObject, &mut dyn FnMut(&[u8]) -> Result<()>) -> Result<()>,
84 ) -> Result<LocalCopy> {
85 std::fs::create_dir_all(root).map_err(unwritable)?;
86 sweep(root);
87 let dir = tempfile::Builder::new()
88 .prefix("copy-")
89 .tempdir_in(root)
90 .map_err(unwritable)?;
91 let held = hold(dir.path())?;
92 let mut copy = LocalCopy {
93 _held: held,
94 dir,
95 paths: HashMap::with_capacity(objects.len()),
96 bytes: 0,
97 };
98 for object in objects {
99 stop.check()?;
100 let path = copy.dir.path().join(relative_path(&object.url));
101 if let Some(parent) = path.parent() {
102 std::fs::create_dir_all(parent).map_err(unwritable)?;
103 }
104 let mut file = std::fs::File::create_new(&path).map_err(unwritable)?;
107 let mut written = 0u64;
108 get(object, &mut |chunk: &[u8]| {
109 stop.check()?;
110 file.write_all(chunk).map_err(unwritable)?;
111 written += chunk.len() as u64;
112 Ok(())
113 })?;
114 file.flush().map_err(unwritable)?;
115 if written != object.size {
116 return Err(eyre!(
117 "{} is {written} bytes, not the {} listed when it opened: \
118 it changed. Open the dataset again",
119 object.url,
120 object.size
121 ));
122 }
123 copy.bytes += written;
124 copy.paths.insert(object.url.clone(), path);
125 }
126 Ok(copy)
127 }
128
129 pub fn redirect(&self, lf: &LazyFrame) -> Option<LazyFrame> {
133 let mut plan = lf.logical_plan.clone();
134 if !redirect_plan(&mut plan, &self.paths) || reads_remote(&plan) {
135 return None;
136 }
137 Some(LazyFrame::from(plan).with_optimizations(lf.get_current_optimizations()))
138 }
139}
140
141fn unwritable(error: std::io::Error) -> color_eyre::Report {
143 eyre!(
144 "Could not write the local copy: {error}. \
145 quality_local_copy = 0 reads the source instead"
146 )
147}
148
149pub fn same_etag(listed: &str, fetched: &str) -> bool {
152 let bare = |tag: &str| {
153 tag.trim()
154 .trim_start_matches("W/")
155 .trim_matches('"')
156 .to_string()
157 };
158 bare(listed) == bare(fetched)
159}
160
161fn hold(dir: &Path) -> Result<std::fs::File> {
163 use fs2::FileExt;
164 let file = std::fs::File::create(dir.join(HELD)).map_err(unwritable)?;
165 file.try_lock_exclusive().map_err(unwritable)?;
166 Ok(file)
167}
168
169pub fn sweep(root: &Path) {
173 use fs2::FileExt;
174 let Ok(entries) = std::fs::read_dir(root) else {
175 return;
176 };
177 for entry in entries.flatten() {
178 let path = entry.path();
179 let ours = path.is_dir()
180 && path
181 .file_name()
182 .and_then(|name| name.to_str())
183 .is_some_and(|name| name.starts_with("copy-"));
184 if !ours {
185 continue;
186 }
187 let old = |meta: std::io::Result<std::fs::Metadata>| {
188 meta.and_then(|meta| meta.modified())
189 .ok()
190 .and_then(|modified| modified.elapsed().ok())
191 .is_some_and(|age| age > UNHELD_GRACE)
192 };
193 let orphaned = match std::fs::File::open(path.join(HELD)) {
194 Ok(file) => {
195 let free = file.try_lock_exclusive().is_ok();
196 if free {
197 let _ = fs2::FileExt::unlock(&file);
198 }
199 free && old(file.metadata())
200 }
201 Err(_) => old(entry.metadata()),
202 };
203 if orphaned {
204 let _ = std::fs::remove_dir_all(&path);
205 }
206 }
207}
208
209pub fn free_space(dir: &Path) -> Option<u64> {
211 let existing = dir.ancestors().find(|path| path.exists())?;
212 fs2::available_space(existing).ok()
213}
214
215fn relative_path(url: &str) -> PathBuf {
219 let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
220 rest.split('/')
221 .filter(|part| !part.is_empty())
222 .map(|part| match part {
223 "." | ".." => "_".to_string(),
224 part => safe_component(part, cfg!(windows)),
225 })
226 .collect()
227}
228
229fn safe_component(part: &str, windows: bool) -> String {
233 if !windows {
234 return part.to_string();
235 }
236 part.chars()
237 .map(|c| match c {
238 '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*' => format!("%{:02X}", c as u32),
239 c => c.to_string(),
240 })
241 .collect()
242}
243
244fn is_remote(path: &str) -> bool {
245 crate::source::is_remote_url(Path::new(path))
246}
247
248pub fn scan_paths(lf: &LazyFrame) -> Vec<String> {
250 let mut paths = Vec::new();
251 for node in &lf.logical_plan {
252 if let DslPlan::Scan {
253 sources: ScanSources::Paths(sources),
254 ..
255 } = node
256 {
257 paths.extend(sources.iter().map(|path| path.as_str().to_string()));
258 }
259 }
260 paths
261}
262
263fn reads_remote(plan: &DslPlan) -> bool {
264 plan.into_iter().any(|node| match node {
265 DslPlan::Scan {
266 sources: ScanSources::Paths(sources),
267 ..
268 } => sources.iter().any(|path| is_remote(path.as_str())),
269 _ => false,
270 })
271}
272
273fn redirect_plan(plan: &mut DslPlan, paths: &HashMap<String, PathBuf>) -> bool {
276 let into = |input: &mut Arc<DslPlan>| redirect_plan(Arc::make_mut(input), paths);
277 let each = |inputs: &mut [DslPlan]| inputs.iter_mut().all(|input| redirect_plan(input, paths));
278 match plan {
279 DslPlan::Scan {
280 sources,
281 unified_scan_args,
282 cached_ir,
283 ..
284 } => {
285 let ScanSources::Paths(urls) = sources else {
286 return true;
287 };
288 if !urls.iter().any(|url| is_remote(url.as_str())) {
289 return true;
290 }
291 let mut local = Vec::with_capacity(urls.len());
292 for url in urls.iter() {
293 let Some(path) = paths.get(url.as_str()).and_then(|path| path.to_str()) else {
294 return false;
295 };
296 local.push(PlRefPath::new(path));
297 }
298 *sources = ScanSources::Paths(local.into_iter().collect());
299 unified_scan_args.cloud_options = None;
300 unified_scan_args.glob = false;
302 *cached_ir = Default::default();
304 true
305 }
306 DslPlan::IR { dsl, .. } => {
309 let mut inner = Arc::unwrap_or_clone(dsl.clone());
310 let ok = redirect_plan(&mut inner, paths);
311 *plan = inner;
312 ok
313 }
314 DslPlan::Select { input, .. }
315 | DslPlan::GroupBy { input, .. }
316 | DslPlan::Filter { input, .. }
317 | DslPlan::Distinct { input, .. }
318 | DslPlan::Sort { input, .. }
319 | DslPlan::Slice { input, .. }
320 | DslPlan::HStack { input, .. }
321 | DslPlan::MatchToSchema { input, .. }
322 | DslPlan::MapFunction { input, .. }
323 | DslPlan::Sink { input, .. }
324 | DslPlan::Cache { input, .. }
325 | DslPlan::Pivot { input, .. } => into(input),
326 DslPlan::Union { inputs, .. }
327 | DslPlan::HConcat { inputs, .. }
328 | DslPlan::SinkMultiple { inputs } => each(inputs),
329 DslPlan::PipeWithSchema { input, .. } => {
330 let mut inputs = input.to_vec();
331 let ok = each(&mut inputs);
332 *input = inputs.into();
333 ok
334 }
335 DslPlan::Join {
336 input_left,
337 input_right,
338 ..
339 } => into(input_left) & into(input_right),
340 DslPlan::Gather { input, idxs, .. } => into(input) & into(idxs),
341 DslPlan::ExtContext { input, contexts } => into(input) & each(contexts),
342 _ => true,
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use polars::prelude::*;
352
353 fn files(dir: &Path) -> Vec<RemoteObject> {
356 (0..2)
357 .map(|part| {
358 let mut df =
359 df!("id" => ((part * 10)..(part * 10 + 10)).collect::<Vec<i64>>()).unwrap();
360 let path = dir.join(format!("part={part}")).join("data.parquet");
361 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
362 ParquetWriter::new(std::fs::File::create(&path).unwrap())
363 .finish(&mut df)
364 .unwrap();
365 let size = std::fs::metadata(&path).unwrap().len();
366 RemoteObject {
367 url: format!("s3://lake/events/part={part}/data.parquet"),
368 size,
369 etag: None,
370 }
371 })
372 .collect()
373 }
374
375 fn bytes_of(source: &Path, url: &str) -> Vec<u8> {
377 let key = url.trim_start_matches("s3://lake/events/");
378 std::fs::read(source.join(key)).unwrap()
379 }
380
381 fn remote_scan(urls: &[String]) -> LazyFrame {
382 let sources = ScanSources::Paths(urls.iter().map(PlRefPath::new).collect());
383 let args = polars::lazy::dsl::UnifiedScanArgs {
384 hive_options: polars::io::HiveOptions::new_enabled(),
385 ..Default::default()
386 };
387 DslBuilder::scan_parquet(sources, Default::default(), args)
388 .unwrap()
389 .build()
390 .into()
391 }
392
393 #[test]
396 fn a_copy_reads_as_the_remote_scan_would() {
397 let source = tempfile::tempdir().unwrap();
398 let root = tempfile::tempdir().unwrap();
399 let objects = files(source.path());
400 let copy = LocalCopy::fetch(
401 root.path(),
402 &objects,
403 &ReadWatch::default(),
404 |object, write| {
405 for chunk in bytes_of(source.path(), &object.url).chunks(7) {
406 write(chunk)?;
407 }
408 Ok(())
409 },
410 )
411 .unwrap();
412 assert_eq!(copy.objects(), 2);
413 assert_eq!(
414 copy.bytes(),
415 objects.iter().map(|object| object.size).sum::<u64>()
416 );
417
418 let urls = objects
419 .iter()
420 .map(|object| object.url.clone())
421 .collect::<Vec<_>>();
422 let remote = remote_scan(&urls)
423 .filter(col("id").gt(lit(3)))
424 .group_by([col("part")])
425 .agg([len().alias("rows")])
426 .sort(["part"], Default::default());
427 let remote: LazyFrame = DslPlan::IR {
430 dsl: Arc::new(remote.logical_plan),
431 version: 0,
432 node: None,
433 opt_flags: None,
434 }
435 .into();
436 let local = copy.redirect(&remote).expect("every object is in the copy");
437 assert!(scan_paths(&local).iter().all(|path| !is_remote(path)));
438 let df = local.collect().unwrap();
439 assert_eq!(df.column("rows").unwrap().u32().unwrap().get(0), Some(6));
440 assert_eq!(df.column("rows").unwrap().u32().unwrap().get(1), Some(10));
441 assert_eq!(df.height(), 2, "the partition column survives the copy");
442 }
443
444 #[test]
445 fn a_plan_reading_an_object_not_copied_is_left_alone() {
446 let source = tempfile::tempdir().unwrap();
447 let root = tempfile::tempdir().unwrap();
448 let objects = files(source.path());
449 let copy = LocalCopy::fetch(
450 root.path(),
451 &objects[..1],
452 &ReadWatch::default(),
453 |object, write| write(&bytes_of(source.path(), &object.url)),
454 )
455 .unwrap();
456 let urls = objects
457 .iter()
458 .map(|object| object.url.clone())
459 .collect::<Vec<_>>();
460 assert!(copy.redirect(&remote_scan(&urls)).is_none());
461 }
462
463 #[test]
465 fn a_stopped_or_failed_fetch_leaves_no_files() {
466 let source = tempfile::tempdir().unwrap();
467 let root = tempfile::tempdir().unwrap();
468 let objects = files(source.path());
469 let entries = || std::fs::read_dir(root.path()).unwrap().count();
470
471 let stop = ReadWatch::default();
472 let stopped = LocalCopy::fetch(root.path(), &objects, &stop, |object, write| {
473 let bytes = bytes_of(source.path(), &object.url);
474 write(&bytes[..10])?;
475 stop.stop();
476 write(&bytes[10..])
477 });
478 assert!(stopped.is_err());
479 assert_eq!(entries(), 0, "the partial copy is gone");
480
481 let failed = LocalCopy::fetch(
482 root.path(),
483 &objects,
484 &ReadWatch::default(),
485 |object, write| {
486 if object.url.contains("part=1") {
487 return Err(eyre!("404"));
488 }
489 write(&bytes_of(source.path(), &object.url))
490 },
491 );
492 assert!(failed.is_err());
493 assert_eq!(entries(), 0, "the first object went with it");
494
495 let short = LocalCopy::fetch(
496 root.path(),
497 &objects,
498 &ReadWatch::default(),
499 |object, write| write(&bytes_of(source.path(), &object.url)[1..]),
500 );
501 assert!(short.unwrap_err().to_string().contains("changed"));
502 assert_eq!(entries(), 0);
503 }
504
505 #[test]
508 fn a_copy_lives_while_held_and_a_sweep_clears_orphans() {
509 let source = tempfile::tempdir().unwrap();
510 let root = tempfile::tempdir().unwrap();
511 let objects = files(source.path());
512 let fetch = || {
513 LocalCopy::fetch(
514 root.path(),
515 &objects,
516 &ReadWatch::default(),
517 |object, write| write(&bytes_of(source.path(), &object.url)),
518 )
519 .unwrap()
520 };
521 let held = fetch();
522 let dir = held.dir().to_path_buf();
523 let orphan = |name: &str, age: u64| {
526 let dir = root.path().join(name);
527 std::fs::create_dir_all(dir.join("lake")).unwrap();
528 let lock = std::fs::File::create(dir.join(HELD)).unwrap();
529 let then = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
530 lock.set_modified(then).unwrap();
531 dir
532 };
533 let old = orphan("copy-old", 2 * UNHELD_GRACE.as_secs());
534 let new = orphan("copy-new", 0);
535 sweep(root.path());
536 assert!(dir.exists(), "a held copy stays");
537 assert!(!old.exists(), "an orphan goes");
538 assert!(new.exists(), "one too new to judge stays");
539 drop(held);
540 assert!(!dir.exists(), "dropped, the copy is removed");
541 }
542
543 #[test]
546 fn two_objects_never_share_a_file() {
547 let source = tempfile::tempdir().unwrap();
548 let root = tempfile::tempdir().unwrap();
549 let mut objects = files(source.path());
550 objects[1].url = objects[0].url.replace("s3://lake/", "s3://lake//");
551 let error = LocalCopy::fetch(
552 root.path(),
553 &objects,
554 &ReadWatch::default(),
555 |object, write| write(&bytes_of(source.path(), &object.url)),
557 )
558 .unwrap_err();
559 assert!(
560 error.to_string().contains("quality_local_copy = 0"),
561 "{error}"
562 );
563 assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
564 }
565
566 #[test]
567 fn urls_map_to_their_bucket_and_key() {
568 assert_eq!(
569 relative_path("s3://lake/events/region=North/part-0.parquet"),
570 PathBuf::from("lake/events/region=North/part-0.parquet")
571 );
572 assert_eq!(
573 relative_path("gs://b/../x.parquet"),
574 PathBuf::from("b/_/x.parquet")
575 );
576 assert_eq!(
577 relative_path("s3:///../../etc/passwd"),
578 PathBuf::from("_/_/etc/passwd")
579 );
580 assert_eq!(safe_component("at=12:00", false), "at=12:00");
581 assert_eq!(safe_component("at=12:00", true), "at=12%3A00");
582 assert_eq!(safe_component(r"a\b", true), "a%5Cb");
583 }
584
585 #[test]
586 fn etags_compare_unquoted() {
587 assert!(same_etag("\"abc\"", "abc"));
588 assert!(same_etag("W/\"abc\"", "\"abc\""));
589 assert!(!same_etag("\"abc\"", "\"abd\""));
590 }
591}