Skip to main content

datui_lib/
local_copy.rs

1//! A local copy of a remote dataset's objects, for Data Quality's full scan.
2//!
3//! A full scan is several passes over its scope, and over an object store each pass
4//! reads the objects again. Within a byte budget the run fetches each object once
5//! into the cache directory and makes its passes over the copy instead. The copy is
6//! a session snapshot: it is kept for later full scans of the same dataset until it
7//! is released, the dataset is opened again or replaced, or datui exits. A copy
8//! that did not finish is removed when the fetch stops.
9
10use 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
20/// Where copies live under the cache directory.
21pub const COPIES_DIR: &str = "quality-copies";
22
23/// Held, locked, by the session that owns a copy; [`sweep`] leaves a locked copy alone.
24const HELD: &str = ".held";
25
26/// A sweep leaves a copy younger than this alone, locked or not: until its lock is
27/// taken, a live session's copy looks like a dead one's.
28const UNHELD_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
29
30/// One remote object a copy fetches, as the open found it.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RemoteObject {
33    pub url: String,
34    pub size: u64,
35    /// The store's tag for the version listed, where it gave one.
36    pub etag: Option<String>,
37}
38
39/// Objects copied from a remote dataset, each at the path its URL maps to. Removed
40/// from disk when the last holder drops it: the app's retained copies and any run
41/// reading it.
42#[derive(Debug)]
43pub struct LocalCopy {
44    // Closed before the directory goes: Windows will not remove a directory holding
45    // an open file.
46    _held: std::fs::File,
47    dir: tempfile::TempDir,
48    paths: HashMap<String, PathBuf>,
49    bytes: u64,
50}
51
52impl LocalCopy {
53    /// Bytes on disk.
54    pub fn bytes(&self) -> u64 {
55        self.bytes
56    }
57
58    /// Objects copied.
59    pub fn objects(&self) -> usize {
60        self.paths.len()
61    }
62
63    pub fn dir(&self) -> &Path {
64        self.dir.path()
65    }
66
67    /// Whether this copy holds the object at `url`.
68    pub fn covers(&self, url: &str) -> bool {
69        self.paths.contains_key(url)
70    }
71
72    /// Copy `objects` under `root`, one at a time. `get` streams one object's bytes
73    /// into the writer it is given, in order; the writer refuses once `stop` is set,
74    /// so a cancel ends the fetch within a chunk. On any failure or cancel the partial
75    /// copy is removed before this returns.
76    ///
77    /// An object whose bytes differ in length from its listing changed since the
78    /// dataset opened: the copy would not be the dataset on screen, so it fails.
79    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            // Never over an object already copied: on a disk that ignores case, two
105            // keys can name one file.
106            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    /// `lf` reading this copy wherever it read a remote object. `None` when it reads
130    /// a remote object the copy does not hold, or a plan shape this cannot follow:
131    /// the caller then reads the source as before.
132    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
141/// A local write that failed, and the way around it.
142fn 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
149/// Whether the tag a fetch was answered with is the one listed: stores quote it in
150/// one answer and not the other, and mark a weak one.
151pub 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
161/// Lock `dir` as held by this session.
162fn 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
169/// Remove copies under `root` no session holds: left by a datui that did not exit
170/// cleanly, or quit while a run read one. A copy another running datui holds is
171/// locked and stays, and so does one too new to judge.
172pub 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
209/// Free bytes on the file system holding `dir`, or its nearest existing parent.
210pub fn free_space(dir: &Path) -> Option<u64> {
211    let existing = dir.ancestors().find(|path| path.exists())?;
212    fs2::available_space(existing).ok()
213}
214
215/// Where `url` lands inside a copy: its bucket and key as directories, so a hive
216/// path keeps its `key=value` parts and Polars reads the same partition columns.
217/// No part climbs out of the copy.
218fn 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
229/// `part` as one file name. Windows refuses some characters in one: they are
230/// percent-encoded, which Polars decodes in a hive value, so the partition reads as
231/// the source's.
232fn 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
248/// Every path a plan's scans read.
249pub 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
273/// Point every scan of a remote object in `plan` at its copy in `paths`. False when a
274/// scan reads a remote object `paths` does not hold.
275fn 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            // Each copy is one named file, and an object key may hold `[` or `*`.
301            unified_scan_args.glob = false;
302            // The IR cached for the remote paths would be reused as is.
303            *cached_ir = Default::default();
304            true
305        }
306        // A plan already turned into IR keeps the plan it came from: rewrite that, and
307        // leave the IR behind.
308        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        // In-memory frames read nothing; any other shape is checked by `reads_remote`
343        // after, and a remote scan left inside it sends the run back to the source.
344        _ => true,
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use polars::prelude::*;
352
353    /// Two Parquet files under `dir`, rows 0..10 and 10..20, in a `part=` directory
354    /// each so the copy has to keep hive paths working.
355    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    /// The URL's bytes from the "bucket" under `source`.
376    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    /// A fetched copy serves a remote plan, filters and aggregates included, with the
394    /// same rows and the hive column; no remote path is left in the plan.
395    #[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        // A frame asked for its schema holds its plan as IR, wrapping the plan it
428        // came from.
429        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    /// A cancel between chunks and a failed object both leave nothing behind.
464    #[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    /// A finished copy stays while held and goes when dropped; a sweep removes a
506    /// copy no one holds and keeps one that is held.
507    #[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        // Copies left by sessions that died: their locks went with the processes. One
524        // is as new as a live session's copy before it takes its lock.
525        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    /// Two keys that land on one file, as on a disk that ignores case, fail the
544    /// fetch rather than one overwriting the other.
545    #[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            // Asked only for the first: the second has nowhere to go.
556            |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}