pub mod grep_bloom;
pub mod keys;
pub mod keys_governance;
pub(crate) mod name_dict;
pub mod writer;
use std::path::{Path, PathBuf};
use fjall::{Database, Keyspace, KeyspaceCreateOptions};
use thiserror::Error;
const INDEX_PARTITION_REVISION: u32 = 5;
pub const INDEX_SCHEMA_VER: u32 = crate::version::RELEASE_MINOR as u32 + INDEX_PARTITION_REVISION;
const META_SCHEMA_VER: &[u8] = b"schema_ver";
const META_BM25_DOC_COUNT: &[u8] = b"code_bm25_n";
const META_BM25_TOTAL_LEN: &[u8] = b"code_bm25_total_len";
const INDEX_DIR: &str = "index.fjall";
const KEYSPACE_MEMTABLE_BYTES: &[(&str, u64)] = &[
("meta", MEMTABLE_COLD_BYTES),
("symbols_by_path", MEMTABLE_WARM_BYTES),
("symbols_by_name", MEMTABLE_WARM_BYTES),
("calls_by_path", MEMTABLE_WARM_BYTES),
("calls_by_callee", MEMTABLE_WARM_BYTES),
("imports_by_module", MEMTABLE_COLD_BYTES),
("imports_by_path", MEMTABLE_COLD_BYTES),
("implementations_by_trait", MEMTABLE_COLD_BYTES),
("implementations_by_path", MEMTABLE_COLD_BYTES),
("refs_by_def", MEMTABLE_WARM_BYTES),
("refs_by_path", MEMTABLE_WARM_BYTES),
("code_bm25_postings", MEMTABLE_HOT_BYTES),
("code_bm25_by_path", MEMTABLE_WARM_BYTES),
("grep_bloom", MEMTABLE_COLD_BYTES),
("embeddings", MEMTABLE_COLD_BYTES),
("memory_by_key", MEMTABLE_COLD_BYTES),
("memory_archive", MEMTABLE_COLD_BYTES),
("proposals", MEMTABLE_COLD_BYTES),
];
const MEMTABLE_HOT_BYTES: u64 = 16 * 1_024 * 1_024;
const MEMTABLE_WARM_BYTES: u64 = 8 * 1_024 * 1_024;
const MEMTABLE_COLD_BYTES: u64 = 4 * 1_024 * 1_024;
const MEMTABLE_SEALED_LIMIT: u64 = 4;
pub const INDEX_MEMTABLE_CEILING_BYTES: u64 = memtable_ceiling_bytes();
const fn memtable_ceiling_bytes() -> u64 {
let mut total = 0;
let mut i = 0;
while i < KEYSPACE_MEMTABLE_BYTES.len() {
total += KEYSPACE_MEMTABLE_BYTES[i].1 * (1 + MEMTABLE_SEALED_LIMIT);
i += 1;
}
total
}
fn open_keyspace(db: &Database, name: &str) -> Result<Keyspace, IndexError> {
let bytes = memtable_bytes_for(name);
Ok(db.keyspace(name, move || KeyspaceCreateOptions::default().max_memtable_size(bytes))?)
}
fn memtable_bytes_for(name: &str) -> u64 {
KEYSPACE_MEMTABLE_BYTES
.iter()
.find(|(known, _)| *known == name)
.map_or(MEMTABLE_COLD_BYTES, |(_, bytes)| *bytes)
}
const INDEX_CACHE_FLOOR_BYTES: u64 = 32 * 1_024 * 1_024;
const INDEX_CACHE_CEILING_BYTES: u64 = 256 * 1_024 * 1_024;
const INDEX_CACHE_DISK_FRACTION: f64 = 0.20;
const INDEX_CACHE_BYTES_ENV: &str = "BASEMIND_INDEX_CACHE_BYTES";
fn dir_size_bytes(dir: &Path) -> u64 {
let mut total = 0u64;
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(entries) = std::fs::read_dir(¤t) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(entry.path());
} else if file_type.is_file()
&& let Ok(metadata) = entry.metadata()
{
total += metadata.len();
}
}
}
total
}
fn index_cache_bytes(dir: &Path) -> u64 {
if let Some(bytes) = std::env::var(INDEX_CACHE_BYTES_ENV)
.ok()
.and_then(|v| v.parse::<u64>().ok())
{
return bytes;
}
let disk_bytes = dir_size_bytes(dir);
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
let scaled_bytes = (disk_bytes as f64 * INDEX_CACHE_DISK_FRACTION) as u64;
scaled_bytes.clamp(INDEX_CACHE_FLOOR_BYTES, INDEX_CACHE_CEILING_BYTES)
}
#[derive(Debug, Error)]
pub enum IndexError {
#[error("fjall error: {0}")]
Fjall(#[from] fjall::Error),
#[error("io error on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("msgpack encode error: {0}")]
Encode(#[from] rmp_serde::encode::Error),
#[error("msgpack decode error: {0}")]
Decode(#[from] rmp_serde::decode::Error),
}
#[derive(Clone)]
pub struct IndexDb {
pub(crate) db: Database,
pub(crate) meta: Keyspace,
pub(crate) symbols_by_path: Keyspace,
pub(crate) symbols_by_name: Keyspace,
pub(crate) calls_by_path: Keyspace,
pub(crate) calls_by_callee: Keyspace,
pub(crate) callee_names: std::sync::Arc<name_dict::NameDict>,
pub(crate) imports_by_module: Keyspace,
pub(crate) imports_by_path: Keyspace,
pub(crate) implementations_by_trait: Keyspace,
pub(crate) trait_names: std::sync::Arc<name_dict::NameDict>,
pub(crate) implementations_by_path: Keyspace,
pub(crate) refs_by_def: Keyspace,
pub(crate) refs_by_path: Keyspace,
#[allow(dead_code)]
pub(crate) code_bm25_postings: Keyspace,
pub(crate) code_bm25_by_path: Keyspace,
pub(crate) grep_bloom: Keyspace,
#[allow(dead_code)]
pub(crate) embeddings: Keyspace,
#[allow(dead_code)]
pub(crate) memory_by_key: Keyspace,
#[allow(dead_code)]
pub(crate) memory_archive: Keyspace,
#[allow(dead_code)]
pub(crate) proposals: Keyspace,
}
const FJALL_WORKER_THREADS: usize = 2;
const FJALL_MAX_JOURNAL_BYTES: u64 = 64 * 1_024 * 1_024;
const JOURNAL_FLUSH_ON_OPEN_BYTES: u64 = 256 * 1_024 * 1_024;
pub fn journal_bytes_on_disk(view_dir: &Path) -> u64 {
std::fs::read_dir(view_dir.join(INDEX_DIR))
.into_iter()
.flatten()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jnl"))
.filter_map(|entry| entry.metadata().ok())
.filter(std::fs::Metadata::is_file)
.map(|meta| meta.len())
.sum()
}
pub fn journal_exceeds_open_budget(view_dir: &Path) -> bool {
journal_bytes_on_disk(view_dir) > JOURNAL_FLUSH_ON_OPEN_BYTES
}
pub fn peek_index_schema_ver(view_dir: &Path) -> Result<Option<u32>, IndexError> {
let dir = view_dir.join(INDEX_DIR);
let db = Database::builder(&dir)
.cache_size(8 * 1_024 * 1_024)
.worker_threads(FJALL_WORKER_THREADS)
.max_journaling_size(FJALL_MAX_JOURNAL_BYTES)
.open()?;
let meta = open_keyspace(&db, "meta")?;
let ver = meta
.get(META_SCHEMA_VER)?
.and_then(|bytes| <[u8; 4]>::try_from(&bytes[..]).ok())
.map(u32::from_be_bytes);
Ok(ver)
}
impl IndexDb {
pub fn open(view_dir: &Path) -> Result<Self, IndexError> {
let dir = view_dir.join(INDEX_DIR);
std::fs::create_dir_all(&dir).map_err(|source| IndexError::Io {
path: dir.clone(),
source,
})?;
let cache_bytes = index_cache_bytes(&dir);
let mut db = Database::builder(&dir)
.cache_size(cache_bytes)
.worker_threads(FJALL_WORKER_THREADS)
.max_journaling_size(FJALL_MAX_JOURNAL_BYTES)
.open()?;
let mut meta = open_keyspace(&db, "meta")?;
let on_disk_ver = meta
.get(META_SCHEMA_VER)?
.and_then(|bytes| <[u8; 4]>::try_from(&bytes[..]).ok())
.map(u32::from_be_bytes);
if matches!(on_disk_ver, Some(ver) if ver != INDEX_SCHEMA_VER) {
drop(meta);
drop(db);
std::fs::remove_dir_all(&dir).map_err(|source| IndexError::Io {
path: dir.clone(),
source,
})?;
std::fs::create_dir_all(&dir).map_err(|source| IndexError::Io {
path: dir.clone(),
source,
})?;
db = Database::builder(&dir)
.cache_size(cache_bytes)
.worker_threads(FJALL_WORKER_THREADS)
.max_journaling_size(FJALL_MAX_JOURNAL_BYTES)
.open()?;
meta = open_keyspace(&db, "meta")?;
}
let symbols_by_path = open_keyspace(&db, "symbols_by_path")?;
let symbols_by_name = open_keyspace(&db, "symbols_by_name")?;
let calls_by_path = open_keyspace(&db, "calls_by_path")?;
let calls_by_callee = open_keyspace(&db, "calls_by_callee")?;
let imports_by_module = open_keyspace(&db, "imports_by_module")?;
let imports_by_path = open_keyspace(&db, "imports_by_path")?;
let implementations_by_trait = open_keyspace(&db, "implementations_by_trait")?;
let implementations_by_path = open_keyspace(&db, "implementations_by_path")?;
let refs_by_def = open_keyspace(&db, "refs_by_def")?;
let refs_by_path = open_keyspace(&db, "refs_by_path")?;
let code_bm25_postings = open_keyspace(&db, "code_bm25_postings")?;
let code_bm25_by_path = open_keyspace(&db, "code_bm25_by_path")?;
let grep_bloom = open_keyspace(&db, "grep_bloom")?;
let embeddings = open_keyspace(&db, "embeddings")?;
let memory_by_key = open_keyspace(&db, "memory_by_key")?;
let memory_archive = open_keyspace(&db, "memory_archive")?;
let proposals = open_keyspace(&db, "proposals")?;
meta.insert(META_SCHEMA_VER, INDEX_SCHEMA_VER.to_be_bytes())?;
if db.journal_disk_space().unwrap_or(0) > JOURNAL_FLUSH_ON_OPEN_BYTES {
for keyspace in [
&symbols_by_path,
&symbols_by_name,
&calls_by_path,
&calls_by_callee,
&imports_by_module,
&imports_by_path,
&implementations_by_trait,
&implementations_by_path,
&refs_by_def,
&refs_by_path,
&code_bm25_postings,
&code_bm25_by_path,
&grep_bloom,
&embeddings,
&memory_by_key,
&memory_archive,
&proposals,
&meta,
] {
if let Err(error) = keyspace.rotate_memtable_and_wait() {
tracing::warn!(%error, "index journal flush on open failed; continuing with the replayed journal");
break;
}
}
}
Ok(Self {
db,
meta,
symbols_by_path,
symbols_by_name,
calls_by_path,
calls_by_callee,
callee_names: std::sync::Arc::default(),
imports_by_module,
imports_by_path,
implementations_by_trait,
trait_names: std::sync::Arc::default(),
implementations_by_path,
refs_by_def,
refs_by_path,
code_bm25_postings,
code_bm25_by_path,
grep_bloom,
embeddings,
memory_by_key,
memory_archive,
proposals,
})
}
pub fn writer(&self) -> writer::IndexWriter {
writer::IndexWriter::new(self.clone())
}
pub fn symbols_index_is_empty(&self) -> bool {
self.symbols_by_path.iter().next().is_none()
}
pub fn references_to(&self, def_path: &crate::path::RelPath, def_start: u32) -> Vec<(crate::path::RelPath, u32)> {
let prefix = keys::refs_by_def_prefix(def_path, def_start);
let mut out = Vec::new();
for guard in self.refs_by_def.prefix(prefix) {
if let Ok((k, _)) = guard.into_inner()
&& let Some((_def_path, _def_start, use_path, use_start)) = keys::parse_ref_by_def(&k)
{
out.push((use_path, use_start));
}
}
out
}
pub fn definition_of(
&self,
use_path: &crate::path::RelPath,
use_start: u32,
) -> Option<(crate::path::RelPath, u32)> {
let prefix = keys::refs_by_use_prefix(use_path, use_start);
let mut local = None;
for guard in self.refs_by_path.prefix(prefix) {
if let Ok((k, _)) = guard.into_inner()
&& let Some((_use_path, _use_start, def_path, def_start)) = keys::parse_ref_by_path(&k)
{
if def_path != *use_path {
return Some((def_path, def_start));
}
local = Some((def_path, def_start));
}
}
local
}
pub fn symbols_by_name_lookup(
&self,
name: &str,
cap: usize,
) -> Vec<(String, crate::extract::SymbolKind, crate::path::RelPath, u32)> {
let prefix = keys::symbols_by_name_prefix(name);
let mut out = Vec::new();
for guard in self.symbols_by_name.prefix(prefix) {
if out.len() >= cap {
break;
}
if let Ok((k, _)) = guard.into_inner()
&& let Some((matched, kind, rel, start_byte)) = keys::parse_symbol_by_name(&k)
{
out.push((matched, kind, rel, start_byte));
}
}
out
}
pub fn bm25_stats(&self) -> Option<(u64, u64)> {
let n = self
.meta
.get(META_BM25_DOC_COUNT)
.ok()
.flatten()
.and_then(|b| <[u8; 8]>::try_from(&b[..]).ok())
.map(u64::from_be_bytes)?;
let total_len = self
.meta
.get(META_BM25_TOTAL_LEN)
.ok()
.flatten()
.and_then(|b| <[u8; 8]>::try_from(&b[..]).ok())
.map(u64::from_be_bytes)
.unwrap_or(0);
Some((n, total_len))
}
pub fn recompute_bm25_stats(&self) -> Result<(), IndexError> {
let mut n: u64 = 0;
let mut total_len: u64 = 0;
for guard in self.code_bm25_by_path.iter() {
let (_k, v) = guard.into_inner()?;
if v.len() >= 4 {
total_len += u64::from(u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
}
n += 1;
}
self.meta.insert(META_BM25_DOC_COUNT, n.to_be_bytes())?;
self.meta.insert(META_BM25_TOTAL_LEN, total_len.to_be_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const ROTATION_PROBE_BYTES: usize = 24 * 1_024 * 1_024;
fn write_until_rotation_probe(keyspace: &Keyspace) {
let value = vec![0u8; 4_096];
for i in 0..(ROTATION_PROBE_BYTES / value.len()) {
keyspace
.insert(i.to_be_bytes(), value.as_slice())
.expect("probe insert");
}
}
fn flushed_within(keyspace: &Keyspace, timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if keyspace.disk_space() > 0 {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
keyspace.disk_space() > 0
}
#[test]
fn keyspace_memtable_table_covers_every_partition() {
let dir = tempfile::tempdir().unwrap();
let db = IndexDb::open(dir.path()).unwrap();
let mut opened: Vec<String> = db.db.list_keyspace_names().iter().map(ToString::to_string).collect();
opened.sort();
let mut sized: Vec<String> = KEYSPACE_MEMTABLE_BYTES
.iter()
.map(|(name, _)| (*name).to_string())
.collect();
sized.sort();
assert_eq!(
opened, sized,
"every opened keyspace must be sized by the table, and the table must name no keyspace \
that is never opened — an unlisted name silently falls back to the cold tier"
);
}
#[test]
fn memtable_ceiling_is_bounded() {
let partitions = u64::try_from(KEYSPACE_MEMTABLE_BYTES.len()).expect("keyspace count fits a u64");
let unsized_ceiling = partitions * (1 + MEMTABLE_SEALED_LIMIT) * 64 * 1_024 * 1_024;
const { assert!(INDEX_MEMTABLE_CEILING_BYTES <= 1_024 * 1_024 * 1_024) };
assert!(
INDEX_MEMTABLE_CEILING_BYTES * 4 <= unsized_ceiling,
"sizing must buy at least a 4x cut against fjall's 64 MiB default"
);
}
#[test]
fn configured_memtable_size_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
{
let db = IndexDb::open(dir.path()).unwrap();
assert_eq!(db.proposals.disk_space(), 0, "nothing written yet");
}
let db = IndexDb::open(dir.path()).unwrap();
write_until_rotation_probe(&db.proposals);
assert!(
flushed_within(&db.proposals, std::time::Duration::from_secs(30)),
"a reopened keyspace must still rotate at its configured {MEMTABLE_COLD_BYTES}-byte \
memtable size; no flush means fjall recovered the 64 MiB default instead"
);
}
}