use fjall::{Keyspace, OwnedWriteBatch, Slice};
use super::keys;
use super::{IndexDb, IndexError};
use crate::extract::{FileMapL1, FileMapL2, Symbol};
use crate::intel::model::FileResolvedRefs;
use crate::path::RelPath;
#[cfg(feature = "code-search")]
use crate::search::bm25::ChunkPosting;
pub struct IndexWriter {
db: IndexDb,
batch: OwnedWriteBatch,
staged_bytes: u64,
#[cfg(test)]
pub(crate) fail_after_staging: bool,
}
const STAGED_ITEM_BYTES: u64 = 64;
const SLICE_INLINE_MAX: usize = 20;
const SLICE_HEAP_HEADER: u64 = 8;
fn staged_entry_bytes(key_len: usize, value_len: usize) -> u64 {
fn payload(len: usize) -> u64 {
if len <= SLICE_INLINE_MAX {
0
} else {
SLICE_HEAP_HEADER + len as u64
}
}
STAGED_ITEM_BYTES + payload(key_len) + payload(value_len)
}
fn stage_insert<K: Into<Slice>, V: Into<Slice>>(
batch: &mut OwnedWriteBatch,
staged_bytes: &mut u64,
partition: &Keyspace,
key: K,
value: V,
) {
let key = key.into();
let value = value.into();
*staged_bytes += staged_entry_bytes(key.len(), value.len());
batch.insert(partition, key, value);
}
fn stage_remove<K: Into<Slice>>(batch: &mut OwnedWriteBatch, staged_bytes: &mut u64, partition: &Keyspace, key: K) {
let key = key.into();
*staged_bytes += staged_entry_bytes(key.len(), 0);
batch.remove(partition, key);
}
impl IndexWriter {
pub(super) fn new(db: IndexDb) -> Self {
let batch = db.db.batch();
Self {
db,
batch,
staged_bytes: 0,
#[cfg(test)]
fail_after_staging: false,
}
}
pub fn staged_bytes(&self) -> u64 {
self.staged_bytes
}
pub fn upsert_file(&mut self, rel: &RelPath, l1: &FileMapL1, l2: Option<&FileMapL2>) -> Result<(), IndexError> {
self.stage_deletes_for(rel)?;
self.stage_inserts_for(rel, l1)?;
if let Some(l2) = l2 {
self.stage_call_inserts(rel, l2)?;
}
#[cfg(test)]
if self.fail_after_staging {
return Err(IndexError::Io {
path: rel.to_path_buf(),
source: std::io::Error::other("injected upsert failure after staging"),
});
}
Ok(())
}
pub fn remove_file(&mut self, rel: &RelPath) -> Result<(), IndexError> {
self.stage_deletes_for(rel)?;
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
stage_remove(batch, staged_bytes, &db.grep_bloom, rel.as_bytes().to_vec());
Ok(())
}
pub fn upsert_grep_bloom(&mut self, rel: &RelPath, row: Vec<u8>) {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
stage_insert(batch, staged_bytes, &db.grep_bloom, rel.as_bytes().to_vec(), row);
}
pub fn upsert_resolved_file(&mut self, use_rel: &RelPath, refs: &FileResolvedRefs) -> Result<(), IndexError> {
self.stage_resolved_deletes_for(use_rel)?;
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
for edge in &refs.intra {
stage_insert(
batch,
staged_bytes,
&db.refs_by_def,
keys::ref_by_def(use_rel, edge.def_start, use_rel, edge.use_start),
Vec::<u8>::new(),
);
stage_insert(
batch,
staged_bytes,
&db.refs_by_path,
keys::ref_by_path(use_rel, edge.use_start, use_rel, edge.def_start),
Vec::<u8>::new(),
);
}
Ok(())
}
pub fn remove_resolved_file(&mut self, use_rel: &RelPath) -> Result<(), IndexError> {
self.stage_resolved_deletes_for(use_rel)
}
#[cfg(feature = "code-search")]
pub fn upsert_bm25_file(&mut self, rel: &RelPath, postings: &[ChunkPosting]) -> Result<(), IndexError> {
self.stage_bm25_deletes_for(rel)?;
self.stage_bm25_inserts_for(rel, postings)?;
Ok(())
}
#[cfg(feature = "code-search")]
pub fn remove_bm25_file(&mut self, rel: &RelPath) -> Result<(), IndexError> {
self.stage_bm25_deletes_for(rel)
}
#[cfg(any(feature = "code-intel-js", feature = "code-intel-stack"))]
pub fn upsert_cross_file_edge(
&mut self,
def_rel: &RelPath,
def_start: u32,
use_rel: &RelPath,
use_start: u32,
) -> Result<(), IndexError> {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
stage_insert(
batch,
staged_bytes,
&db.refs_by_def,
keys::ref_by_def(def_rel, def_start, use_rel, use_start),
Vec::<u8>::new(),
);
stage_insert(
batch,
staged_bytes,
&db.refs_by_path,
keys::ref_by_path(use_rel, use_start, def_rel, def_start),
Vec::<u8>::new(),
);
Ok(())
}
pub fn commit(self) -> Result<(), IndexError> {
self.batch.commit()?;
Ok(())
}
fn stage_deletes_for(&mut self, rel: &RelPath) -> Result<(), IndexError> {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
let path_prefix = keys::symbols_by_path_prefix(rel);
let mut found_symbols: Vec<(Vec<u8>, Symbol)> = Vec::new();
for guard in db.symbols_by_path.prefix(path_prefix) {
let (k, v) = guard.into_inner()?;
match rmp_serde::from_slice::<Symbol>(&v) {
Ok(sym) => found_symbols.push(((*k).to_vec(), sym)),
Err(e) => {
tracing::warn!(
path = %rel,
error = %e,
"index: failed to decode Symbol blob during delete staging — skipping entry"
);
}
}
}
for (path_key, sym) in found_symbols {
stage_remove(batch, staged_bytes, &db.symbols_by_path, path_key);
if let Some(name_key) = keys::symbol_by_name(&sym.name, sym.kind, rel, sym.start_byte) {
stage_remove(batch, staged_bytes, &db.symbols_by_name, name_key);
}
}
let call_path_prefix = keys::calls_by_path_prefix(rel);
let mut found_calls: Vec<(Vec<u8>, crate::extract::Call)> = Vec::new();
for guard in db.calls_by_path.prefix(call_path_prefix) {
let (k, v) = guard.into_inner()?;
match rmp_serde::from_slice::<crate::extract::Call>(&v) {
Ok(call) => found_calls.push(((*k).to_vec(), call)),
Err(e) => {
tracing::warn!(
path = %rel,
error = %e,
"index: failed to decode Call blob during delete staging — skipping entry"
);
}
}
}
for (path_key, call) in found_calls {
stage_remove(batch, staged_bytes, &db.calls_by_path, path_key);
if let Some(callee_key) = keys::call_by_callee(&call.callee, rel, call.start_byte) {
stage_remove(batch, staged_bytes, &db.calls_by_callee, callee_key);
}
}
let imp_path_prefix = keys::imports_by_path_prefix(rel);
let mut found_imports: Vec<(Vec<u8>, String, u32)> = Vec::new();
for guard in db.imports_by_path.prefix(imp_path_prefix) {
let (k, _) = guard.into_inner()?;
if let Some((_, module, start_byte)) = keys::parse_import_by_path(&k) {
found_imports.push(((*k).to_vec(), module, start_byte));
}
}
for (path_key, module, start_byte) in found_imports {
stage_remove(batch, staged_bytes, &db.imports_by_path, path_key);
if let Some(module_key) = keys::import_by_module(&module, rel, start_byte) {
stage_remove(batch, staged_bytes, &db.imports_by_module, module_key);
}
}
let impl_path_prefix = keys::impls_by_path_prefix(rel);
let mut found_impls: Vec<(Vec<u8>, String, String, u32)> = Vec::new();
for guard in db.implementations_by_path.prefix(impl_path_prefix) {
let (k, _) = guard.into_inner()?;
if let Some((_, trait_name, impl_type, start_byte)) = keys::parse_impl_by_path(&k) {
found_impls.push(((*k).to_vec(), trait_name, impl_type, start_byte));
}
}
for (path_key, trait_name, impl_type, start_byte) in found_impls {
stage_remove(batch, staged_bytes, &db.implementations_by_path, path_key);
if let Some(trait_key) = keys::impl_by_trait(&trait_name, &impl_type, rel, start_byte) {
stage_remove(batch, staged_bytes, &db.implementations_by_trait, trait_key);
}
}
Ok(())
}
fn stage_resolved_deletes_for(&mut self, use_rel: &RelPath) -> Result<(), IndexError> {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
let prefix = keys::refs_by_path_prefix(use_rel);
let mut found: Vec<(Vec<u8>, RelPath, u32, u32)> = Vec::new();
for guard in db.refs_by_path.prefix(prefix) {
let (k, _) = guard.into_inner()?;
if let Some((_use_path, use_start, def_path, def_start)) = keys::parse_ref_by_path(&k) {
found.push(((*k).to_vec(), def_path, def_start, use_start));
}
}
for (path_key, def_path, def_start, use_start) in found {
stage_remove(batch, staged_bytes, &db.refs_by_path, path_key);
stage_remove(
batch,
staged_bytes,
&db.refs_by_def,
keys::ref_by_def(&def_path, def_start, use_rel, use_start),
);
}
Ok(())
}
#[cfg(feature = "code-search")]
fn stage_bm25_deletes_for(&mut self, rel: &RelPath) -> Result<(), IndexError> {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
let prefix = keys::code_bm25_by_path_prefix(rel);
let mut found: Vec<(Vec<u8>, String, Vec<String>)> = Vec::new();
for guard in db.code_bm25_by_path.prefix(prefix) {
let (k, v) = guard.into_inner()?;
let Some((_rel, chunk_id)) = keys::parse_code_bm25_by_path(&k) else {
continue;
};
let terms: Vec<String> = if v.len() >= 4 {
match rmp_serde::from_slice::<Vec<String>>(&v[4..]) {
Ok(terms) => terms,
Err(e) => {
tracing::warn!(
path = %rel,
error = %e,
"index: failed to decode BM25 term list during delete staging — skipping entry"
);
Vec::new()
}
}
} else {
Vec::new()
};
found.push(((*k).to_vec(), chunk_id, terms));
}
for (path_key, chunk_id, terms) in found {
stage_remove(batch, staged_bytes, &db.code_bm25_by_path, path_key);
for term in terms {
if let Some(posting_key) = keys::code_bm25_posting(&term, &chunk_id) {
stage_remove(batch, staged_bytes, &db.code_bm25_postings, posting_key);
}
}
}
Ok(())
}
#[cfg(feature = "code-search")]
fn stage_bm25_inserts_for(&mut self, rel: &RelPath, postings: &[ChunkPosting]) -> Result<(), IndexError> {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
for posting in postings {
for (term, tf) in &posting.terms {
if let Some(posting_key) = keys::code_bm25_posting(term, &posting.chunk_id) {
stage_insert(
batch,
staged_bytes,
&db.code_bm25_postings,
posting_key,
keys::code_bm25_posting_value(*tf, posting.doclen),
);
}
}
let term_names: Vec<&str> = posting.terms.iter().map(|(t, _)| t.as_str()).collect();
let terms_bytes = rmp_serde::to_vec(&term_names)?;
let mut value = Vec::with_capacity(4 + terms_bytes.len());
value.extend_from_slice(&posting.doclen.to_be_bytes());
value.extend_from_slice(&terms_bytes);
stage_insert(
batch,
staged_bytes,
&db.code_bm25_by_path,
keys::code_bm25_by_path(rel, &posting.chunk_id),
value,
);
}
Ok(())
}
fn stage_call_inserts(&mut self, rel: &RelPath, l2: &FileMapL2) -> Result<(), IndexError> {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
db.callee_names.record(l2.calls.iter().map(|call| call.callee.as_str()));
for call in &l2.calls {
let path_key = keys::call_by_path(rel, call.start_byte);
let value = rmp_serde::to_vec_named(call)?;
stage_insert(batch, staged_bytes, &db.calls_by_path, path_key, value);
if let Some(callee_key) = keys::call_by_callee(&call.callee, rel, call.start_byte) {
stage_insert(batch, staged_bytes, &db.calls_by_callee, callee_key, Vec::<u8>::new());
} else {
tracing::debug!(
path = %rel,
callee_len = call.callee.len(),
"index: callee name exceeds 64 KiB — skipping calls_by_callee entry"
);
}
}
Ok(())
}
fn stage_inserts_for(&mut self, rel: &RelPath, l1: &FileMapL1) -> Result<(), IndexError> {
let (db, batch, staged_bytes) = (&self.db, &mut self.batch, &mut self.staged_bytes);
for sym in &l1.symbols {
let path_key = keys::symbol_by_path(rel, sym.start_byte);
let value = rmp_serde::to_vec_named(sym)?;
stage_insert(batch, staged_bytes, &db.symbols_by_path, path_key, value);
if let Some(name_key) = keys::symbol_by_name(&sym.name, sym.kind, rel, sym.start_byte) {
stage_insert(batch, staged_bytes, &db.symbols_by_name, name_key, Vec::<u8>::new());
} else {
tracing::debug!(
path = %rel,
name_len = sym.name.len(),
"index: symbol name exceeds 64 KiB — skipping symbols_by_name entry"
);
}
}
for imp in &l1.imports {
if let Some(module) = &imp.module {
match (
keys::import_by_module(module, rel, imp.start_byte),
keys::import_by_path(rel, module, imp.start_byte),
) {
(Some(module_key), Some(path_key)) => {
stage_insert(batch, staged_bytes, &db.imports_by_module, module_key, Vec::<u8>::new());
stage_insert(batch, staged_bytes, &db.imports_by_path, path_key, Vec::<u8>::new());
}
_ => {
tracing::debug!(
path = %rel,
module_len = module.len(),
"index: import module name exceeds 64 KiB — skipping imports index entries"
);
}
}
}
}
db.trait_names
.record(l1.implementations.iter().map(|imp| imp.trait_name.as_str()));
for imp in &l1.implementations {
match (
keys::impl_by_trait(&imp.trait_name, &imp.impl_type, rel, imp.start_byte),
keys::impl_by_path(rel, &imp.trait_name, &imp.impl_type, imp.start_byte),
) {
(Some(trait_key), Some(path_key)) => {
stage_insert(
batch,
staged_bytes,
&db.implementations_by_trait,
trait_key,
Vec::<u8>::new(),
);
stage_insert(
batch,
staged_bytes,
&db.implementations_by_path,
path_key,
Vec::<u8>::new(),
);
}
_ => {
tracing::debug!(
path = %rel,
trait_len = imp.trait_name.len(),
impl_len = imp.impl_type.len(),
"index: trait/impl-type name exceeds 64 KiB — skipping implementations index entries"
);
}
}
}
Ok(())
}
}
#[cfg(test)]
#[path = "writer_tests.rs"]
mod tests;