use crate::analysis::sampling::ReadWatch;
use crate::cloud::download::{self, StreamError};
use color_eyre::Result;
use color_eyre::eyre::eyre;
use polars::lazy::dsl::{DslPlan, ScanSources};
use polars::prelude::{LazyFrame, PlRefPath};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub const COPIES_DIR: &str = "quality-copies";
const HELD: &str = ".held";
const UNHELD_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteObject {
pub url: String,
pub size: u64,
pub etag: Option<String>,
}
#[derive(Debug)]
pub struct LocalCopy {
_held: std::fs::File,
dir: tempfile::TempDir,
paths: HashMap<String, PathBuf>,
bytes: u64,
}
impl LocalCopy {
pub fn bytes(&self) -> u64 {
self.bytes
}
pub fn objects(&self) -> usize {
self.paths.len()
}
#[cfg(test)]
pub fn dir(&self) -> &Path {
self.dir.path()
}
pub fn covers(&self, url: &str) -> bool {
self.paths.contains_key(url)
}
pub fn fetch(
root: &Path,
objects: &[RemoteObject],
stop: &ReadWatch,
mut get: impl FnMut(
&RemoteObject,
&mut dyn FnMut(&[u8]) -> Result<()>,
) -> std::result::Result<u64, StreamError>,
) -> Result<LocalCopy> {
std::fs::create_dir_all(root).map_err(unwritable)?;
sweep(root);
let dir = tempfile::Builder::new()
.prefix("copy-")
.tempdir_in(root)
.map_err(unwritable)?;
let held = hold(dir.path())?;
let mut copy = LocalCopy {
_held: held,
dir,
paths: HashMap::with_capacity(objects.len()),
bytes: 0,
};
for object in objects {
stop.check()?;
let path = copy.dir.path().join(relative_path(&object.url));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(unwritable)?;
}
let mut file = std::fs::File::create_new(&path).map_err(unwritable)?;
let written = download::fill_file(&mut file, unwritable, |write| get(object, write))
.map_err(|error| not_copied(object, error))?;
if written != object.size {
return Err(changed(object, written));
}
copy.bytes += written;
copy.paths.insert(object.url.clone(), path);
}
Ok(copy)
}
pub fn redirect(&self, lf: &LazyFrame) -> Option<LazyFrame> {
let mut plan = lf.logical_plan.clone();
if !redirect_plan(&mut plan, &self.paths) || reads_remote(&plan) {
return None;
}
Some(LazyFrame::from(plan).with_optimizations(lf.get_current_optimizations()))
}
}
fn unwritable(error: std::io::Error) -> color_eyre::Report {
eyre!(
"Could not write the local copy: {error}. \
quality_local_copy = 0 reads the source instead"
)
}
fn not_copied(object: &RemoteObject, error: StreamError) -> color_eyre::Report {
let url = &object.url;
match error {
StreamError::Write(report) => report,
StreamError::Open(e) if crate::error_display::says_gone_since_opened(&e) => eyre!(e),
StreamError::Open(e) | StreamError::Read(e) => eyre!("Could not copy {url}: {e}"),
StreamError::Short { got, .. } => changed(object, got),
StreamError::Cut => eyre!(crate::analysis::sampling::CANCELLED),
}
}
fn changed(object: &RemoteObject, got: u64) -> color_eyre::Report {
eyre!(
"{} is {got} bytes, not the {} listed when it opened: \
it changed. Open the dataset again",
object.url,
object.size
)
}
pub fn same_etag(listed: &str, fetched: &str) -> bool {
let bare = |tag: &str| {
tag.trim()
.trim_start_matches("W/")
.trim_matches('"')
.to_string()
};
bare(listed) == bare(fetched)
}
fn hold(dir: &Path) -> Result<std::fs::File> {
use fs2::FileExt;
let file = std::fs::File::create(dir.join(HELD)).map_err(unwritable)?;
file.try_lock_exclusive().map_err(unwritable)?;
Ok(file)
}
pub fn sweep(root: &Path) {
use fs2::FileExt;
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let ours = path.is_dir()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("copy-"));
if !ours {
continue;
}
let old = |meta: std::io::Result<std::fs::Metadata>| {
meta.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > UNHELD_GRACE)
};
let orphaned = match std::fs::File::open(path.join(HELD)) {
Ok(file) => {
let free = file.try_lock_exclusive().is_ok();
if free {
let _ = fs2::FileExt::unlock(&file);
}
free && old(file.metadata())
}
Err(_) => old(entry.metadata()),
};
if orphaned {
let _ = std::fs::remove_dir_all(&path);
}
}
}
pub fn free_space(dir: &Path) -> Option<u64> {
let existing = dir.ancestors().find(|path| path.exists())?;
fs2::available_space(existing).ok()
}
fn relative_path(url: &str) -> PathBuf {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
rest.split('/')
.filter(|part| !part.is_empty())
.map(|part| match part {
"." | ".." => "_".to_string(),
part => safe_component(part, cfg!(windows)),
})
.collect()
}
fn safe_component(part: &str, windows: bool) -> String {
if !windows {
return part.to_string();
}
part.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*' => format!("%{:02X}", c as u32),
c => c.to_string(),
})
.collect()
}
fn is_remote(path: &str) -> bool {
crate::cloud::source::is_remote_url(Path::new(path))
}
#[cfg(test)]
pub fn scan_paths(lf: &LazyFrame) -> Vec<String> {
let mut paths = Vec::new();
for node in &lf.logical_plan {
if let DslPlan::Scan {
sources: ScanSources::Paths(sources),
..
} = node
{
paths.extend(sources.iter().map(|path| path.as_str().to_string()));
}
}
paths
}
fn reads_remote(plan: &DslPlan) -> bool {
plan.into_iter().any(|node| match node {
DslPlan::Scan {
sources: ScanSources::Paths(sources),
..
} => sources.iter().any(|path| is_remote(path.as_str())),
_ => false,
})
}
fn redirect_plan(plan: &mut DslPlan, paths: &HashMap<String, PathBuf>) -> bool {
let into = |input: &mut Arc<DslPlan>| redirect_plan(Arc::make_mut(input), paths);
let each = |inputs: &mut [DslPlan]| inputs.iter_mut().all(|input| redirect_plan(input, paths));
match plan {
DslPlan::Scan {
sources,
unified_scan_args,
cached_ir,
..
} => {
let ScanSources::Paths(urls) = sources else {
return true;
};
if !urls.iter().any(|url| is_remote(url.as_str())) {
return true;
}
let mut local = Vec::with_capacity(urls.len());
for url in urls.iter() {
let Some(path) = paths.get(url.as_str()).and_then(|path| path.to_str()) else {
return false;
};
local.push(PlRefPath::new(path));
}
*sources = ScanSources::Paths(local.into_iter().collect());
unified_scan_args.cloud_options = None;
unified_scan_args.glob = false;
*cached_ir = Default::default();
true
}
DslPlan::IR { dsl, .. } => {
let mut inner = Arc::unwrap_or_clone(dsl.clone());
let ok = redirect_plan(&mut inner, paths);
*plan = inner;
ok
}
DslPlan::Select { input, .. }
| DslPlan::GroupBy { input, .. }
| DslPlan::Filter { input, .. }
| DslPlan::Distinct { input, .. }
| DslPlan::Sort { input, .. }
| DslPlan::Slice { input, .. }
| DslPlan::HStack { input, .. }
| DslPlan::MatchToSchema { input, .. }
| DslPlan::MapFunction { input, .. }
| DslPlan::Sink { input, .. }
| DslPlan::Cache { input, .. }
| DslPlan::Pivot { input, .. } => into(input),
DslPlan::Union { inputs, .. }
| DslPlan::HConcat { inputs, .. }
| DslPlan::SinkMultiple { inputs } => each(inputs),
DslPlan::PipeWithSchema { input, .. } => {
let mut inputs = input.to_vec();
let ok = each(&mut inputs);
*input = inputs.into();
ok
}
DslPlan::Join {
input_left,
input_right,
..
} => into(input_left) & into(input_right),
DslPlan::Gather { input, idxs, .. } => into(input) & into(idxs),
DslPlan::ExtContext { input, contexts } => into(input) & each(contexts),
_ => true,
}
}
#[cfg(test)]
mod tests;