Skip to main content

datui_lib/cloud/
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::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
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    #[cfg(test)]
64    pub fn dir(&self) -> &Path {
65        self.dir.path()
66    }
67
68    /// Whether this copy holds the object at `url`.
69    pub fn covers(&self, url: &str) -> bool {
70        self.paths.contains_key(url)
71    }
72
73    /// Copy `objects` under `root`, one at a time. `get` streams one object's bytes
74    /// into the writer it is given, in order, as [`download::stream_into`] does,
75    /// answering with the bytes it wrote; a cancel ends it within a chunk. On any
76    /// failure or cancel the partial copy is removed before this returns.
77    ///
78    /// An object whose bytes differ in length from its listing changed since the
79    /// dataset opened: the copy would not be the dataset on screen, so it fails.
80    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            // Never over an object already copied: on a disk that ignores case, two
109            // keys can name one file.
110            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    /// `lf` reading this copy wherever it read a remote object. `None` when it reads
123    /// a remote object the copy does not hold, or a plan shape this cannot follow:
124    /// the caller then reads the source as before.
125    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
134/// A local write that failed, and the way around it.
135fn 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
142/// Why `object` did not arrive whole.
143fn 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
153/// `object` arrived as `got` bytes, not the size it was listed with.
154fn 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
163/// Whether the tag a fetch was answered with is the one listed: stores quote it in
164/// one answer and not the other, and mark a weak one.
165pub 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
175/// Lock `dir` as held by this session.
176fn 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
183/// Remove copies under `root` no session holds: left by a datui that did not exit
184/// cleanly, or quit while a run read one. A copy another running datui holds is
185/// locked and stays, and so does one too new to judge.
186pub 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
223/// Free bytes on the file system holding `dir`, or its nearest existing parent.
224pub fn free_space(dir: &Path) -> Option<u64> {
225    let existing = dir.ancestors().find(|path| path.exists())?;
226    fs2::available_space(existing).ok()
227}
228
229/// Where `url` lands inside a copy: its bucket and key as directories, so a hive
230/// path keeps its `key=value` parts and Polars reads the same partition columns.
231/// No part climbs out of the copy.
232fn 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
243/// `part` as one file name. Windows refuses some characters in one: they are
244/// percent-encoded, which Polars decodes in a hive value, so the partition reads as
245/// the source's.
246fn 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/// Every path a plan's scans read.
263#[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
288/// Point every scan of a remote object in `plan` at its copy in `paths`. False when a
289/// scan reads a remote object `paths` does not hold.
290fn 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            // Each copy is one named file, and an object key may hold `[` or `*`.
316            unified_scan_args.glob = false;
317            // The IR cached for the remote paths would be reused as is.
318            *cached_ir = Default::default();
319            true
320        }
321        // A plan already turned into IR keeps the plan it came from: rewrite that, and
322        // leave the IR behind.
323        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        // In-memory frames read nothing; any other shape is checked by `reads_remote`
358        // after, and a remote scan left inside it sends the run back to the source.
359        _ => true,
360    }
361}
362
363#[cfg(test)]
364mod tests;