use std::collections::HashMap;
use std::fmt::{self, Write as _};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use datafusion::catalog::default_table_source::source_as_provider;
use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
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};
#[derive(Debug)]
pub struct GraphIndex {
pub topology: Arc<Topology>,
pub ids: Arc<IdMap>,
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,
weights: WeightCache::default(),
stored: None,
}
}
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 _ = 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(|t| {
let paths: Vec<String> =
t.table_paths().iter().map(|p| p.to_string()).collect();
format!("{paths:?} {:?}", t.options())
})
});
match listing {
Some(listing) => {
let _ = write!(rendered, "\n{}: {listing}", scan.table_name);
}
None => {
cacheable = false;
return Ok(TreeNodeRecursion::Stop);
}
}
}
Ok(TreeNodeRecursion::Continue)
});
cacheable.then_some(rendered)
}
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 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?;
identities.push(FileIdentity {
store: url.to_string(),
location: location.to_string(),
size: meta.size,
modified: meta.last_modified.timestamp_nanos_opt().unwrap_or(i64::MAX),
e_tag: meta.e_tag,
});
}
Ok(identities)
}
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, 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>> {
let stored = database_of_config(config).map(|db| {
(
db.index_store(),
crate::options::index_storage_limit(config),
)
});
self.get_or_build_in(stored, key, build).await
}
async fn get_or_build_in(
&self,
stored: Option<(Arc<IndexStore>, u64)>,
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.await.map(Arc::new);
};
let stored = stored.map(|(store, limit)| {
(
store,
limit,
IndexKey::new(fingerprint.clone(), 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, limit, key)) = &stored {
let (store, key, limit) = (Arc::clone(store), key.clone(), *limit);
let loaded = crate::run_blocking(move || store.load(&key, limit))
.await
.ok()
.flatten();
if let Some(index) = loaded {
loads.fetch_add(1, Ordering::Relaxed);
return Ok(index);
}
}
builds.fetch_add(1, Ordering::Relaxed);
let index = Arc::new(build.await?);
if let Some((store, limit, key)) = stored {
store.save(key, Arc::clone(&index), limit);
}
Ok(index)
})
.await
.cloned()
}
}
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)
}