use std::collections::HashMap;
use std::fmt::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use datafusion::arrow::buffer::BooleanBuffer;
use datafusion::catalog::default_table_source::source_as_provider;
use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
use datafusion::datasource::file_format::arrow::ArrowFormat;
use datafusion::datasource::file_format::csv::CsvFormat;
use datafusion::datasource::file_format::json::JsonFormat;
use datafusion::datasource::file_format::parquet::ParquetFormat;
use datafusion::datasource::file_format::FileFormat as DataFusionFileFormat;
use datafusion::datasource::listing::ListingTable;
use datafusion::datasource::physical_plan::FileScanConfig;
use datafusion::datasource::source::DataSourceExec;
use datafusion::error::Result;
use datafusion::execution::TaskContext;
use datafusion::logical_expr::LogicalPlan;
use datafusion::physical_plan::ExecutionPlan;
use datafusion::prelude::{SessionConfig, SessionContext};
use futures::future::BoxFuture;
use object_store::ObjectStoreExt;
use ursa_core::{IdMap, Topology};
use crate::database::{database_of_config, IndexKey, IndexStore};
use crate::notices::{self, NoticeLevel, Notifier};
#[derive(Debug)]
pub struct GraphIndex {
pub topology: Arc<Topology>,
pub ids: Arc<IdMap>,
n_dropped: u64,
kept: Option<BooleanBuffer>,
rows: OnceLock<Vec<u64>>,
weights: WeightCache,
stored: Option<StoredWeights>,
}
pub type WeightReader = Box<dyn Fn(&str) -> Option<Vec<Vec<f64>>> + Send + Sync>;
struct StoredWeights {
keys: Vec<String>,
read: WeightReader,
}
impl fmt::Debug for StoredWeights {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "StoredWeights({} sets)", self.keys.len())
}
}
impl GraphIndex {
pub fn new(topology: Arc<Topology>, ids: Arc<IdMap>) -> Self {
Self {
topology,
ids,
n_dropped: 0,
kept: None,
rows: OnceLock::new(),
weights: WeightCache::default(),
stored: None,
}
}
pub fn with_dropped(mut self, n_dropped: u64, kept: Option<BooleanBuffer>) -> Self {
self.n_dropped = n_dropped;
self.kept = kept.filter(|_| n_dropped > 0);
self
}
pub fn n_dropped(&self) -> u64 {
self.n_dropped
}
pub fn kept_rows(&self) -> Option<&BooleanBuffer> {
self.kept.as_ref()
}
pub fn edge_row(&self, edge: u32) -> u64 {
match &self.kept {
None => u64::from(edge),
Some(kept) => self
.rows
.get_or_init(|| kept.set_indices().map(|i| i as u64).collect())[edge as usize],
}
}
pub fn with_stored_weights(mut self, keys: Vec<String>, read: WeightReader) -> Self {
if !keys.is_empty() {
self.stored = Some(StoredWeights { keys, read });
}
self
}
pub fn weights(&self, key: &str) -> Option<Weights> {
self.weights.get(key)
}
pub fn has_stored_weights(&self, key: &str) -> bool {
self.stored
.as_ref()
.is_some_and(|s| s.keys.iter().any(|k| k == key))
}
pub fn stored_weights(&self, key: &str, n_columns: usize) -> Option<Weights> {
if let Some(weights) = self.weights(key) {
return Some(weights);
}
if !self.has_stored_weights(key) {
return None;
}
let columns = (self.stored.as_ref()?.read)(key)?;
let n_edges = self.topology.n_edges();
if columns.len() != n_columns || columns.iter().any(|c| c.len() != n_edges) {
return None;
}
let weights: Weights = Arc::new(columns.into_iter().map(Arc::new).collect());
self.store_weights(key.to_string(), Arc::clone(&weights));
Some(weights)
}
pub fn weight_sets(&self) -> Vec<(String, Weights)> {
self.weights.all()
}
pub fn store_weights(&self, key: String, weights: Weights) {
self.weights.put(key, weights)
}
}
pub type Weights = Arc<Vec<Arc<Vec<f64>>>>;
const WEIGHT_CAPACITY: usize = 4;
#[derive(Default)]
struct WeightCache {
entries: Mutex<Vec<(String, Weights)>>,
}
impl fmt::Debug for WeightCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
write!(f, "WeightCache({} entries)", entries.len())
}
}
impl WeightCache {
fn get(&self, key: &str) -> Option<Weights> {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let i = entries.iter().position(|(k, _)| k == key)?;
let entry = entries.remove(i);
let weights = Arc::clone(&entry.1);
entries.push(entry);
Some(weights)
}
fn all(&self) -> Vec<(String, Weights)> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries
.iter()
.map(|(k, w)| (k.clone(), Arc::clone(w)))
.collect()
}
fn put(&self, key: String, weights: Weights) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
entries.retain(|(k, _)| *k != key);
if entries.len() >= WEIGHT_CAPACITY {
entries.remove(0);
}
entries.push((key, weights));
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FileIdentity {
pub store: String,
pub location: String,
pub size: u64,
pub modified: i64,
pub e_tag: Option<String>,
}
pub fn fingerprint(plan: &LogicalPlan) -> Option<String> {
let mut rendered = plan.display_indent_schema().to_string();
let mut cacheable = true;
let mut locations: Vec<String> = Vec::new();
let mut scans: Vec<(String, String)> = Vec::new();
let _ = plan.apply_with_subqueries(|node| {
if node.expressions().iter().any(|e| e.is_volatile()) {
cacheable = false;
return Ok(TreeNodeRecursion::Stop);
}
if let LogicalPlan::TableScan(scan) = node {
let listing = source_as_provider(&scan.source)
.ok()
.and_then(|p| p.downcast_ref::<ListingTable>().map(listing_key));
match listing {
Some(listing) => {
let name = scan.table_name.to_string();
scans.push((name.clone(), listing));
if crate::paths::looks_like_location(&name) && !locations.contains(&name) {
locations.push(name);
}
}
None => {
cacheable = false;
return Ok(TreeNodeRecursion::Stop);
}
}
}
Ok(TreeNodeRecursion::Continue)
});
if !cacheable {
return None;
}
let placeholder = |name: &str| {
locations
.iter()
.position(|l| l == name)
.map(|i| format!("\u{0}file {i}\u{0}"))
};
let mut by_length: Vec<&String> = locations.iter().collect();
by_length.sort_by_key(|name| std::cmp::Reverse(name.len()));
for name in by_length {
if let Some(placeholder) = placeholder(name) {
rendered = rendered.replace(name.as_str(), &placeholder);
}
}
for (name, listing) in &scans {
let name = placeholder(name).unwrap_or_else(|| name.clone());
let _ = write!(rendered, "\n{name}: {listing}");
}
Some(rendered)
}
fn listing_key(table: &ListingTable) -> String {
let paths: Vec<String> = table.table_paths().iter().map(|p| p.to_string()).collect();
let options = table.options();
let partitions: Vec<String> = options
.table_partition_cols
.iter()
.map(|(name, data_type)| format!("{name} {data_type}"))
.collect();
let order: Vec<String> = options
.file_sort_order
.iter()
.map(|exprs| {
exprs
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
})
.collect();
format!(
"paths {paths:?}; extension {:?}; {}; partitions {partitions:?}; order {order:?}",
options.file_extension,
format_key(options.format.as_ref())
)
}
fn format_key(format: &dyn DataFusionFileFormat) -> String {
if let Some(csv) = format.downcast_ref::<CsvFormat>() {
let o = csv.options();
return format!(
"csv (header {:?}, delimiter {}, quote {}, terminator {:?}, escape {:?}, \
double_quote {:?}, comment {:?}, null_regex {:?}, null_value {:?}, \
truncated_rows {:?}, compression {:?}, formats {:?})",
o.has_header,
o.delimiter,
o.quote,
o.terminator,
o.escape,
o.double_quote,
o.comment,
o.null_regex,
o.null_value,
o.truncated_rows,
o.compression,
[
&o.date_format,
&o.datetime_format,
&o.timestamp_format,
&o.timestamp_tz_format,
&o.time_format
],
);
}
if let Some(json) = format.downcast_ref::<JsonFormat>() {
let o = json.options();
return format!(
"json (newline_delimited {}, compression {:?})",
o.newline_delimited, o.compression
);
}
if let Some(parquet) = format.downcast_ref::<ParquetFormat>() {
let o = &parquet.options().global;
return format!(
"parquet (binary_as_string {}, schema_force_view_types {}, coerce_int96 {:?} {:?})",
o.binary_as_string, o.schema_force_view_types, o.coerce_int96, o.coerce_int96_tz
);
}
if format.downcast_ref::<ArrowFormat>().is_some() {
return "arrow".to_string();
}
format!("{} {format:?}", format.get_ext())
}
pub(crate) const DATABASE_DIR: &str = "\u{0}database\u{0}";
fn dir_forms(dir: &Path) -> Vec<String> {
let mut forms = Vec::new();
let canonical = std::fs::canonicalize(dir).ok();
for dir in std::iter::once(dir).chain(canonical.as_deref()) {
let text = dir.to_string_lossy().replace('\\', "/");
let text = text.trim_end_matches('/');
if text.is_empty() {
continue;
}
let url = match text.strip_prefix('/') {
Some(rest) => format!("file:///{rest}/"),
None => format!("file:///{text}/"),
};
for form in [url, format!("{text}/")] {
if !forms.contains(&form) {
forms.push(form);
}
}
}
forms.sort_by_key(|f| std::cmp::Reverse(f.len()));
forms
}
pub(crate) fn portable(text: &str, dir: &Path) -> String {
let mut text = text.to_string();
for form in dir_forms(dir) {
text = text.replace(&form, &format!("{DATABASE_DIR}/"));
}
text
}
pub(crate) fn portable_in(text: &str, config: &SessionConfig) -> String {
match database_of_config(config) {
Some(db) => portable(text, db.dir()),
None => text.to_string(),
}
}
fn local_path(location: &str) -> String {
if cfg!(windows) || location.starts_with('/') {
location.to_string()
} else {
format!("/{location}")
}
}
pub async fn file_identities(
plan: &Arc<dyn ExecutionPlan>,
context: &TaskContext,
) -> Result<Vec<FileIdentity>> {
let mut files = Vec::new();
let _ = plan.apply(|node| {
if let Some(config) = node
.downcast_ref::<DataSourceExec>()
.and_then(|exec| exec.data_source().downcast_ref::<FileScanConfig>())
{
for group in &config.file_groups {
for file in group.files() {
files.push((
config.object_store_url.clone(),
file.object_meta.location.clone(),
));
}
}
}
Ok(TreeNodeRecursion::Continue)
});
files.sort();
files.dedup();
let database = database_of_config(context.session_config());
let mut identities = Vec::with_capacity(files.len());
for (url, location) in files {
let store = context.runtime_env().object_store(&url)?;
let meta = store.head(&location).await?;
let store = url.to_string();
let location = if store == LOCAL_STORE {
let path = local_path(location.as_ref());
match &database {
Some(db) => portable(&path, db.dir()),
None => path,
}
} else {
location.to_string()
};
identities.push(FileIdentity {
store,
location,
size: meta.size,
modified: meta.last_modified.timestamp_nanos_opt().unwrap_or(i64::MAX),
e_tag: meta.e_tag,
});
}
identities.sort();
Ok(identities)
}
const LOCAL_STORE: &str = "file:///";
const CAPACITY: usize = 16;
#[derive(Debug, Default)]
pub struct IndexCache {
slots: Mutex<HashMap<String, Slot>>,
clock: AtomicU64,
builds: AtomicU64,
loads: AtomicU64,
}
#[derive(Debug)]
struct Slot {
files: Vec<FileIdentity>,
cell: Arc<tokio::sync::OnceCell<Arc<GraphIndex>>>,
used: u64,
}
impl IndexCache {
pub fn builds(&self) -> u64 {
self.builds.load(Ordering::Relaxed)
}
pub fn loads(&self) -> u64 {
self.loads.load(Ordering::Relaxed)
}
pub fn record_build(&self) {
self.builds.fetch_add(1, Ordering::Relaxed);
}
pub fn get(&self, key: &(String, Vec<FileIdentity>)) -> Option<Arc<GraphIndex>> {
let mut slots = self.slots.lock().unwrap_or_else(|e| e.into_inner());
let slot = slots.get_mut(&key.0).filter(|s| s.files == key.1)?;
let index = slot.cell.get().cloned()?;
slot.used = self.clock.fetch_add(1, Ordering::Relaxed);
Some(index)
}
pub async fn get_or_build(
&self,
key: Option<(String, Vec<FileIdentity>)>,
build: BoxFuture<'_, Result<GraphIndex>>,
) -> Result<Arc<GraphIndex>> {
self.get_or_build_in(None, &Notifier::silent(), &BuildInfo::default(), key, build)
.await
}
pub async fn get_or_build_stored(
&self,
config: &SessionConfig,
key: Option<(String, Vec<FileIdentity>)>,
build: BoxFuture<'_, Result<GraphIndex>>,
) -> Result<Arc<GraphIndex>> {
self.get_or_build_for(config, key, &BuildInfo::default(), build)
.await
}
pub async fn get_or_build_for(
&self,
config: &SessionConfig,
key: Option<(String, Vec<FileIdentity>)>,
info: &BuildInfo,
build: BoxFuture<'_, Result<GraphIndex>>,
) -> Result<Arc<GraphIndex>> {
let stored =
database_of_config(config).map(|db| (db.index_store(), db.dir().to_path_buf()));
let notifier = Notifier::of(config);
self.get_or_build_in(stored, ¬ifier, info, key, build)
.await
}
async fn get_or_build_in(
&self,
stored: Option<(Arc<IndexStore>, PathBuf)>,
notifier: &Notifier,
info: &BuildInfo,
key: Option<(String, Vec<FileIdentity>)>,
build: BoxFuture<'_, Result<GraphIndex>>,
) -> Result<Arc<GraphIndex>> {
let Some((fingerprint, files)) = key else {
self.builds.fetch_add(1, Ordering::Relaxed);
return build_noticed(notifier, info, build, Kept::Never).await;
};
let stored = stored.map(|(store, dir)| {
(
store,
IndexKey::new(portable(&fingerprint, &dir), files.clone()),
)
});
let cell = {
let mut slots = self.slots.lock().unwrap_or_else(|e| e.into_inner());
let used = self.clock.fetch_add(1, Ordering::Relaxed);
match slots.get_mut(&fingerprint) {
Some(slot) if slot.files == files => {
slot.used = used;
Arc::clone(&slot.cell)
}
_ => {
if !slots.contains_key(&fingerprint) && slots.len() >= CAPACITY {
if let Some(oldest) = slots
.iter()
.min_by_key(|(_, s)| s.used)
.map(|(k, _)| k.clone())
{
slots.remove(&oldest);
}
}
let cell = Arc::new(tokio::sync::OnceCell::new());
slots.insert(
fingerprint,
Slot {
files,
cell: Arc::clone(&cell),
used,
},
);
cell
}
}
};
let (builds, loads) = (&self.builds, &self.loads);
cell.get_or_try_init(|| async move {
if let Some((store, key)) = &stored {
let (store, key) = (Arc::clone(store), key.clone());
let started = clock(notifier, NoticeLevel::All);
let reader = notifier.clone();
let loaded = crate::run_blocking(move || {
let loaded = store.load(&key, &reader);
loaded.map(|(index, size)| (index, size, store))
})
.await
.ok()
.flatten();
if let Some((index, size, store)) = loaded {
loads.fetch_add(1, Ordering::Relaxed);
notifier.say(NoticeLevel::All, || {
format!(
"loaded the index for {} ({} edges) from {} ({}){}",
info.graph(),
notices::count(index.topology.n_edges() as u64),
store.database(),
notices::size(size),
took(started)
)
});
return Ok(index);
}
}
builds.fetch_add(1, Ordering::Relaxed);
let kept = match &stored {
Some((store, key)) => Kept::Stored(Arc::clone(store), key.clone()),
None => Kept::InMemory,
};
build_noticed(notifier, info, build, kept).await
})
.await
.cloned()
}
}
#[derive(Debug, Clone, Default)]
pub struct BuildInfo {
pub graph: Option<String>,
pub edges: Option<(u64, bool)>,
}
impl BuildInfo {
fn graph(&self) -> &str {
self.graph.as_deref().unwrap_or("a graph")
}
}
enum Kept {
Stored(Arc<IndexStore>, IndexKey),
InMemory,
Never,
}
fn clock(notifier: &Notifier, level: NoticeLevel) -> Option<Instant> {
(notifier.level() >= level).then(Instant::now)
}
fn took(started: Option<Instant>) -> String {
started
.map(|s| format!(" in {}", notices::duration(s.elapsed())))
.unwrap_or_default()
}
async fn build_noticed(
notifier: &Notifier,
info: &BuildInfo,
build: BoxFuture<'_, Result<GraphIndex>>,
kept: Kept,
) -> Result<Arc<GraphIndex>> {
let started = clock(notifier, NoticeLevel::On);
let estimate = match info.edges {
Some((n, true)) if n > 0 => format!(" ({} edges)", notices::count(n)),
Some((n, false)) if n > 0 => format!(" (about {} edges)", notices::count(n)),
_ => String::new(),
};
let delay = match notifier.level() {
NoticeLevel::All => Duration::ZERO,
_ => notices::BUILD_NOTICE_AFTER,
};
let announcement = notifier.announce_after(
delay,
format!("building the index for {}{estimate}…", info.graph()),
);
let built = build.await;
let announced = announcement.finish();
let index = Arc::new(built?);
let (outcome, loses_work) = match kept {
Kept::Stored(store, key) => {
let saved = store.save(key, Arc::clone(&index), info.graph(), notifier);
(
format!("; {}", saved.describe(store.database())),
saved.loses_work(),
)
}
Kept::InMemory => (
"; kept for this session only (open a .ursa database to store it)".to_string(),
false,
),
Kept::Never => (
"; not kept: what its edges read can change (an in-memory table, or a volatile \
function such as random())"
.to_string(),
false,
),
};
let level = if announced || loses_work {
NoticeLevel::On
} else {
NoticeLevel::All
};
notifier.say(level, || {
format!(
"built the index for {} ({} edges){}{outcome}",
info.graph(),
notices::count(index.topology.n_edges() as u64),
took(started)
)
});
Ok(index)
}
pub(crate) fn with_index_cache(config: SessionConfig) -> SessionConfig {
config.with_extension(Arc::new(IndexCache::default()))
}
pub fn index_builds(ctx: &SessionContext) -> u64 {
ctx.state()
.config()
.get_extension::<IndexCache>()
.map(|c| c.builds())
.unwrap_or(0)
}
pub fn index_loads(ctx: &SessionContext) -> u64 {
ctx.state()
.config()
.get_extension::<IndexCache>()
.map(|c| c.loads())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_inside_the_database_directory_are_portable() {
let dir = Path::new("/home/me/proj");
let text =
"TableScan: /home/me/proj/edges.csv\npaths [\"file:///home/me/proj/edges.csv\"] \
/home/me/project2/x.csv /home/me/proj";
assert_eq!(
portable(text, dir),
format!(
"TableScan: {DATABASE_DIR}/edges.csv\npaths [\"{DATABASE_DIR}/edges.csv\"] \
/home/me/project2/x.csv /home/me/proj"
)
);
assert_eq!(portable("/a/b.csv", Path::new("/")), "/a/b.csv");
assert_eq!(
portable("/home/me/proj/edges.csv", dir),
portable("/mnt/elsewhere/edges.csv", Path::new("/mnt/elsewhere/"))
);
}
#[test]
fn local_locations_keep_their_leading_slash() {
#[cfg(not(windows))]
assert_eq!(local_path("home/me/a.csv"), "/home/me/a.csv");
assert_eq!(local_path("/home/me/a.csv"), "/home/me/a.csv");
}
}