use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Seek, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use datafusion::error::{DataFusionError, Result};
use datafusion::execution::context::SessionState;
use datafusion::prelude::SessionConfig;
pub mod catalog_file;
pub mod index_file;
pub mod index_store;
use crate::options::{ByteSize, DEFAULT_INDEX_STORAGE_LIMIT};
pub use catalog_file::{CatalogFile, FORMAT, VERSION};
pub use index_file::IndexKey;
pub use index_store::IndexStore;
pub const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Clone)]
pub struct OpenOptions {
pub read_only: bool,
pub lock_timeout: Duration,
}
impl Default for OpenOptions {
fn default() -> Self {
Self {
read_only: false,
lock_timeout: DEFAULT_LOCK_TIMEOUT,
}
}
}
impl OpenOptions {
pub fn read_only() -> Self {
Self {
read_only: true,
..Self::default()
}
}
}
#[derive(Debug)]
pub struct Database {
name: String,
path: PathBuf,
dir: PathBuf,
sidecar: PathBuf,
read_only: bool,
_lock: Option<File>,
catalog: Mutex<CatalogFile>,
indexes: OnceLock<Arc<IndexStore>>,
}
pub const INDEX_STORAGE_LIMIT: &str = "ursa.index_storage_limit";
impl Database {
pub fn open(path: impl AsRef<Path>, options: &OpenOptions) -> Result<Self> {
let given = path.as_ref();
let name = given.display().to_string();
let path = std::path::absolute(given).map_err(|e| {
DataFusionError::Execution(format!("cannot open database '{name}': {e}"))
})?;
let dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/"));
let file_name = path.file_name().ok_or_else(|| {
DataFusionError::Execution(format!("'{name}' does not name a database file"))
})?;
let mut sidecar_name = file_name.to_os_string();
sidecar_name.push(".d");
let sidecar = dir.join(sidecar_name);
if options.read_only {
if !path.exists() {
return Err(DataFusionError::Execution(format!(
"cannot open '{name}' read-only: the file does not exist"
)));
}
let catalog = CatalogFile::read(&path, &name)?;
return Ok(Self {
name,
path,
dir,
sidecar,
read_only: true,
_lock: None,
catalog: Mutex::new(catalog),
indexes: OnceLock::new(),
});
}
if !dir.is_dir() {
return Err(DataFusionError::Execution(format!(
"cannot create database '{name}': the directory {} does not exist",
dir.display()
)));
}
let io = |what: &str, e: std::io::Error| {
DataFusionError::Execution(format!("cannot open database '{name}': {what}: {e}"))
};
std::fs::create_dir_all(&sidecar)
.map_err(|e| io(&format!("creating {}", sidecar.display()), e))?;
let mut lock = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(sidecar.join("lock"))
.map_err(|e| io("opening its lock file", e))?;
acquire(&lock, &name, options.lock_timeout)?;
record_holder(&mut lock);
let catalog = if path.exists() {
CatalogFile::read(&path, &name)?
} else {
let empty = CatalogFile::default();
empty.write(&path, &sidecar, &name)?;
empty
};
Ok(Self {
name,
path,
dir,
sidecar,
read_only: false,
_lock: Some(lock),
catalog: Mutex::new(catalog),
indexes: OnceLock::new(),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn sidecar_dir(&self) -> &Path {
&self.sidecar
}
pub fn index_dir(&self) -> PathBuf {
self.sidecar.join("indexes")
}
pub fn index_store(&self) -> Arc<IndexStore> {
Arc::clone(self.indexes.get_or_init(|| {
Arc::new(IndexStore::new(
self.index_dir(),
!self.read_only,
self.name.clone(),
self.index_storage_limit(),
))
}))
}
pub fn flush(&self) {
if let Some(store) = self.indexes.get() {
store.flush();
}
}
pub fn is_read_only(&self) -> bool {
self.read_only
}
fn catalog(&self) -> std::sync::MutexGuard<'_, CatalogFile> {
self.catalog.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn statements(&self) -> Vec<String> {
self.catalog().statements.clone()
}
pub fn settings(&self) -> BTreeMap<String, String> {
self.catalog().settings.clone()
}
pub fn index_storage_limit(&self) -> u64 {
self.catalog()
.settings
.get(INDEX_STORAGE_LIMIT)
.and_then(|v| ByteSize::parse(v).ok())
.map_or(DEFAULT_INDEX_STORAGE_LIMIT, |b| b.0)
}
pub fn save(&self, statements: Vec<String>) -> Result<()> {
let mut catalog = self.catalog();
if !self.read_only && catalog.statements == statements {
return Ok(());
}
let updated = CatalogFile {
statements,
settings: catalog.settings.clone(),
};
self.write_catalog(&mut catalog, updated)
}
pub fn set_setting(&self, name: &str, value: Option<&str>) -> Result<()> {
if name == INDEX_STORAGE_LIMIT {
if let Some(value) = value {
ByteSize::parse(value)?;
}
}
let mut catalog = self.catalog();
let mut updated = catalog.clone();
match value {
Some(value) => updated.settings.insert(name.to_string(), value.to_string()),
None => updated.settings.remove(name),
};
if !self.read_only && updated == *catalog {
return Ok(());
}
self.write_catalog(&mut catalog, updated)?;
drop(catalog);
if let Some(store) = self.indexes.get() {
store.set_limit(self.index_storage_limit());
}
Ok(())
}
fn write_catalog(&self, current: &mut CatalogFile, updated: CatalogFile) -> Result<()> {
if self.read_only {
return Err(DataFusionError::Plan(format!(
"{} is open read-only",
self.name
)));
}
updated.write(&self.path, &self.sidecar, &self.name)?;
*current = updated;
Ok(())
}
pub fn stored_location(&self, location: &str, cwd: &Path) -> Option<String> {
if !is_local_path(location) {
return None;
}
let absolute = normalize(&cwd.join(location));
let canonical_dir = std::fs::canonicalize(&self.dir).ok();
let inside = [Some(&self.dir), canonical_dir.as_ref()]
.into_iter()
.flatten()
.find_map(|dir| absolute.strip_prefix(dir).ok());
let mut stored = match inside {
Some(relative) if relative.as_os_str().is_empty() => ".".to_string(),
Some(relative) => relative.to_string_lossy().into_owned(),
None => absolute.to_string_lossy().into_owned(),
};
keep_trailing_separator(location, &mut stored);
(stored != location).then_some(stored)
}
pub fn replayed_location(&self, location: &str) -> Option<String> {
if !is_relative_path(location) {
return None;
}
let mut resolved = normalize(&self.dir.join(location))
.to_string_lossy()
.into_owned();
keep_trailing_separator(location, &mut resolved);
Some(resolved)
}
}
impl Drop for Database {
fn drop(&mut self) {
self.flush();
}
}
pub fn is_local_path(location: &str) -> bool {
if location.is_empty() || location.starts_with('~') {
return false;
}
match url::Url::parse(location) {
Ok(url) => url.scheme().len() == 1,
Err(_) => true,
}
}
pub fn is_relative_path(location: &str) -> bool {
is_local_path(location) && Path::new(location).is_relative()
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => out.push(".."),
},
other => out.push(other),
}
}
out
}
fn keep_trailing_separator(original: &str, rewritten: &mut String) {
let separator = std::path::MAIN_SEPARATOR;
if (original.ends_with('/') || original.ends_with(separator))
&& !rewritten.ends_with(['/', separator])
{
rewritten.push(separator);
}
}
fn acquire(lock: &File, name: &str, timeout: Duration) -> Result<()> {
let start = Instant::now();
loop {
match lock.try_lock() {
Ok(()) => return Ok(()),
Err(std::fs::TryLockError::WouldBlock) => {
if start.elapsed() >= timeout {
let holder = holder(lock)
.map(|pid| format!(" (process {pid})"))
.unwrap_or_default();
return Err(DataFusionError::Execution(format!(
"{name} is in use by another process{holder}, which has it open for \
writing; open it read-only to query it meanwhile (ursa --readonly \
{name}), or wait for that process to finish"
)));
}
std::thread::sleep(Duration::from_millis(50));
}
Err(std::fs::TryLockError::Error(e)) => {
return Err(DataFusionError::Execution(format!(
"cannot lock database '{name}': {e}"
)))
}
}
}
}
fn record_holder(lock: &mut File) {
let _ = lock.set_len(0).and_then(|()| {
lock.rewind()?;
writeln!(lock, "{}", std::process::id())?;
lock.flush()
});
}
fn holder(lock: &File) -> Option<u32> {
let mut text = String::new();
let mut reader = lock;
reader.rewind().ok()?;
reader.take(32).read_to_string(&mut text).ok()?;
text.trim().parse().ok()
}
#[derive(Debug, Default)]
pub struct DatabaseSlot {
database: OnceLock<Arc<Database>>,
}
impl DatabaseSlot {
pub fn get(&self) -> Option<Arc<Database>> {
self.database.get().cloned()
}
pub fn set(&self, database: Arc<Database>) -> Result<()> {
self.database
.set(database)
.map_err(|_| DataFusionError::Plan("this session already has a database open".into()))
}
}
pub(crate) fn with_database_slot(config: SessionConfig) -> SessionConfig {
config.with_extension(Arc::new(DatabaseSlot::default()))
}
pub fn slot(state: &SessionState) -> Arc<DatabaseSlot> {
slot_of_config(state.config())
}
fn slot_of_config(config: &SessionConfig) -> Arc<DatabaseSlot> {
config
.get_extension::<DatabaseSlot>()
.expect("an Ursa session has a database slot (built by ursa_engine::new_session)")
}
pub fn database(state: &SessionState) -> Option<Arc<Database>> {
database_of_config(state.config())
}
pub fn database_of_config(config: &SessionConfig) -> Option<Arc<Database>> {
config.get_extension::<DatabaseSlot>()?.get()
}
pub fn flush(state: &SessionState) {
if let Some(db) = database(state) {
db.flush();
}
}
#[cfg(test)]
mod tests;