use crate::adj::{read_varint, write_varint};
use crate::codec::{frame_value, unframe_value};
use crate::db::Db;
use crate::error::{storage_err, TopoError};
use crate::ids::ScopeSet;
use crate::index::IndexSpec;
use crate::props::PropValue;
use crate::state::NodeRecord;
use crate::storage::{read_node_by_slot, slot_key, FTS_DOCS, FTS_STATS, NODES, POSTINGS};
use crate::vector_store::{EMBEDDING_REF, VECTORS};
use redb::{ReadableTable, Table};
use std::collections::{BTreeMap, BTreeSet, HashMap};
pub(crate) const K1: f32 = 1.2;
pub(crate) const B: f32 = 0.75;
pub(crate) fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
pub(crate) fn doc_text(spec: &IndexSpec, rec: &NodeRecord) -> Option<String> {
let mut parts: Vec<&str> = Vec::new();
for pi in &spec.text {
if pi.label != rec.label {
continue;
}
if let Some(PropValue::Str(s)) = rec.props.get(&pi.prop) {
parts.push(s.as_str());
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
fn term_freqs(tokens: &[String]) -> BTreeMap<&str, u32> {
let mut m: BTreeMap<&str, u32> = BTreeMap::new();
for t in tokens {
*m.entry(t.as_str()).or_insert(0) += 1;
}
m
}
fn read_stats(
stats: &impl ReadableTable<&'static [u8], &'static [u8]>,
scope_id: u32,
) -> Result<(u64, u64), TopoError> {
let key = scope_id.to_be_bytes();
match stats.get(key.as_slice()).map_err(storage_err)? {
Some(v) => postcard::from_bytes(v.value()).map_err(|e| TopoError::Encoding(e.to_string())),
None => Ok((0, 0)),
}
}
fn write_stats(
stats: &mut Table<'_, &'static [u8], &'static [u8]>,
scope_id: u32,
doc_count: u64,
total_len: u64,
) -> Result<(), TopoError> {
let key = scope_id.to_be_bytes();
if doc_count == 0 {
stats.remove(key.as_slice()).map_err(storage_err)?;
} else {
let bytes = postcard::to_allocvec(&(doc_count, total_len))
.map_err(|e| TopoError::Encoding(e.to_string()))?;
stats
.insert(key.as_slice(), bytes.as_slice())
.map_err(storage_err)?;
}
Ok(())
}
fn read_doc_len(
docs: &impl ReadableTable<&'static [u8], &'static [u8]>,
slot: u64,
) -> Result<u32, TopoError> {
let key = slot_key(slot);
match docs.get(key.as_slice()).map_err(storage_err)? {
Some(v) => {
postcard::from_bytes::<u32>(v.value()).map_err(|e| TopoError::Encoding(e.to_string()))
}
None => Ok(0),
}
}
pub(crate) fn fts_update(
postings: &mut Table<'_, &'static [u8], &'static [u8]>,
docs: &mut Table<'_, &'static [u8], &'static [u8]>,
stats: &mut Table<'_, &'static [u8], &'static [u8]>,
scope_id: u32,
slot: u64,
old_text: Option<&str>,
new_text: Option<&str>,
) -> Result<(), TopoError> {
let old_tokens = old_text.map(tokenize).unwrap_or_default();
let new_tokens = new_text.map(tokenize).unwrap_or_default();
let old_text = if old_tokens.is_empty() {
None
} else {
old_text
};
let new_text = if new_tokens.is_empty() {
None
} else {
new_text
};
if old_text == new_text {
return Ok(());
}
let old_tf = term_freqs(&old_tokens);
let new_tf = term_freqs(&new_tokens);
let mut terms: BTreeSet<&str> = BTreeSet::new();
terms.extend(old_tf.keys().copied());
terms.extend(new_tf.keys().copied());
for term in terms {
let count = new_tf.get(term).copied().unwrap_or(0);
set_posting(postings, scope_id, term, slot, count)?;
}
let key = slot_key(slot);
let old_len = old_tokens.len() as u64;
let new_len = new_tokens.len() as u64;
let (mut doc_count, mut total_len) = read_stats(stats, scope_id)?;
match (old_text.is_some(), new_text.is_some()) {
(false, true) => {
doc_count += 1;
total_len += new_len;
}
(true, true) => {
total_len = total_len.saturating_sub(old_len) + new_len;
}
(true, false) => {
doc_count = doc_count.saturating_sub(1);
total_len = total_len.saturating_sub(old_len);
}
(false, false) => {}
}
if new_text.is_some() {
let bytes = postcard::to_allocvec(&(new_len as u32))
.map_err(|e| TopoError::Encoding(e.to_string()))?;
docs.insert(key.as_slice(), bytes.as_slice())
.map_err(storage_err)?;
} else {
docs.remove(key.as_slice()).map_err(storage_err)?;
}
write_stats(stats, scope_id, doc_count, total_len)?;
Ok(())
}
pub(crate) const POSTINGS_BLOCK_FORMAT_V0: u8 = 0x00;
pub(crate) const POSTINGS_CHUNK_TARGET: usize = 4 * 1024;
pub(crate) fn chunked_posting_key(scope_id: u32, term: &str, chunk: u32) -> Vec<u8> {
let mut key = Vec::with_capacity(4 + term.len() + 4);
key.extend_from_slice(&scope_id.to_be_bytes());
key.extend_from_slice(term.as_bytes());
key.extend_from_slice(&chunk.to_be_bytes());
key
}
pub(crate) fn encode_posting_block(entries: &[(u64, u32)]) -> Result<Vec<u8>, TopoError> {
if entries.is_empty() {
return Err(TopoError::Encoding(
"cannot encode an empty postings chunk (empty chunks are removed, never written)"
.into(),
));
}
if entries.windows(2).any(|pair| pair[0].0 > pair[1].0) {
return Err(TopoError::Encoding(
"postings chunk entries are not slot-sorted".into(),
));
}
let mut out = Vec::new();
out.push(POSTINGS_BLOCK_FORMAT_V0);
write_varint(&mut out, entries.len() as u64);
let mut previous = 0u64;
for &(slot, tf) in entries {
write_varint(
&mut out,
slot.checked_sub(previous)
.ok_or_else(|| TopoError::Encoding("postings chunk slot underflow".into()))?,
);
previous = slot;
write_varint(&mut out, tf as u64);
}
Ok(out)
}
pub(crate) fn decode_posting_block(payload: &[u8]) -> Result<Vec<(u64, u32)>, TopoError> {
let Some((&format, mut input)) = payload.split_first() else {
return Err(TopoError::Encoding("empty postings chunk block".into()));
};
if format != POSTINGS_BLOCK_FORMAT_V0 {
return Err(TopoError::Encoding(format!(
"unknown postings block format 0x{format:02X}"
)));
}
let count = usize::try_from(read_varint(&mut input)?)
.map_err(|_| TopoError::Encoding("postings chunk count too large".into()))?;
let mut entries = Vec::with_capacity(count);
let mut slot = 0u64;
for _ in 0..count {
slot = slot
.checked_add(read_varint(&mut input)?)
.ok_or_else(|| TopoError::Encoding("postings chunk slot overflow".into()))?;
let tf = u32::try_from(read_varint(&mut input)?)
.map_err(|_| TopoError::Encoding("postings chunk tf too large".into()))?;
entries.push((slot, tf));
}
if !input.is_empty() {
return Err(TopoError::Encoding(
"trailing bytes in postings chunk block".into(),
));
}
Ok(entries)
}
pub(crate) fn posting_block_count(payload: &[u8]) -> Result<u64, TopoError> {
let Some((&format, mut input)) = payload.split_first() else {
return Err(TopoError::Encoding("empty postings chunk block".into()));
};
if format != POSTINGS_BLOCK_FORMAT_V0 {
return Err(TopoError::Encoding(format!(
"unknown postings block format 0x{format:02X}"
)));
}
read_varint(&mut input)
}
fn term_chunk_keys(
postings: &impl ReadableTable<&'static [u8], &'static [u8]>,
scope_id: u32,
term: &str,
) -> Result<Vec<Vec<u8>>, TopoError> {
let start = chunked_posting_key(scope_id, term, 0);
let end = chunked_posting_key(scope_id, term, u32::MAX);
let want_len = start.len();
let mut keys = Vec::new();
for item in postings
.range(start.as_slice()..=end.as_slice())
.map_err(storage_err)?
{
let (key, _) = item.map_err(storage_err)?;
let k = key.value();
if k.len() == want_len {
keys.push(k.to_vec());
}
}
Ok(keys)
}
fn chunk_number(key: &[u8]) -> u32 {
let n = key.len();
u32::from_be_bytes(
key[n - 4..]
.try_into()
.expect("chunk key ends in a 4-byte BE chunk index"),
)
}
fn load_posting_chunk(
postings: &impl ReadableTable<&'static [u8], &'static [u8]>,
key: &[u8],
) -> Result<Vec<(u64, u32)>, TopoError> {
match postings.get(key).map_err(storage_err)? {
Some(v) => {
let raw = unframe_value(v.value())?;
decode_posting_block(raw.as_ref())
}
None => Ok(Vec::new()),
}
}
fn store_posting_chunk(
postings: &mut Table<'_, &'static [u8], &'static [u8]>,
key: &[u8],
entries: &[(u64, u32)],
) -> Result<(), TopoError> {
let framed = frame_value(encode_posting_block(entries)?);
postings
.insert(key, framed.as_slice())
.map_err(storage_err)?;
Ok(())
}
pub(crate) fn read_posting(
postings: &impl ReadableTable<&'static [u8], &'static [u8]>,
scope_id: u32,
term: &str,
) -> Result<Vec<(u64, u32)>, TopoError> {
let mut out = Vec::new();
for key in term_chunk_keys(postings, scope_id, term)? {
out.extend(load_posting_chunk(postings, &key)?);
}
Ok(out)
}
pub(crate) fn posting_df(
postings: &impl ReadableTable<&'static [u8], &'static [u8]>,
scope_id: u32,
term: &str,
) -> Result<u64, TopoError> {
let mut total = 0u64;
for key in term_chunk_keys(postings, scope_id, term)? {
if let Some(v) = postings.get(key.as_slice()).map_err(storage_err)? {
let raw = unframe_value(v.value())?;
total += posting_block_count(raw.as_ref())?;
}
}
Ok(total)
}
fn mutate_posting_chunk(
postings: &mut Table<'_, &'static [u8], &'static [u8]>,
key: &[u8],
mut entries: Vec<(u64, u32)>,
slot: u64,
count: u32,
) -> Result<(), TopoError> {
match entries.binary_search_by_key(&slot, |&(s, _)| s) {
Ok(at) => {
if count == 0 {
entries.remove(at);
} else {
entries[at].1 = count;
}
}
Err(at) => {
if count == 0 {
return Ok(()); }
entries.insert(at, (slot, count));
}
}
if entries.is_empty() {
postings.remove(key).map_err(storage_err)?;
} else {
store_posting_chunk(postings, key, &entries)?;
}
Ok(())
}
pub(crate) fn set_posting(
postings: &mut Table<'_, &'static [u8], &'static [u8]>,
scope_id: u32,
term: &str,
slot: u64,
count: u32,
) -> Result<(), TopoError> {
let keys = term_chunk_keys(postings, scope_id, term)?;
let Some(last_key) = keys.last() else {
if count == 0 {
return Ok(()); }
let key = chunked_posting_key(scope_id, term, 0);
return store_posting_chunk(postings, &key, &[(slot, count)]);
};
let mut last_entries = load_posting_chunk(postings, last_key)?;
let last_max = last_entries
.last()
.expect("a stored chunk key is never empty")
.0;
if slot > last_max {
if count == 0 {
return Ok(()); }
last_entries.push((slot, count));
let chunk = chunk_number(last_key);
return if encode_posting_block(&last_entries)?.len() <= POSTINGS_CHUNK_TARGET {
store_posting_chunk(postings, last_key, &last_entries)
} else {
let split = last_entries.len() / 2;
store_posting_chunk(postings, last_key, &last_entries[..split])?;
let next_key = chunked_posting_key(scope_id, term, chunk + 1);
store_posting_chunk(postings, &next_key, &last_entries[split..])
};
}
let last_min = last_entries
.first()
.expect("a stored chunk key is never empty")
.0;
if slot >= last_min {
return mutate_posting_chunk(postings, last_key, last_entries, slot, count);
}
for key in &keys[..keys.len() - 1] {
let entries = load_posting_chunk(postings, key)?;
let chunk_max = entries.last().expect("a stored chunk key is never empty").0;
if chunk_max >= slot {
return mutate_posting_chunk(postings, key, entries, slot, count);
}
}
mutate_posting_chunk(postings, last_key, last_entries, slot, count)
}
impl Db {
pub fn search_text(
&self,
scopes: &ScopeSet,
query: &str,
k: usize,
) -> Result<Vec<(NodeRecord, f32)>, TopoError> {
if k == 0 {
return Err(TopoError::Rejected("text search requires k > 0".into()));
}
let tokens = tokenize(query);
if tokens.is_empty() {
return Err(TopoError::Rejected("query has no searchable terms".into()));
}
let distinct: BTreeSet<String> = tokens.into_iter().collect();
let storage = self.storage();
let tx = storage.db.begin_read().map_err(storage_err)?;
let postings = tx.open_table(POSTINGS).map_err(storage_err)?;
let docs = tx.open_table(FTS_DOCS).map_err(storage_err)?;
let stats = tx.open_table(FTS_STATS).map_err(storage_err)?;
let nodes = tx.open_table(NODES).map_err(storage_err)?;
let vectors = tx.open_table(VECTORS).map_err(storage_err)?;
let embedding_ref = tx.open_table(EMBEDDING_REF).map_err(storage_err)?;
let dicts = storage.dicts.read().expect("dict lock poisoned");
let scope_registry = storage
.scope_registry
.read()
.expect("scope registry lock poisoned");
let mut scores: HashMap<u64, f32> = HashMap::new();
for scope in scopes.iter_scopes() {
let Some(scope_id) = scope_registry.id_of(scope) else {
continue;
};
let (n_docs, total_len) = read_stats(&stats, scope_id)?;
if n_docs == 0 {
continue;
}
let avgdl = total_len as f32 / n_docs as f32;
for term in &distinct {
let df = posting_df(&postings, scope_id, term)? as f32;
if df == 0.0 {
continue;
}
let list = read_posting(&postings, scope_id, term)?;
let idf = ((n_docs as f32 - df + 0.5) / (df + 0.5) + 1.0).ln();
for (slot, tf) in list {
let len = read_doc_len(&docs, slot)? as f32;
let tf = tf as f32;
let denom = tf + K1 * (1.0 - B + B * len / avgdl);
*scores.entry(slot).or_insert(0.0) += idf * tf * (K1 + 1.0) / denom;
}
}
}
let mut out: Vec<(NodeRecord, f32)> = Vec::with_capacity(scores.len());
for (slot, score) in scores {
if let Some(rec) = read_node_by_slot(
&nodes,
&vectors,
&embedding_ref,
&dicts,
&scope_registry,
slot,
)? {
if scopes.contains(rec.scope) {
out.push((rec, score));
}
}
}
out.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.id.cmp(&b.0.id))
});
out.truncate(k);
self.bump(out.iter().map(|(n, _)| n.id));
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use redb::Database;
#[test]
fn postings_roundtrip_deltas_and_isolate_be_sharing_scope_prefixes() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
let mut docs = tx.open_table(FTS_DOCS).unwrap();
let mut stats = tx.open_table(FTS_STATS).unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
1,
2,
None,
Some("rust rust database"),
)
.unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
1,
5,
None,
Some("rust engine"),
)
.unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
1,
9,
None,
Some("rust topology graph"),
)
.unwrap();
fts_update(
&mut postings,
&mut docs,
&mut stats,
256,
2,
None,
Some("rust filler"),
)
.unwrap();
let list_1 = read_posting(&postings, 1, "rust").unwrap();
assert_eq!(
list_1,
vec![(2, 2), (5, 1), (9, 1)],
"scope 1's postings must round-trip sorted by slot with the correct tf"
);
let list_256 = read_posting(&postings, 256, "rust").unwrap();
assert_eq!(
list_256,
vec![(2, 1)],
"scope 256's postings must stay isolated from scope 1's, despite sharing slot 2 and a BE-key prefix"
);
}
tx.commit().unwrap();
}
#[test]
fn posting_block_roundtrips_boundaries() {
assert!(
encode_posting_block(&[]).is_err(),
"encoding an empty entry list must be an error — empty chunks are removed, never written"
);
let one = vec![(5u64, 3u32)];
assert_eq!(
decode_posting_block(&encode_posting_block(&one).unwrap()).unwrap(),
one
);
let tf_gt_one = vec![(0u64, 1u32), (10, 7)];
assert_eq!(
decode_posting_block(&encode_posting_block(&tf_gt_one).unwrap()).unwrap(),
tf_gt_one
);
let max_slot = vec![(u64::MAX, 1u32)];
assert_eq!(
decode_posting_block(&encode_posting_block(&max_slot).unwrap()).unwrap(),
max_slot
);
let equal_slots = vec![(5u64, 1u32), (5, 2)];
assert_eq!(
decode_posting_block(&encode_posting_block(&equal_slots).unwrap()).unwrap(),
equal_slots
);
}
#[test]
fn posting_block_rejects_bad_payloads() {
assert!(
encode_posting_block(&[(2, 1), (1, 1)]).is_err(),
"decreasing slots must be rejected"
);
assert!(
decode_posting_block(&[]).is_err(),
"empty payload must be rejected"
);
assert!(
decode_posting_block(&[0xFF]).is_err(),
"unknown block format must be rejected"
);
let full = encode_posting_block(&[(0, 1), (5, 2), (9, 1)]).unwrap();
for cut in 1..full.len() {
assert!(
decode_posting_block(&full[..cut]).is_err(),
"truncation at byte {cut} must be rejected"
);
}
}
#[test]
fn posting_block_count_matches_decode_len_without_full_decode() {
let blocks: Vec<Vec<(u64, u32)>> = vec![
vec![(0, 1)],
vec![(0, 1), (3, 5), (100, 2)],
vec![(u64::MAX, u32::MAX)],
(0..50)
.map(|i| (i as u64 * 2, (i % 7) as u32 + 1))
.collect(),
];
for entries in blocks {
let payload = encode_posting_block(&entries).unwrap();
let count = posting_block_count(&payload).unwrap();
let decoded = decode_posting_block(&payload).unwrap();
assert_eq!(count, decoded.len() as u64);
}
assert!(
posting_block_count(&[]).is_err(),
"empty payload must be rejected by the count fast path"
);
assert!(
posting_block_count(&[0xFF]).is_err(),
"unknown block format must be rejected by the count fast path"
);
}
#[test]
fn chunked_posting_key_disambiguates_terms_scopes_and_chunks() {
let ab = chunked_posting_key(1, "ab", 0);
let abc = chunked_posting_key(1, "abc", 0);
assert_eq!(ab.len(), 4 + "ab".len() + 4);
assert_eq!(abc.len(), 4 + "abc".len() + 4);
assert_ne!(ab, abc);
let scope1 = chunked_posting_key(1, "rust", 0);
let scope256 = chunked_posting_key(256, "rust", 0);
assert_ne!(scope1, scope256);
let chunk0 = chunked_posting_key(1, "rust", 0);
let chunk1 = chunked_posting_key(1, "rust", 1);
assert!(chunk0 < chunk1);
}
proptest! {
#[test]
fn sorted_posting_block_entries_roundtrip(
mut entries in proptest::collection::vec((0u64..10_000, any::<u32>()), 1..64)
) {
entries.sort_by_key(|entry| entry.0);
prop_assert_eq!(
decode_posting_block(&encode_posting_block(&entries).unwrap()).unwrap(),
entries
);
}
}
#[test]
fn three_docs_sharing_a_term_produce_one_chunk_with_three_entries() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
set_posting(&mut postings, 1, "rust", 2, 1).unwrap();
set_posting(&mut postings, 1, "rust", 5, 1).unwrap();
set_posting(&mut postings, 1, "rust", 9, 1).unwrap();
let keys = term_chunk_keys(&postings, 1, "rust").unwrap();
assert_eq!(keys.len(), 1, "3 small docs must fit in a single chunk");
assert_eq!(
read_posting(&postings, 1, "rust").unwrap(),
vec![(2, 1), (5, 1), (9, 1)]
);
assert_eq!(posting_df(&postings, 1, "rust").unwrap(), 3);
}
tx.commit().unwrap();
}
#[test]
fn a_hot_term_splits_into_multiple_chunks_and_df_sums_across_them() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let n: u64 = 5000;
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
for slot in 0..n {
set_posting(&mut postings, 1, "hot", slot, 1).unwrap();
}
let keys = term_chunk_keys(&postings, 1, "hot").unwrap();
assert!(
keys.len() > 1,
"{n} sequential docs on one term must split into >1 chunk, got {}",
keys.len()
);
let df = posting_df(&postings, 1, "hot").unwrap();
assert_eq!(df, n, "df must equal the total entry count across chunks");
let all = read_posting(&postings, 1, "hot").unwrap();
assert_eq!(all.len(), n as usize);
assert!(
all.windows(2).all(|w| w[0].0 < w[1].0),
"concatenation across chunks must stay slot-ascending"
);
}
tx.commit().unwrap();
}
#[test]
fn updating_one_docs_tf_touches_only_its_covering_chunk() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let n: u64 = 5000;
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
for slot in 0..n {
set_posting(&mut postings, 1, "hot", slot, 1).unwrap();
}
}
tx.commit().unwrap();
let keys_before = {
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
term_chunk_keys(&postings, 1, "hot").unwrap()
};
assert!(
keys_before.len() > 1,
"setup must produce a multi-chunk term"
);
let first_chunk_bytes_before = {
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
postings
.get(keys_before[0].as_slice())
.unwrap()
.unwrap()
.value()
.to_vec()
};
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
set_posting(&mut postings, 1, "hot", n - 1, 7).unwrap();
}
tx.commit().unwrap();
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
let first_chunk_bytes_after = postings
.get(keys_before[0].as_slice())
.unwrap()
.unwrap()
.value()
.to_vec();
assert_eq!(
first_chunk_bytes_before, first_chunk_bytes_after,
"a sibling chunk's stored bytes must be untouched by an update to a different chunk"
);
let all = read_posting(&postings, 1, "hot").unwrap();
assert_eq!(
all.iter().find(|&&(s, _)| s == n - 1).unwrap().1,
7,
"the updated slot's tf must actually change"
);
}
#[test]
fn removing_a_full_chunks_docs_drops_only_that_chunks_key() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let n: u64 = 5000;
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
for slot in 0..n {
set_posting(&mut postings, 1, "hot", slot, 1).unwrap();
}
}
tx.commit().unwrap();
let keys = {
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
term_chunk_keys(&postings, 1, "hot").unwrap()
};
assert!(keys.len() > 1, "setup must produce a multi-chunk term");
let last_key = keys.last().unwrap().clone();
let last_chunk_slots: Vec<u64> = {
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
load_posting_chunk(&postings, last_key.as_slice())
.unwrap()
.into_iter()
.map(|(slot, _)| slot)
.collect()
};
assert!(!last_chunk_slots.is_empty());
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
for slot in &last_chunk_slots {
set_posting(&mut postings, 1, "hot", *slot, 0).unwrap();
}
}
tx.commit().unwrap();
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
let remaining_keys = term_chunk_keys(&postings, 1, "hot").unwrap();
assert_eq!(
remaining_keys.len(),
keys.len() - 1,
"emptying the last chunk must drop exactly its own key"
);
assert!(
!remaining_keys.contains(&last_key),
"the emptied chunk's key must be gone"
);
assert_eq!(
read_posting(&postings, 1, "hot").unwrap().len(),
(n as usize) - last_chunk_slots.len()
);
}
#[test]
fn removing_every_doc_drops_the_whole_term() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
set_posting(&mut postings, 1, "x", 1, 1).unwrap();
set_posting(&mut postings, 1, "x", 2, 1).unwrap();
set_posting(&mut postings, 1, "x", 3, 1).unwrap();
set_posting(&mut postings, 1, "x", 1, 0).unwrap();
set_posting(&mut postings, 1, "x", 2, 0).unwrap();
assert_eq!(read_posting(&postings, 1, "x").unwrap(), vec![(3, 1)]);
set_posting(&mut postings, 1, "x", 3, 0).unwrap();
assert!(
term_chunk_keys(&postings, 1, "x").unwrap().is_empty(),
"the term must fully disappear once its last doc is removed"
);
assert_eq!(read_posting(&postings, 1, "x").unwrap(), vec![]);
assert_eq!(posting_df(&postings, 1, "x").unwrap(), 0);
}
tx.commit().unwrap();
}
#[test]
fn out_of_order_insert_lands_sorted_within_the_covering_chunk() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
set_posting(&mut postings, 1, "late", 10, 1).unwrap();
set_posting(&mut postings, 1, "late", 3, 2).unwrap();
assert_eq!(
read_posting(&postings, 1, "late").unwrap(),
vec![(3, 2), (10, 1)]
);
}
tx.commit().unwrap();
}
#[test]
fn fast_path_insert_leaves_every_non_last_chunks_bytes_untouched() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let n: u64 = 10_000;
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
for slot in 0..n {
set_posting(&mut postings, 1, "hot", slot, 1).unwrap();
}
}
tx.commit().unwrap();
let (keys, non_last_bytes_before) = {
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
let keys = term_chunk_keys(&postings, 1, "hot").unwrap();
let bytes: Vec<Vec<u8>> = keys[..keys.len() - 1]
.iter()
.map(|key| {
postings
.get(key.as_slice())
.unwrap()
.unwrap()
.value()
.to_vec()
})
.collect();
(keys, bytes)
};
assert!(
keys.len() >= 3,
"setup must produce >= 3 chunks, got {}",
keys.len()
);
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
set_posting(&mut postings, 1, "hot", n, 1).unwrap();
}
tx.commit().unwrap();
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
for (i, (key, before)) in keys[..keys.len() - 1]
.iter()
.zip(&non_last_bytes_before)
.enumerate()
{
let after = postings
.get(key.as_slice())
.unwrap()
.unwrap()
.value()
.to_vec();
assert_eq!(
before, &after,
"non-last chunk {i}'s stored bytes must be byte-identical after a fast-path insert"
);
}
assert_eq!(
posting_df(&postings, 1, "hot").unwrap(),
n + 1,
"the fast-path insert must actually land"
);
}
#[test]
fn out_of_order_insert_into_an_earlier_chunk_leaves_the_last_chunk_untouched() {
let dir = tempfile::tempdir().unwrap();
let db = Database::create(dir.path().join("t.redb")).unwrap();
let n: u64 = 6000; let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
for i in 0..n {
set_posting(&mut postings, 1, "hot", i * 2, 1).unwrap();
}
}
tx.commit().unwrap();
let (keys, first_chunk, last_bytes_before) = {
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
let keys = term_chunk_keys(&postings, 1, "hot").unwrap();
let first_chunk = load_posting_chunk(&postings, keys[0].as_slice()).unwrap();
let last_bytes = postings
.get(keys.last().unwrap().as_slice())
.unwrap()
.unwrap()
.value()
.to_vec();
(keys, first_chunk, last_bytes)
};
assert!(
keys.len() >= 2,
"setup must produce >= 2 chunks, got {}",
keys.len()
);
let missing = first_chunk[0].0 + 1;
assert!(missing < first_chunk.last().unwrap().0);
let tx = db.begin_write().unwrap();
{
let mut postings = tx.open_table(POSTINGS).unwrap();
set_posting(&mut postings, 1, "hot", missing, 5).unwrap();
}
tx.commit().unwrap();
let tx = db.begin_read().unwrap();
let postings = tx.open_table(POSTINGS).unwrap();
let keys_after = term_chunk_keys(&postings, 1, "hot").unwrap();
assert_eq!(
keys_after.len(),
keys.len(),
"a covering-chunk insert must not add or remove chunk keys"
);
let last_bytes_after = postings
.get(keys.last().unwrap().as_slice())
.unwrap()
.unwrap()
.value()
.to_vec();
assert_eq!(
last_bytes_before, last_bytes_after,
"the LAST chunk's stored bytes must be untouched by an insert covered by an earlier chunk"
);
let all = read_posting(&postings, 1, "hot").unwrap();
assert_eq!(all.len(), n as usize + 1);
assert!(
all.windows(2).all(|w| w[0].0 < w[1].0),
"the out-of-order insert must land in sorted position"
);
assert!(
all.contains(&(missing, 5)),
"the inserted (slot, tf) must be present with its tf"
);
}
}