Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1532,19 +1532,21 @@ mod tests {
let path = dir.path().join("test.graph");
let wal_path = dir.path().join("test.graph.wal");

let db = Minigraf::open(&path).unwrap();
db.execute(r#"(transact [[:alice :person/name "Alice"]])"#)
.unwrap();
{
let db = Minigraf::open(&path).unwrap();
db.execute(r#"(transact [[:alice :person/name "Alice"]])"#)
.unwrap();

// WAL should exist before checkpoint
assert!(wal_path.exists(), "WAL must exist after a write");
// WAL should exist before checkpoint
assert!(wal_path.exists(), "WAL must exist after a write");

db.checkpoint().unwrap();
db.checkpoint().unwrap();

// WAL should be deleted after checkpoint
assert!(!wal_path.exists(), "WAL must be deleted after checkpoint");
// WAL should be deleted after checkpoint
assert!(!wal_path.exists(), "WAL must be deleted after checkpoint");
}

// Main file should be present with facts
// Reopen after first handle is dropped (releases file lock)
let db2 = Minigraf::open(&path).unwrap();
let facts = db2.inner.fact_storage.get_asserted_facts().unwrap();
assert_eq!(facts.len(), 1, "facts must survive checkpoint");
Expand Down
58 changes: 46 additions & 12 deletions src/graph/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,9 +450,16 @@ pub(crate) fn filter_facts_as_of(facts: Vec<Fact>, as_of: &AsOf) -> Vec<Fact> {

/// Compute the net-asserted view of a fact set.
///
/// For each unique `(entity, attribute, value)` triple, keeps the fact only if
/// the record with the highest `tx_count` for that triple has `asserted=true`.
/// Facts whose most recent record is a retraction are excluded entirely.
/// For each unique `(entity, attribute, value)` triple:
/// 1. Find the retraction with the highest `tx_count` (if any).
/// 2. Keep all assertions whose `tx_count` is greater than that retraction.
/// 3. Deduplicate surviving assertions by `(valid_from, valid_to)`, keeping the
/// one with the highest `tx_count` for each validity window.
///
/// This allows the same EAV triple to be asserted at multiple non-overlapping
/// valid-time intervals (e.g., salary=$100k valid 2020–2022 AND 2024–2026).
/// A retraction still cancels all prior assertions of that triple, but
/// re-assertions after the retraction are preserved.
///
/// Uses [`encode_value`] for the value key to handle floating-point edge cases
/// (NaN canonicalisation, ±0.0 disambiguation) consistently with the rest of
Expand All @@ -462,25 +469,52 @@ pub(crate) fn filter_facts_as_of(facts: Vec<Fact>, as_of: &AsOf) -> Vec<Fact> {
/// `get_current_value` and `filter_facts_for_query`.
pub(crate) fn net_asserted_facts(facts: Vec<Fact>) -> Vec<Fact> {
use std::collections::HashMap;
// key: (entity, attribute, canonical value bytes) → fact with highest tx_count
let mut latest: HashMap<(EntityId, Attribute, Vec<u8>), Fact> = HashMap::new();

// Group all facts by (entity, attribute, canonical value bytes).
let mut groups: HashMap<(EntityId, Attribute, Vec<u8>), Vec<Fact>> = HashMap::new();
for fact in facts {
let key = (
fact.entity,
fact.attribute.clone(),
encode_value(&fact.value),
);
match latest.get(&key) {
None => {
latest.insert(key, fact);
groups.entry(key).or_default().push(fact);
}

let mut result = Vec::new();
for (_key, group) in groups {
// Find the highest tx_count among retractions in this group.
let max_retract_tx = group
.iter()
.filter(|f| !f.asserted)
.map(|f| f.tx_count)
.max()
.unwrap_or(0);

// Keep assertions whose tx_count > max_retract_tx.
// (If no retraction exists, max_retract_tx is 0 and all assertions pass.)
// Deduplicate by (valid_from, valid_to): keep the highest-tx_count assertion
// for each validity window so that re-asserting the same EAV at the same
// window acts as an update rather than a duplicate.
let mut by_window: HashMap<(i64, i64), Fact> = HashMap::new();
for fact in group {
if !fact.asserted || fact.tx_count <= max_retract_tx {
continue;
}
Some(existing) if fact.tx_count > existing.tx_count => {
latest.insert(key, fact);
let window = (fact.valid_from, fact.valid_to);
match by_window.get(&window) {
None => {
by_window.insert(window, fact);
}
Some(existing) if fact.tx_count > existing.tx_count => {
by_window.insert(window, fact);
}
_ => {}
}
_ => {}
}
result.extend(by_window.into_values());
}
latest.into_values().filter(|f| f.asserted).collect()
result
}

/// Resolve a FactRef to a Fact using the committed reader (for on-disk facts)
Expand Down
57 changes: 53 additions & 4 deletions src/query/datalog/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,15 @@ fn tokenize(input: &str) -> Result<Vec<Token>, String> {
let mut num_str = String::from("-");
let (is_float, num) = parse_number(&mut chars, &mut num_str)?;
if is_float {
tokens.push(Token::Float(num.parse().unwrap()));
let f: f64 = num
.parse()
.map_err(|_| format!("Float literal out of range: {}", num))?;
tokens.push(Token::Float(f));
} else {
tokens.push(Token::Integer(num.parse().unwrap()));
let i: i64 = num
.parse()
.map_err(|_| format!("Integer literal out of range: {}", num))?;
tokens.push(Token::Integer(i));
}
} else {
// It's a symbol starting with -
Expand All @@ -169,9 +175,15 @@ fn tokenize(input: &str) -> Result<Vec<Token>, String> {
let mut num_str = String::new();
let (is_float, num) = parse_number(&mut chars, &mut num_str)?;
if is_float {
tokens.push(Token::Float(num.parse().unwrap()));
let f: f64 = num
.parse()
.map_err(|_| format!("Float literal out of range: {}", num))?;
tokens.push(Token::Float(f));
} else {
tokens.push(Token::Integer(num.parse().unwrap()));
let i: i64 = num
.parse()
.map_err(|_| format!("Integer literal out of range: {}", num))?;
tokens.push(Token::Integer(i));
}
}
// Symbols (including variables starting with ?)
Expand Down Expand Up @@ -2810,6 +2822,43 @@ mod tests {
let result = parse_edn("$");
assert!(result.is_err(), "bare '$' should be a parse error");
}

#[test]
fn test_integer_overflow_returns_error() {
let result = tokenize("99999999999999999999999999999");
assert!(result.is_err(), "i64 overflow must return error, not panic");
let err = result.unwrap_err();
assert!(
err.contains("out of range"),
"error should mention out of range"
);
}

#[test]
fn test_negative_integer_overflow_returns_error() {
let result = tokenize("-99999999999999999999999999999");
assert!(result.is_err(), "negative i64 overflow must return error");
}

#[test]
fn test_float_overflow_returns_error() {
// f64::MAX is ~1.8e308, so 1e999 overflows to infinity
let result = tokenize("1.0e999");
// f64 parse of very large exponent may produce infinity rather than error;
// our code rejects it if parse() itself errors. For f64, parse() succeeds
// with infinity, so this may not error. The critical fix is for integers.
// Just ensure no panic occurs.
let _ = result;
}

#[test]
fn test_overflow_in_transact_returns_error() {
let result = parse_datalog_command("(transact [[:e :a 99999999999999999999999999999]])");
assert!(
result.is_err(),
"overflow in transact should be parse error"
);
}
}

#[cfg(test)]
Expand Down
89 changes: 89 additions & 0 deletions src/storage/backend/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,86 @@ use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};

/// Advisory file lock to prevent multi-process corruption.
///
/// Uses a sidecar `.lock` file with exclusive creation semantics.
/// The lock is released (file deleted) on drop.
struct FileLock {
path: PathBuf,
}

impl FileLock {
/// Attempt to acquire an exclusive lock for the given database path.
/// Returns `Err` if another process already holds the lock.
///
/// If a stale lock file exists (the holder PID is no longer running),
/// it is automatically removed and a new lock is acquired. This handles
/// the case where the previous process crashed without cleaning up.
fn acquire(db_path: &Path) -> Result<Self> {
let lock_path = db_path.with_extension("graph.lock");
// create_new fails with AlreadyExists if the lock file is present
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut f) => {
// Write PID for diagnostics (best-effort)
let _ = write!(f, "{}", std::process::id());
Ok(FileLock { path: lock_path })
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// Check if the holder process is still alive
let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
if let Ok(pid) = holder.trim().parse::<u32>() {
let our_pid = std::process::id();
if pid == our_pid || !Self::is_process_alive(pid) {
// Stale lock — either our own leaked handle or previous
// process crashed. Remove and retry.
let _ = std::fs::remove_file(&lock_path);
return Self::acquire(db_path);
}
}
anyhow::bail!(
"Database is locked by another process (lock file: {}, holder PID: {}). \
If no other process is using this database, delete the lock file manually.",
lock_path.display(),
holder.trim()
);
}
Err(e) => {
anyhow::bail!(
"Failed to acquire database lock at {}: {}",
lock_path.display(),
e
);
}
}
}

/// Check if a process with the given PID is still running.
fn is_process_alive(pid: u32) -> bool {
// On Linux/Android, /proc/<pid> exists iff the process is alive.
let proc_path = format!("/proc/{}", pid);
if std::path::Path::new(&proc_path).exists() {
return true;
}
// On Linux, if /proc exists but /proc/<pid> doesn't, the process is dead.
if std::path::Path::new("/proc").exists() {
return false;
}
// On non-procfs systems (macOS, Windows), assume alive (conservative).
// Users must manually delete stale lock files on these platforms.
true
}
}

impl Drop for FileLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}

/// File-based storage backend for native platforms.
///
/// Stores graph data in a single `.graph` file with a page-based structure:
Expand All @@ -22,16 +102,24 @@ pub struct FileBackend {
file: File,
header: FileHeader,
is_new: bool,
_lock: FileLock,
}

impl FileBackend {
/// Open or create a .graph file at the given path.
///
/// If the file doesn't exist, creates it with an initial header.
/// If it exists, validates and loads the header.
///
/// Acquires an advisory file lock (sidecar `.graph.lock` file) to prevent
/// multi-process corruption. Returns an error if the database is already
/// opened by another process.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref().to_path_buf();

// Acquire advisory lock before touching the database file.
let lock = FileLock::acquire(&path)?;

let mut file = OpenOptions::new()
.read(true)
.write(true)
Expand Down Expand Up @@ -71,6 +159,7 @@ impl FileBackend {
file,
header,
is_new,
_lock: lock,
})
}

Expand Down
Loading
Loading