Index Internals¶
ORMDB uses two index types: hash indexes for O(1) equality lookups and B-tree indexes for O(log N) range queries.
Index Overview¶
┌─────────────────────────────────────────────────────────────────┐
│ Index Layer │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Hash Index │ │ B-tree Index │ │
│ │ │ │ │ │
│ │ O(1) equality │ │ O(log N) range │ │
│ │ WHERE x = value │ │ WHERE x > value │ │
│ │ WHERE x IN (...) │ │ WHERE x BETWEEN a,b │ │
│ │ │ │ ORDER BY x │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ Backed by sled trees │
└─────────────────────────────────────────────────────────────────┘
Hash Index¶
Structure¶
Key Format¶
Key: <entity_type>:<field>:<value_hash>
Value: Compressed list of entity IDs
Example:
User:status:hash("active") → [id1, id2, id3, ...]
User:status:hash("pending") → [id4, id5, ...]
Post:published:hash(true) → [id6, id7, id8, ...]
Value Hashing¶
fn hash_value(value: &Value) -> [u8; 16] {
let mut hasher = blake3::Hasher::new();
match value {
Value::String(s) => {
hasher.update(b"string:");
hasher.update(s.as_bytes());
}
Value::Int32(n) => {
hasher.update(b"i32:");
hasher.update(&n.to_le_bytes());
}
Value::Int64(n) => {
hasher.update(b"i64:");
hasher.update(&n.to_le_bytes());
}
Value::Bool(b) => {
hasher.update(b"bool:");
hasher.update(&[*b as u8]);
}
Value::Uuid(bytes) => {
hasher.update(b"uuid:");
hasher.update(bytes);
}
// ...
}
let hash = hasher.finalize();
hash.as_bytes()[..16].try_into().unwrap()
}
Operations¶
Insert¶
pub fn insert(
&self,
entity_type: &str,
field: &str,
value: &Value,
entity_id: [u8; 16],
) -> Result<()> {
let key = self.make_key(entity_type, field, value);
// Read current ID list
let mut ids = match self.tree.get(&key)? {
Some(bytes) => decompress_ids(&bytes)?,
None => Vec::new(),
};
// Add new ID if not present
if !ids.contains(&entity_id) {
ids.push(entity_id);
// Write back
let compressed = compress_ids(&ids)?;
self.tree.insert(&key, compressed)?;
}
Ok(())
}
Remove¶
pub fn remove(
&self,
entity_type: &str,
field: &str,
value: &Value,
entity_id: [u8; 16],
) -> Result<()> {
let key = self.make_key(entity_type, field, value);
if let Some(bytes) = self.tree.get(&key)? {
let mut ids = decompress_ids(&bytes)?;
// Remove ID
ids.retain(|id| *id != entity_id);
if ids.is_empty() {
// Remove entry entirely
self.tree.remove(&key)?;
} else {
// Write back reduced list
let compressed = compress_ids(&ids)?;
self.tree.insert(&key, compressed)?;
}
}
Ok(())
}
Lookup¶
pub fn lookup(
&self,
entity_type: &str,
field: &str,
value: &Value,
) -> Result<Vec<[u8; 16]>> {
let key = self.make_key(entity_type, field, value);
match self.tree.get(&key)? {
Some(bytes) => decompress_ids(&bytes),
None => Ok(Vec::new()),
}
}
ID List Compression¶
fn compress_ids(ids: &[[u8; 16]]) -> Result<Vec<u8>> {
let mut data = Vec::with_capacity(ids.len() * 16);
for id in ids {
data.extend_from_slice(id);
}
Ok(lz4::compress(&data))
}
fn decompress_ids(bytes: &[u8]) -> Result<Vec<[u8; 16]>> {
let data = lz4::decompress(bytes)?;
let mut ids = Vec::with_capacity(data.len() / 16);
for chunk in data.chunks_exact(16) {
let mut id = [0u8; 16];
id.copy_from_slice(chunk);
ids.push(id);
}
Ok(ids)
}
Batch Insert¶
For bulk loading, batch insert is more efficient:
pub fn insert_batch(
&self,
entity_type: &str,
field: &str,
entries: &[(Value, [u8; 16])],
) -> Result<()> {
// Group by value hash
let mut groups: HashMap<Vec<u8>, Vec<[u8; 16]>> = HashMap::new();
for (value, id) in entries {
let key = self.make_key(entity_type, field, value);
groups.entry(key).or_default().push(*id);
}
// Write each group
for (key, new_ids) in groups {
let mut ids = match self.tree.get(&key)? {
Some(bytes) => decompress_ids(&bytes)?,
None => Vec::new(),
};
// Add new IDs
for id in new_ids {
if !ids.contains(&id) {
ids.push(id);
}
}
let compressed = compress_ids(&ids)?;
self.tree.insert(&key, compressed)?;
}
Ok(())
}
B-tree Index¶
Structure¶
Key Format¶
Key: <entity_type>:<field>:<encoded_value>
Value: entity_id
Example (Int32 field "age"):
User:age:\x00\x00\x00\x12 → id1 (age = 18)
User:age:\x00\x00\x00\x13 → id2 (age = 19)
User:age:\x00\x00\x00\x14 → id3 (age = 20)
User:age:\x00\x00\x00\x19 → id4 (age = 25)
Values are encoded to preserve sort order.
Value Encoding¶
fn encode_value(value: &Value) -> Vec<u8> {
match value {
// Integers: big-endian with sign bit flip
Value::Int32(n) => {
let flipped = (*n as u32) ^ 0x80000000;
flipped.to_be_bytes().to_vec()
}
Value::Int64(n) => {
let flipped = (*n as u64) ^ 0x8000000000000000;
flipped.to_be_bytes().to_vec()
}
// Floats: IEEE 754 with sign handling
Value::Float64(f) => {
let bits = f.to_bits();
let encoded = if *f >= 0.0 {
bits ^ 0x8000000000000000
} else {
!bits
};
encoded.to_be_bytes().to_vec()
}
// Strings: raw bytes (naturally sorted)
Value::String(s) => s.as_bytes().to_vec(),
// Timestamps: i64 microseconds
Value::Timestamp(ts) => {
let flipped = (*ts as u64) ^ 0x8000000000000000;
flipped.to_be_bytes().to_vec()
}
// ...
}
}
Operations¶
Insert¶
pub fn insert(
&self,
entity_type: &str,
field: &str,
value: &Value,
entity_id: [u8; 16],
) -> Result<()> {
let tree = self.tree_for_field(entity_type, field)?;
let key = encode_value(value);
// Multiple entities may have same value
// Use composite key: value + entity_id
let mut full_key = key;
full_key.extend_from_slice(&entity_id);
tree.insert(&full_key, &entity_id)?;
Ok(())
}
Remove¶
pub fn remove(
&self,
entity_type: &str,
field: &str,
value: &Value,
entity_id: [u8; 16],
) -> Result<()> {
let tree = self.tree_for_field(entity_type, field)?;
let mut key = encode_value(value);
key.extend_from_slice(&entity_id);
tree.remove(&key)?;
Ok(())
}
Range Scan¶
pub fn scan_range(
&self,
entity_type: &str,
field: &str,
min: &Value,
max: &Value,
) -> Result<Vec<[u8; 16]>> {
let tree = self.tree_for_field(entity_type, field)?;
let min_key = encode_value(min);
let max_key = encode_value(max);
let ids: Vec<[u8; 16]> = tree
.range(min_key..=max_key)
.filter_map(|result| {
let (_, value) = result.ok()?;
let mut id = [0u8; 16];
id.copy_from_slice(&value);
Some(id)
})
.collect();
Ok(ids)
}
Greater Than¶
pub fn scan_greater_than(
&self,
entity_type: &str,
field: &str,
value: &Value,
) -> Result<Vec<[u8; 16]>> {
let tree = self.tree_for_field(entity_type, field)?;
let min_key = encode_value(value);
// Add suffix to exclude exact match
let mut exclusive_min = min_key.clone();
exclusive_min.push(0xFF);
let ids: Vec<[u8; 16]> = tree
.range(exclusive_min..)
.filter_map(|result| {
let (_, value) = result.ok()?;
let mut id = [0u8; 16];
id.copy_from_slice(&value);
Some(id)
})
.collect();
Ok(ids)
}
Lazy Index Building¶
B-tree indexes are built on demand:
pub fn ensure_btree_index(
&self,
entity_type: &str,
column_name: &str,
) -> Result<bool> {
// Check if already built
let key = (entity_type.to_string(), column_name.to_string());
if self.indexed_columns.read()?.contains(&key) {
return Ok(true);
}
// Build from columnar data
let projection = self.columnar.projection(entity_type)?;
let column_iter = projection.scan_column(column_name);
let tree = self.tree_for_field(entity_type, column_name)?;
for (entity_id, value) in column_iter {
let mut key = encode_value(&value);
key.extend_from_slice(&entity_id);
tree.insert(&key, &entity_id)?;
}
// Mark as built
self.indexed_columns.write()?.insert(key);
Ok(true)
}
Index Selection¶
The query executor chooses indexes automatically:
fn select_index(
filter: &FilterExpr,
hash_index: &HashIndex,
btree_index: Option<&BTreeIndex>,
) -> IndexChoice {
match filter {
// Equality: prefer hash index
FilterExpr::Eq { field, value } => {
IndexChoice::Hash { field, value }
}
// Range: require B-tree
FilterExpr::Gt { field, value } |
FilterExpr::Gte { field, value } |
FilterExpr::Lt { field, value } |
FilterExpr::Lte { field, value } => {
if btree_index.is_some() {
IndexChoice::BTree { field, range: ... }
} else {
IndexChoice::Scan // Fall back
}
}
// IN: multiple hash lookups
FilterExpr::In { field, values } => {
IndexChoice::HashMultiple { field, values }
}
// LIKE with prefix: B-tree range
FilterExpr::Like { field, pattern } if !pattern.starts_with('%') => {
if btree_index.is_some() {
let prefix = pattern.split('%').next().unwrap();
IndexChoice::BTreePrefix { field, prefix }
} else {
IndexChoice::Scan
}
}
// Complex: evaluate and intersect
FilterExpr::And { left, right } => {
let left_choice = select_index(left, ...);
let right_choice = select_index(right, ...);
IndexChoice::Intersect(left_choice, right_choice)
}
_ => IndexChoice::Scan,
}
}
Index Maintenance¶
Automatic Updates¶
When entities are written, indexes are updated:
pub fn update_secondary_indexes(
&self,
entity_type: &str,
entity_id: [u8; 16],
before: Option<&[(String, Value)]>,
after: Option<&[(String, Value)]>,
) -> Result<()> {
let before_map = to_map(before);
let after_map = to_map(after);
// Find changed fields
let all_fields: HashSet<_> = before_map.keys()
.chain(after_map.keys())
.collect();
for field in all_fields {
let before_value = before_map.get(field);
let after_value = after_map.get(field);
if before_value == after_value {
continue;
}
// Update hash index
if let Some(v) = before_value {
self.hash_index.remove(entity_type, field, v, entity_id)?;
}
if let Some(v) = after_value {
self.hash_index.insert(entity_type, field, v, entity_id)?;
}
// Update B-tree index if exists
if let Some(btree) = &self.btree_index {
if self.btree_indexed_columns.read()?.contains(&(entity_type, field)) {
if let Some(v) = before_value {
btree.remove(entity_type, field, v, entity_id)?;
}
if let Some(v) = after_value {
btree.insert(entity_type, field, v, entity_id)?;
}
}
}
}
Ok(())
}
Async Index Updates¶
For high-throughput writes, indexes can be updated asynchronously:
// Write path
storage.put_typed("User", key, record)?;
changelog.append(ChangelogEntry::new("User", entity_id, fields))?;
// Background worker
loop {
let entry = changelog.next()?;
storage.update_secondary_indexes(
&entry.entity_type,
entry.entity_id,
entry.before_fields,
entry.after_fields,
)?;
changelog.mark_processed(entry.lsn)?;
}
Performance Characteristics¶
Hash Index¶
| Operation | Complexity | Notes |
|---|---|---|
| Insert | O(1) amortized | May need to read/write ID list |
| Remove | O(n) | n = IDs with same value |
| Lookup | O(1) | Plus decompression |
Best for: - High cardinality fields (user_id, email) - Equality filters
B-tree Index¶
| Operation | Complexity | Notes |
|---|---|---|
| Insert | O(log N) | N = total entries |
| Remove | O(log N) | |
| Point lookup | O(log N) | |
| Range scan | O(log N + K) | K = results |
Best for: - Range queries (age > 18, date between) - Sorting (ORDER BY created_at) - Prefix matching (email LIKE 'admin%')
Best Practices¶
When to Index¶
Hash index (automatic on indexed fields): - Foreign key fields - Status/enum fields with filters - Unique constraint fields
B-tree index (built on demand): - Date/time fields for sorting - Numeric fields for ranges - Text fields for prefix search
When Not to Index¶
- Very low cardinality (boolean with 50/50 split)
- Frequently updated fields
- Fields rarely used in queries
Next Steps¶
- Storage Engine - Data organization
- Query Engine - How indexes are used
- Performance Guide - Optimization tips