ursa_engine/database/
mod.rs1use std::fs::File;
35use std::path::{Component, Path, PathBuf};
36use std::sync::{Arc, Mutex, OnceLock};
37use std::time::{Duration, Instant};
38
39use datafusion::error::{DataFusionError, Result};
40use datafusion::execution::context::SessionState;
41use datafusion::prelude::SessionConfig;
42
43pub mod catalog_file;
44pub mod index_file;
45pub mod index_store;
46
47pub use catalog_file::{CatalogFile, FORMAT, VERSION};
48pub use index_file::IndexKey;
49pub use index_store::IndexStore;
50
51pub const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(2);
53
54#[derive(Debug, Clone)]
56pub struct OpenOptions {
57 pub read_only: bool,
60 pub lock_timeout: Duration,
63}
64
65impl Default for OpenOptions {
66 fn default() -> Self {
67 Self {
68 read_only: false,
69 lock_timeout: DEFAULT_LOCK_TIMEOUT,
70 }
71 }
72}
73
74impl OpenOptions {
75 pub fn read_only() -> Self {
76 Self {
77 read_only: true,
78 ..Self::default()
79 }
80 }
81}
82
83#[derive(Debug)]
85pub struct Database {
86 name: String,
88 path: PathBuf,
90 dir: PathBuf,
93 sidecar: PathBuf,
95 read_only: bool,
96 _lock: Option<File>,
99 statements: Mutex<Vec<String>>,
101 indexes: OnceLock<Arc<IndexStore>>,
103}
104
105impl Database {
106 pub fn open(path: impl AsRef<Path>, options: &OpenOptions) -> Result<Self> {
110 let given = path.as_ref();
111 let name = given.display().to_string();
112 let path = std::path::absolute(given).map_err(|e| {
113 DataFusionError::Execution(format!("cannot open database '{name}': {e}"))
114 })?;
115 let dir = path
116 .parent()
117 .map(Path::to_path_buf)
118 .unwrap_or_else(|| PathBuf::from("/"));
119 let file_name = path.file_name().ok_or_else(|| {
120 DataFusionError::Execution(format!("'{name}' does not name a database file"))
121 })?;
122 let mut sidecar_name = file_name.to_os_string();
123 sidecar_name.push(".d");
124 let sidecar = dir.join(sidecar_name);
125
126 if options.read_only {
127 if !path.exists() {
128 return Err(DataFusionError::Execution(format!(
129 "cannot open '{name}' read-only: the file does not exist"
130 )));
131 }
132 let catalog = CatalogFile::read(&path, &name)?;
133 return Ok(Self {
134 name,
135 path,
136 dir,
137 sidecar,
138 read_only: true,
139 _lock: None,
140 statements: Mutex::new(catalog.statements),
141 indexes: OnceLock::new(),
142 });
143 }
144
145 if !dir.is_dir() {
146 return Err(DataFusionError::Execution(format!(
147 "cannot create database '{name}': the directory {} does not exist",
148 dir.display()
149 )));
150 }
151 let io = |what: &str, e: std::io::Error| {
152 DataFusionError::Execution(format!("cannot open database '{name}': {what}: {e}"))
153 };
154 std::fs::create_dir_all(&sidecar)
155 .map_err(|e| io(&format!("creating {}", sidecar.display()), e))?;
156 let lock = File::options()
157 .read(true)
158 .write(true)
159 .create(true)
160 .truncate(false)
161 .open(sidecar.join("lock"))
162 .map_err(|e| io("opening its lock file", e))?;
163 acquire(&lock, &name, options.lock_timeout)?;
164
165 let statements = if path.exists() {
166 CatalogFile::read(&path, &name)?.statements
167 } else {
168 let empty = CatalogFile::default();
169 empty.write(&path, &sidecar, &name)?;
170 empty.statements
171 };
172 Ok(Self {
173 name,
174 path,
175 dir,
176 sidecar,
177 read_only: false,
178 _lock: Some(lock),
179 statements: Mutex::new(statements),
180 indexes: OnceLock::new(),
181 })
182 }
183
184 pub fn name(&self) -> &str {
186 &self.name
187 }
188
189 pub fn path(&self) -> &Path {
191 &self.path
192 }
193
194 pub fn dir(&self) -> &Path {
197 &self.dir
198 }
199
200 pub fn sidecar_dir(&self) -> &Path {
202 &self.sidecar
203 }
204
205 pub fn index_dir(&self) -> PathBuf {
208 self.sidecar.join("indexes")
209 }
210
211 pub fn index_store(&self) -> Arc<IndexStore> {
214 Arc::clone(
215 self.indexes
216 .get_or_init(|| Arc::new(IndexStore::new(self.index_dir(), !self.read_only))),
217 )
218 }
219
220 pub fn flush(&self) {
222 if let Some(store) = self.indexes.get() {
223 store.flush();
224 }
225 }
226
227 pub fn is_read_only(&self) -> bool {
228 self.read_only
229 }
230
231 pub fn statements(&self) -> Vec<String> {
233 self.statements
234 .lock()
235 .unwrap_or_else(|e| e.into_inner())
236 .clone()
237 }
238
239 pub fn save(&self, statements: Vec<String>) -> Result<()> {
242 if self.read_only {
243 return Err(DataFusionError::Plan(format!(
244 "{} is open read-only",
245 self.name
246 )));
247 }
248 let mut current = self.statements.lock().unwrap_or_else(|e| e.into_inner());
249 if *current == statements {
250 return Ok(());
251 }
252 let file = CatalogFile {
253 statements: statements.clone(),
254 };
255 file.write(&self.path, &self.sidecar, &self.name)?;
256 *current = statements;
257 Ok(())
258 }
259
260 pub fn stored_location(&self, location: &str, cwd: &Path) -> Option<String> {
266 if !is_local_path(location) {
267 return None;
268 }
269 let absolute = normalize(&cwd.join(location));
270 let canonical_dir = std::fs::canonicalize(&self.dir).ok();
271 let inside = [Some(&self.dir), canonical_dir.as_ref()]
272 .into_iter()
273 .flatten()
274 .find_map(|dir| absolute.strip_prefix(dir).ok());
275 let mut stored = match inside {
276 Some(relative) if relative.as_os_str().is_empty() => ".".to_string(),
277 Some(relative) => relative.to_string_lossy().into_owned(),
278 None => absolute.to_string_lossy().into_owned(),
279 };
280 keep_trailing_separator(location, &mut stored);
281 (stored != location).then_some(stored)
282 }
283
284 pub fn replayed_location(&self, location: &str) -> Option<String> {
288 if !is_relative_path(location) {
289 return None;
290 }
291 let mut resolved = normalize(&self.dir.join(location))
292 .to_string_lossy()
293 .into_owned();
294 keep_trailing_separator(location, &mut resolved);
295 Some(resolved)
296 }
297}
298
299impl Drop for Database {
300 fn drop(&mut self) {
302 self.flush();
303 }
304}
305
306pub fn is_local_path(location: &str) -> bool {
308 if location.is_empty() || location.starts_with('~') {
309 return false;
310 }
311 match url::Url::parse(location) {
312 Ok(url) => url.scheme().len() == 1,
314 Err(_) => true,
315 }
316}
317
318pub fn is_relative_path(location: &str) -> bool {
321 is_local_path(location) && Path::new(location).is_relative()
322}
323
324fn normalize(path: &Path) -> PathBuf {
327 let mut out = PathBuf::new();
328 for component in path.components() {
329 match component {
330 Component::CurDir => {}
331 Component::ParentDir => match out.components().next_back() {
332 Some(Component::Normal(_)) => {
333 out.pop();
334 }
335 Some(Component::RootDir | Component::Prefix(_)) => {}
337 _ => out.push(".."),
338 },
339 other => out.push(other),
340 }
341 }
342 out
343}
344
345fn keep_trailing_separator(original: &str, rewritten: &mut String) {
347 let separator = std::path::MAIN_SEPARATOR;
348 if (original.ends_with('/') || original.ends_with(separator))
349 && !rewritten.ends_with(['/', separator])
350 {
351 rewritten.push(separator);
352 }
353}
354
355fn acquire(lock: &File, name: &str, timeout: Duration) -> Result<()> {
357 let start = Instant::now();
358 loop {
359 match lock.try_lock() {
360 Ok(()) => return Ok(()),
361 Err(std::fs::TryLockError::WouldBlock) => {
362 if start.elapsed() >= timeout {
363 return Err(DataFusionError::Execution(format!(
364 "{name} is in use by another process"
365 )));
366 }
367 std::thread::sleep(Duration::from_millis(50));
368 }
369 Err(std::fs::TryLockError::Error(e)) => {
370 return Err(DataFusionError::Execution(format!(
371 "cannot lock database '{name}': {e}"
372 )))
373 }
374 }
375 }
376}
377
378#[derive(Debug, Default)]
382pub struct DatabaseSlot {
383 database: OnceLock<Arc<Database>>,
384}
385
386impl DatabaseSlot {
387 pub fn get(&self) -> Option<Arc<Database>> {
388 self.database.get().cloned()
389 }
390
391 pub fn set(&self, database: Arc<Database>) -> Result<()> {
393 self.database
394 .set(database)
395 .map_err(|_| DataFusionError::Plan("this session already has a database open".into()))
396 }
397}
398
399pub(crate) fn with_database_slot(config: SessionConfig) -> SessionConfig {
401 config.with_extension(Arc::new(DatabaseSlot::default()))
402}
403
404pub fn slot(state: &SessionState) -> Arc<DatabaseSlot> {
406 slot_of_config(state.config())
407}
408
409fn slot_of_config(config: &SessionConfig) -> Arc<DatabaseSlot> {
410 config
411 .get_extension::<DatabaseSlot>()
412 .expect("an Ursa session has a database slot (built by ursa_engine::new_session)")
413}
414
415pub fn database(state: &SessionState) -> Option<Arc<Database>> {
417 database_of_config(state.config())
418}
419
420pub fn database_of_config(config: &SessionConfig) -> Option<Arc<Database>> {
423 config.get_extension::<DatabaseSlot>()?.get()
424}
425
426pub fn flush(state: &SessionState) {
429 if let Some(db) = database(state) {
430 db.flush();
431 }
432}
433
434#[cfg(test)]
435mod tests;