use std::cmp::Ordering;
use std::fmt;
use std::ops::Range;
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use fsqlite_types::value::SqliteValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrieRow {
pub key: Vec<SqliteValue>,
pub payload_row_index: usize,
}
impl TrieRow {
#[must_use]
pub fn new(key: Vec<SqliteValue>, payload_row_index: usize) -> Self {
Self {
key,
payload_row_index,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrieNode {
pub key: SqliteValue,
pub depth: u16,
pub row_range: Range<usize>,
pub child_range: Option<Range<usize>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrieBuildError {
EmptyKey {
row_index: usize,
},
InconsistentArity {
expected: usize,
found: usize,
row_index: usize,
},
UnsortedInput {
previous_row_index: usize,
row_index: usize,
},
NonComparableKey {
row_index: usize,
depth: usize,
},
ArityTooLarge {
arity: usize,
},
}
impl fmt::Display for TrieBuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyKey { row_index } => {
write!(f, "row {row_index} has an empty join key")
}
Self::InconsistentArity {
expected,
found,
row_index,
} => write!(
f,
"row {row_index} has key arity {found}, expected {expected}",
),
Self::UnsortedInput {
previous_row_index,
row_index,
} => write!(
f,
"input rows are not sorted at indices {previous_row_index} and {row_index}",
),
Self::NonComparableKey { row_index, depth } => {
write!(f, "row {row_index} has non-comparable key at depth {depth}")
}
Self::ArityTooLarge { arity } => write!(f, "key arity {arity} exceeds u16::MAX"),
}
}
}
impl std::error::Error for TrieBuildError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrieSeekError {
NonComparableTarget { node_index: usize, depth: usize },
}
impl fmt::Display for TrieSeekError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NonComparableTarget { node_index, depth } => write!(
f,
"target key is not comparable with node {node_index} at depth {depth}",
),
}
}
}
impl std::error::Error for TrieSeekError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LeapfrogMetricsSnapshot {
pub fsqlite_leapfrog_tuples_total: u64,
pub fsqlite_leapfrog_seeks_total: u64,
pub fsqlite_leapfrog_seek_comparisons_total: u64,
}
static FSQLITE_LEAPFROG_TUPLES_TOTAL: AtomicU64 = AtomicU64::new(0);
static FSQLITE_LEAPFROG_SEEKS_TOTAL: AtomicU64 = AtomicU64::new(0);
static FSQLITE_LEAPFROG_SEEK_COMPARISONS_TOTAL: AtomicU64 = AtomicU64::new(0);
#[must_use]
pub fn leapfrog_metrics_snapshot() -> LeapfrogMetricsSnapshot {
LeapfrogMetricsSnapshot {
fsqlite_leapfrog_tuples_total: FSQLITE_LEAPFROG_TUPLES_TOTAL.load(AtomicOrdering::Relaxed),
fsqlite_leapfrog_seeks_total: FSQLITE_LEAPFROG_SEEKS_TOTAL.load(AtomicOrdering::Relaxed),
fsqlite_leapfrog_seek_comparisons_total: FSQLITE_LEAPFROG_SEEK_COMPARISONS_TOTAL
.load(AtomicOrdering::Relaxed),
}
}
pub fn reset_leapfrog_metrics() {
FSQLITE_LEAPFROG_TUPLES_TOTAL.store(0, AtomicOrdering::Relaxed);
FSQLITE_LEAPFROG_SEEKS_TOTAL.store(0, AtomicOrdering::Relaxed);
FSQLITE_LEAPFROG_SEEK_COMPARISONS_TOTAL.store(0, AtomicOrdering::Relaxed);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeapfrogJoinError {
NotEnoughRelations { found: usize },
ArityMismatch {
expected: usize,
found: usize,
relation_index: usize,
},
NonComparableKey { relation_index: usize, depth: usize },
SeekError {
relation_index: usize,
source: TrieSeekError,
},
}
impl fmt::Display for LeapfrogJoinError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotEnoughRelations { found } => {
write!(f, "leapfrog requires at least 2 relations, found {found}")
}
Self::ArityMismatch {
expected,
found,
relation_index,
} => write!(
f,
"relation {relation_index} has arity {found}, expected {expected}",
),
Self::NonComparableKey {
relation_index,
depth,
} => {
write!(
f,
"relation {relation_index} has non-comparable key at depth {depth}",
)
}
Self::SeekError {
relation_index,
source,
} => {
write!(f, "seek failed for relation {relation_index}: {source}")
}
}
}
}
impl std::error::Error for LeapfrogJoinError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeapfrogMatch {
pub key: Vec<SqliteValue>,
pub relation_row_ranges: Vec<Range<usize>>,
}
impl LeapfrogMatch {
#[must_use]
pub fn tuple_multiplicity(&self) -> u64 {
let mut product = 1_u64;
for range in &self.relation_row_ranges {
let span = range.end.saturating_sub(range.start);
let span_u64 = u64::try_from(span).unwrap_or(u64::MAX);
product = product.saturating_mul(span_u64);
}
product
}
}
#[derive(Debug, Default)]
struct LeapfrogExecution {
seeks: u64,
tuples_produced: u64,
matches: Vec<LeapfrogMatch>,
}
#[derive(Debug)]
pub struct LeapfrogJoinExecutor<'a> {
relations: Vec<&'a TrieRelation>,
arity: usize,
}
impl<'a> LeapfrogJoinExecutor<'a> {
pub fn try_new(relations: Vec<&'a TrieRelation>) -> Result<Self, LeapfrogJoinError> {
if relations.len() < 2 {
return Err(LeapfrogJoinError::NotEnoughRelations {
found: relations.len(),
});
}
let arity = relations[0].arity();
for (relation_index, relation) in relations.iter().enumerate().skip(1) {
if relation.arity() != arity {
return Err(LeapfrogJoinError::ArityMismatch {
expected: arity,
found: relation.arity(),
relation_index,
});
}
}
Ok(Self { relations, arity })
}
pub fn execute(&self) -> Result<Vec<LeapfrogMatch>, LeapfrogJoinError> {
if self.arity == 0 {
return Ok(Vec::new());
}
if self
.relations
.iter()
.any(|relation| relation.row_count() == 0)
{
return Ok(Vec::new());
}
let mut root_cursors = Vec::with_capacity(self.relations.len());
for relation in &self.relations {
let cursor = relation.open_root_cursor();
if cursor.at_end() {
return Ok(Vec::new());
}
root_cursors.push(cursor);
}
let span = tracing::span!(
tracing::Level::INFO,
"leapfrog_join",
join_width = self.relations.len(),
tuples_produced = tracing::field::Empty,
seeks = tracing::field::Empty
);
let mut execution = LeapfrogExecution::default();
{
let _guard = span.enter();
let mut prefix = Vec::with_capacity(self.arity);
execute_depth(
&mut root_cursors,
0,
self.arity,
&mut prefix,
&mut execution,
)?;
span.record("tuples_produced", execution.tuples_produced);
span.record("seeks", execution.seeks);
tracing::info!(
join_width = self.relations.len(),
tuples_produced = execution.tuples_produced,
seeks = execution.seeks,
matches = execution.matches.len(),
"leapfrog.join.complete"
);
}
FSQLITE_LEAPFROG_TUPLES_TOTAL.fetch_add(execution.tuples_produced, AtomicOrdering::Relaxed);
FSQLITE_LEAPFROG_SEEKS_TOTAL.fetch_add(execution.seeks, AtomicOrdering::Relaxed);
Ok(execution.matches)
}
}
pub fn leapfrog_join(relations: &[&TrieRelation]) -> Result<Vec<LeapfrogMatch>, LeapfrogJoinError> {
let executor = LeapfrogJoinExecutor::try_new(relations.to_vec())?;
executor.execute()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrieRelation {
arity: usize,
rows: Vec<TrieRow>,
nodes: Vec<TrieNode>,
root_range: Option<Range<usize>>,
}
impl TrieRelation {
pub fn from_sorted_rows(rows: Vec<TrieRow>) -> Result<Self, TrieBuildError> {
if rows.is_empty() {
return Ok(Self {
arity: 0,
rows,
nodes: Vec::new(),
root_range: None,
});
}
let arity = rows[0].key.len();
if arity == 0 {
return Err(TrieBuildError::EmptyKey { row_index: 0 });
}
if arity > usize::from(u16::MAX) {
return Err(TrieBuildError::ArityTooLarge { arity });
}
validate_sorted_rows(&rows, arity)?;
let mut nodes = Vec::new();
let root_range = build_level(&rows, 0, 0, arity, &mut nodes)?;
Ok(Self {
arity,
rows,
nodes,
root_range,
})
}
#[must_use]
pub const fn arity(&self) -> usize {
self.arity
}
#[must_use]
pub fn row_count(&self) -> usize {
self.rows.len()
}
#[must_use]
pub fn node_count(&self) -> usize {
self.nodes.len()
}
#[must_use]
pub fn root_range(&self) -> Option<Range<usize>> {
self.root_range.clone()
}
#[must_use]
pub fn open_root_cursor(&self) -> TrieCursor<'_> {
let sibling_range = self.root_range.clone().unwrap_or(0..0);
TrieCursor::new(self, sibling_range)
}
fn node(&self, index: usize) -> Option<&TrieNode> {
self.nodes.get(index)
}
}
#[derive(Debug, Clone)]
pub struct TrieCursor<'a> {
relation: &'a TrieRelation,
sibling_range: Range<usize>,
position: usize,
}
impl<'a> TrieCursor<'a> {
fn new(relation: &'a TrieRelation, sibling_range: Range<usize>) -> Self {
let position = sibling_range.start;
Self {
relation,
sibling_range,
position,
}
}
#[must_use]
pub fn at_end(&self) -> bool {
self.position >= self.sibling_range.end
}
#[must_use]
pub fn current_key(&self) -> Option<&SqliteValue> {
self.current_node().map(|node| &node.key)
}
pub fn next(&mut self) {
if !self.at_end() {
self.position = self.position.saturating_add(1);
}
}
pub fn seek(&mut self, target: &SqliteValue) -> Result<bool, TrieSeekError> {
if self.at_end() {
return Ok(false);
}
let current_cmp = self.compare_at(self.position, target)?;
if current_cmp == Ordering::Equal {
return Ok(true);
}
if current_cmp == Ordering::Greater {
return Ok(false);
}
let mut low = self.position;
let mut high = self.sibling_range.end;
let mut step = 1usize;
let mut probe = self.position.saturating_add(step);
while probe < self.sibling_range.end {
match self.compare_at(probe, target)? {
Ordering::Less => {
low = probe.saturating_add(1);
step = step.saturating_mul(2);
probe = probe.saturating_add(step);
}
Ordering::Equal => {
self.position = probe;
return Ok(true);
}
Ordering::Greater => {
high = probe;
break;
}
}
}
self.position = lower_bound(self.relation, low, high, target)?;
if self.at_end() {
return Ok(false);
}
Ok(self.compare_at(self.position, target)? == Ordering::Equal)
}
#[must_use]
pub fn open_child(&self) -> Option<Self> {
let range = self.current_node()?.child_range.clone()?;
Some(Self::new(self.relation, range))
}
fn current_node(&self) -> Option<&TrieNode> {
if self.at_end() {
return None;
}
self.relation.node(self.position)
}
fn compare_at(
&self,
node_index: usize,
target: &SqliteValue,
) -> Result<Ordering, TrieSeekError> {
let node = self
.relation
.node(node_index)
.ok_or(TrieSeekError::NonComparableTarget {
node_index,
depth: 0,
})?;
FSQLITE_LEAPFROG_SEEK_COMPARISONS_TOTAL.fetch_add(1, AtomicOrdering::Relaxed);
node.key
.partial_cmp(target)
.ok_or_else(|| TrieSeekError::NonComparableTarget {
node_index,
depth: usize::from(node.depth),
})
}
}
fn validate_sorted_rows(rows: &[TrieRow], arity: usize) -> Result<(), TrieBuildError> {
for (row_index, row) in rows.iter().enumerate() {
let key_len = row.key.len();
if key_len == 0 {
return Err(TrieBuildError::EmptyKey { row_index });
}
if key_len != arity {
return Err(TrieBuildError::InconsistentArity {
expected: arity,
found: key_len,
row_index,
});
}
if row_index > 0 {
let previous = &rows[row_index - 1];
let ordering = compare_key_slices(&previous.key, &row.key).ok_or(
TrieBuildError::NonComparableKey {
row_index,
depth: 0,
},
)?;
if ordering == Ordering::Greater {
return Err(TrieBuildError::UnsortedInput {
previous_row_index: row_index - 1,
row_index,
});
}
}
}
Ok(())
}
fn compare_key_slices(left: &[SqliteValue], right: &[SqliteValue]) -> Option<Ordering> {
for (depth, (left_value, right_value)) in left.iter().zip(right).enumerate() {
let ordering = left_value.partial_cmp(right_value)?;
if ordering != Ordering::Equal {
return Some(ordering);
}
if depth == left.len().saturating_sub(1) {
return Some(Ordering::Equal);
}
}
Some(Ordering::Equal)
}
fn build_level(
rows: &[TrieRow],
depth: usize,
base_row_offset: usize,
arity: usize,
nodes: &mut Vec<TrieNode>,
) -> Result<Option<Range<usize>>, TrieBuildError> {
if rows.is_empty() || depth >= arity {
return Ok(None);
}
let mut groups = Vec::new();
let mut group_start = 0usize;
while group_start < rows.len() {
let current_key = rows[group_start]
.key
.get(depth)
.ok_or(TrieBuildError::InconsistentArity {
expected: arity,
found: rows[group_start].key.len(),
row_index: base_row_offset + group_start,
})?
.clone();
let mut group_end = group_start + 1;
while group_end < rows.len() {
let next_key =
rows[group_end]
.key
.get(depth)
.ok_or(TrieBuildError::InconsistentArity {
expected: arity,
found: rows[group_end].key.len(),
row_index: base_row_offset + group_end,
})?;
if *next_key != current_key {
break;
}
group_end = group_end.saturating_add(1);
}
groups.push((current_key, group_start, group_end));
group_start = group_end;
}
let range_start = nodes.len();
let depth_u16 = u16::try_from(depth).map_err(|_| TrieBuildError::ArityTooLarge { arity })?;
for (key, local_start, local_end) in &groups {
nodes.push(TrieNode {
key: key.clone(),
depth: depth_u16,
row_range: (base_row_offset + *local_start)..(base_row_offset + *local_end),
child_range: None,
});
}
let range_end = nodes.len();
for (offset, (_, local_start, local_end)) in groups.iter().enumerate() {
let child_rows = &rows[*local_start..*local_end];
let child_base = base_row_offset + *local_start;
nodes[range_start + offset].child_range =
build_level(child_rows, depth + 1, child_base, arity, nodes)?;
}
Ok(Some(range_start..range_end))
}
fn lower_bound(
relation: &TrieRelation,
mut low: usize,
mut high: usize,
target: &SqliteValue,
) -> Result<usize, TrieSeekError> {
while low < high {
let mid = low + ((high - low) / 2);
let node = relation
.node(mid)
.ok_or(TrieSeekError::NonComparableTarget {
node_index: mid,
depth: 0,
})?;
FSQLITE_LEAPFROG_SEEK_COMPARISONS_TOTAL.fetch_add(1, AtomicOrdering::Relaxed);
let ordering =
node.key
.partial_cmp(target)
.ok_or_else(|| TrieSeekError::NonComparableTarget {
node_index: mid,
depth: usize::from(node.depth),
})?;
if ordering == Ordering::Less {
low = mid.saturating_add(1);
} else {
high = mid;
}
}
Ok(low)
}
fn execute_depth(
cursors: &mut [TrieCursor<'_>],
depth: usize,
arity: usize,
prefix: &mut Vec<SqliteValue>,
execution: &mut LeapfrogExecution,
) -> Result<(), LeapfrogJoinError> {
let mut pivot = 0_usize;
while align_cursors(cursors, depth, pivot, execution)? {
let Some(candidate_key) = cursors[0].current_key().cloned() else {
return Ok(());
};
if matches!(candidate_key, SqliteValue::Null) {
advance_aligned_nulls(cursors);
continue;
}
tracing::debug!(
depth,
key = ?candidate_key,
"leapfrog.level.aligned"
);
prefix.push(candidate_key.clone());
if depth + 1 == arity {
let relation_row_ranges = collect_current_row_ranges(cursors);
let join_match = LeapfrogMatch {
key: prefix.clone(),
relation_row_ranges,
};
let multiplicity = join_match.tuple_multiplicity();
execution.tuples_produced = execution.tuples_produced.saturating_add(multiplicity);
execution.matches.push(join_match);
tracing::debug!(
depth,
key = ?candidate_key,
multiplicity,
tuples_produced = execution.tuples_produced,
"leapfrog.level.emit"
);
} else {
let mut child_cursors = Vec::with_capacity(cursors.len());
for cursor in &*cursors {
let Some(child) = cursor.open_child() else {
child_cursors.clear();
break;
};
child_cursors.push(child);
}
if child_cursors.len() == cursors.len() {
tracing::debug!(
depth,
key = ?candidate_key,
"leapfrog.level.descend"
);
execute_depth(&mut child_cursors, depth + 1, arity, prefix, execution)?;
}
}
prefix.pop();
cursors[pivot].next();
pivot = (pivot + 1) % cursors.len();
}
Ok(())
}
fn align_cursors(
cursors: &mut [TrieCursor<'_>],
depth: usize,
pivot: usize,
execution: &mut LeapfrogExecution,
) -> Result<bool, LeapfrogJoinError> {
if cursors.iter().any(TrieCursor::at_end) {
return Ok(false);
}
let mut target = max_current_key(cursors, depth)?;
loop {
let mut all_equal = true;
for offset in 0..cursors.len() {
let relation_index = (pivot + offset) % cursors.len();
if cursors[relation_index].at_end() {
return Ok(false);
}
let Some(current_key) = cursors[relation_index].current_key() else {
return Ok(false);
};
match compare_keys(current_key, &target, relation_index, depth)? {
Ordering::Less => {
execution.seeks = execution.seeks.saturating_add(1);
cursors[relation_index].seek(&target).map_err(|source| {
LeapfrogJoinError::SeekError {
relation_index,
source,
}
})?;
if cursors[relation_index].at_end() {
return Ok(false);
}
if let Some(landed_key) = cursors[relation_index].current_key()
&& landed_key != &target
{
target = landed_key.clone();
all_equal = false;
tracing::debug!(
depth,
relation_index,
key = ?target,
seeks = execution.seeks,
"leapfrog.level.advance.seek"
);
}
}
Ordering::Equal => {}
Ordering::Greater => {
target = current_key.clone();
all_equal = false;
tracing::debug!(
depth,
relation_index,
key = ?target,
"leapfrog.level.advance.max"
);
}
}
}
if all_equal {
return Ok(true);
}
}
}
fn max_current_key(
cursors: &[TrieCursor<'_>],
depth: usize,
) -> Result<SqliteValue, LeapfrogJoinError> {
let mut max_key = cursors[0]
.current_key()
.cloned()
.ok_or(LeapfrogJoinError::SeekError {
relation_index: 0,
source: TrieSeekError::NonComparableTarget {
node_index: 0,
depth,
},
})?;
for (relation_index, cursor) in cursors.iter().enumerate().skip(1) {
let Some(current_key) = cursor.current_key() else {
continue;
};
if compare_keys(current_key, &max_key, relation_index, depth)? == Ordering::Greater {
max_key = current_key.clone();
}
}
Ok(max_key)
}
fn compare_keys(
left: &SqliteValue,
right: &SqliteValue,
relation_index: usize,
depth: usize,
) -> Result<Ordering, LeapfrogJoinError> {
left.partial_cmp(right)
.ok_or(LeapfrogJoinError::NonComparableKey {
relation_index,
depth,
})
}
fn advance_aligned_nulls(cursors: &mut [TrieCursor<'_>]) {
for cursor in cursors {
while matches!(cursor.current_key(), Some(SqliteValue::Null)) {
cursor.next();
if cursor.at_end() {
break;
}
}
}
}
fn collect_current_row_ranges(cursors: &[TrieCursor<'_>]) -> Vec<Range<usize>> {
let mut ranges = Vec::with_capacity(cursors.len());
for cursor in cursors {
let Some(node) = cursor.current_node() else {
ranges.push(0..0);
continue;
};
ranges.push(node.row_range.clone());
}
ranges
}
#[cfg(test)]
mod tests {
use super::{
LeapfrogJoinError, LeapfrogJoinExecutor, TrieBuildError, TrieRelation, TrieRow,
leapfrog_join, leapfrog_metrics_snapshot, reset_leapfrog_metrics,
};
use fsqlite_types::value::SqliteValue;
fn sample_rows() -> Vec<TrieRow> {
vec![
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(1)], 0),
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(2)], 1),
TrieRow::new(vec![SqliteValue::Integer(2), SqliteValue::Integer(1)], 2),
TrieRow::new(vec![SqliteValue::Integer(2), SqliteValue::Integer(3)], 3),
]
}
#[test]
fn builds_trie_from_sorted_rows() {
let trie = TrieRelation::from_sorted_rows(sample_rows()).expect("build should succeed");
assert_eq!(trie.arity(), 2);
assert_eq!(trie.row_count(), 4);
assert_eq!(trie.root_range(), Some(0..2));
assert_eq!(trie.node_count(), 6);
}
#[test]
fn rejects_unsorted_rows() {
let unsorted = vec![
TrieRow::new(vec![SqliteValue::Integer(2), SqliteValue::Integer(1)], 0),
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(1)], 1),
];
let err = TrieRelation::from_sorted_rows(unsorted).expect_err("must reject unsorted rows");
assert_eq!(
err,
TrieBuildError::UnsortedInput {
previous_row_index: 0,
row_index: 1
}
);
}
#[test]
fn cursor_seek_and_child_navigation() {
let trie = TrieRelation::from_sorted_rows(sample_rows()).expect("build should succeed");
let mut root = trie.open_root_cursor();
assert!(!root.at_end());
assert_eq!(root.current_key(), Some(&SqliteValue::Integer(1)));
let exact = root
.seek(&SqliteValue::Integer(2))
.expect("seek should succeed");
assert!(exact);
assert_eq!(root.current_key(), Some(&SqliteValue::Integer(2)));
let mut child = root.open_child().expect("child cursor should exist");
assert_eq!(child.current_key(), Some(&SqliteValue::Integer(1)));
child.next();
assert_eq!(child.current_key(), Some(&SqliteValue::Integer(3)));
let exact_child = child
.seek(&SqliteValue::Integer(2))
.expect("seek should succeed");
assert!(!exact_child);
assert_eq!(child.current_key(), Some(&SqliteValue::Integer(3)));
}
#[test]
fn leapfrog_join_two_way_duplicate_multiplicity() {
reset_leapfrog_metrics();
let left = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(1)], 0),
TrieRow::new(vec![SqliteValue::Integer(1)], 1),
TrieRow::new(vec![SqliteValue::Integer(2)], 2),
])
.expect("left build should succeed");
let right = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(1)], 0),
TrieRow::new(vec![SqliteValue::Integer(1)], 1),
TrieRow::new(vec![SqliteValue::Integer(1)], 2),
TrieRow::new(vec![SqliteValue::Integer(3)], 3),
])
.expect("right build should succeed");
let matches = leapfrog_join(&[&left, &right]).expect("join should succeed");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].key, vec![SqliteValue::Integer(1)]);
assert_eq!(matches[0].tuple_multiplicity(), 6);
let metrics = leapfrog_metrics_snapshot();
assert!(
metrics.fsqlite_leapfrog_tuples_total >= 6,
"tuple metric should include emitted multiplicity"
);
assert!(
metrics.fsqlite_leapfrog_seeks_total >= 1,
"seek metric should capture alignment seeks"
);
assert!(
metrics.fsqlite_leapfrog_seek_comparisons_total >= 1,
"seek comparison metric should capture galloping/binary search work"
);
}
#[test]
fn leapfrog_join_three_way_composite_keys() {
let rel_a = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(1)], 0),
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(2)], 1),
TrieRow::new(vec![SqliteValue::Integer(2), SqliteValue::Integer(1)], 2),
])
.expect("a build should succeed");
let rel_b = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(2)], 0),
TrieRow::new(vec![SqliteValue::Integer(2), SqliteValue::Integer(1)], 1),
TrieRow::new(vec![SqliteValue::Integer(2), SqliteValue::Integer(2)], 2),
])
.expect("b build should succeed");
let rel_c = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(0), SqliteValue::Integer(9)], 0),
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(2)], 1),
TrieRow::new(vec![SqliteValue::Integer(2), SqliteValue::Integer(1)], 2),
])
.expect("c build should succeed");
let matches = leapfrog_join(&[&rel_a, &rel_b, &rel_c]).expect("join should succeed");
assert_eq!(matches.len(), 2);
assert_eq!(
matches[0].key,
vec![SqliteValue::Integer(1), SqliteValue::Integer(2)]
);
assert_eq!(
matches[1].key,
vec![SqliteValue::Integer(2), SqliteValue::Integer(1)]
);
assert_eq!(matches[0].tuple_multiplicity(), 1);
assert_eq!(matches[1].tuple_multiplicity(), 1);
}
fn relation_with_common_and_unique_keys(
relation_index: usize,
) -> Result<TrieRelation, TrieBuildError> {
let relation_index_i64 = i64::try_from(relation_index).expect("index should fit i64");
let mut rows = vec![
TrieRow::new(vec![SqliteValue::Integer(-100 - relation_index_i64)], 0),
TrieRow::new(vec![SqliteValue::Integer(10)], 1),
TrieRow::new(vec![SqliteValue::Integer(20)], 2),
TrieRow::new(vec![SqliteValue::Integer(30 + relation_index_i64)], 3),
];
if relation_index % 2 == 0 {
rows.insert(3, TrieRow::new(vec![SqliteValue::Integer(20)], 4));
}
TrieRelation::from_sorted_rows(rows)
}
#[test]
fn leapfrog_join_supports_four_to_six_relations() {
for relation_width in 4..=6 {
let owned_relations: Vec<TrieRelation> = (0..relation_width)
.map(relation_with_common_and_unique_keys)
.collect::<Result<_, _>>()
.expect("relation build should succeed");
let relation_refs: Vec<&TrieRelation> = owned_relations.iter().collect();
let matches = leapfrog_join(&relation_refs).expect("join should succeed");
assert_eq!(
matches.len(),
2,
"expected two common keys for width={relation_width}"
);
assert_eq!(matches[0].key, vec![SqliteValue::Integer(10)]);
assert_eq!(matches[1].key, vec![SqliteValue::Integer(20)]);
assert_eq!(matches[0].tuple_multiplicity(), 1);
let expected_multiplicity =
(0..relation_width).fold(1_u64, |product, relation_index| {
if relation_index % 2 == 0 {
product.saturating_mul(2)
} else {
product
}
});
assert_eq!(
matches[1].tuple_multiplicity(),
expected_multiplicity,
"unexpected multiplicity for width={relation_width}"
);
}
}
#[test]
fn seek_galloping_comparisons_sublinear() {
reset_leapfrog_metrics();
let rows: Vec<TrieRow> = (0_usize..8_192_usize)
.map(|value| {
let key = i64::try_from(value).expect("key should fit i64");
TrieRow::new(vec![SqliteValue::Integer(key)], value)
})
.collect();
let trie = TrieRelation::from_sorted_rows(rows).expect("build should succeed");
let mut cursor = trie.open_root_cursor();
let before = leapfrog_metrics_snapshot().fsqlite_leapfrog_seek_comparisons_total;
let exact = cursor
.seek(&SqliteValue::Integer(8_000))
.expect("seek should succeed");
assert!(exact, "exact key should be found");
assert_eq!(cursor.current_key(), Some(&SqliteValue::Integer(8_000)));
let after = leapfrog_metrics_snapshot().fsqlite_leapfrog_seek_comparisons_total;
let comparisons = after.saturating_sub(before);
assert!(
comparisons > 0,
"seek should perform at least one key comparison"
);
assert!(
comparisons < 160,
"galloping seek should remain sublinear; comparisons={comparisons}"
);
}
#[test]
fn leapfrog_join_excludes_null_keys() {
let rel_a = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Null], 0),
TrieRow::new(vec![SqliteValue::Integer(1)], 1),
])
.expect("a build should succeed");
let rel_b = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Null], 0),
TrieRow::new(vec![SqliteValue::Integer(1)], 1),
])
.expect("b build should succeed");
let rel_c = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Null], 0),
TrieRow::new(vec![SqliteValue::Integer(1)], 1),
])
.expect("c build should succeed");
let matches = leapfrog_join(&[&rel_a, &rel_b, &rel_c]).expect("join should succeed");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].key, vec![SqliteValue::Integer(1)]);
}
#[test]
fn leapfrog_join_rejects_mixed_arity() {
let rel_a = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(1)], 0),
TrieRow::new(vec![SqliteValue::Integer(2)], 1),
])
.expect("a build should succeed");
let rel_b = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(1), SqliteValue::Integer(2)], 0),
TrieRow::new(vec![SqliteValue::Integer(3), SqliteValue::Integer(4)], 1),
])
.expect("b build should succeed");
let err = leapfrog_join(&[&rel_a, &rel_b]).expect_err("mixed arity should be rejected");
assert_eq!(
err,
LeapfrogJoinError::ArityMismatch {
expected: 1,
found: 2,
relation_index: 1
}
);
}
#[test]
fn leapfrog_join_requires_two_relations() {
let rel_a = TrieRelation::from_sorted_rows(vec![
TrieRow::new(vec![SqliteValue::Integer(1)], 0),
TrieRow::new(vec![SqliteValue::Integer(2)], 1),
])
.expect("a build should succeed");
let err = LeapfrogJoinExecutor::try_new(vec![&rel_a]).expect_err("must reject 1 relation");
assert_eq!(err, LeapfrogJoinError::NotEnoughRelations { found: 1 });
}
}