Skip to content

Commit 99fbe1a

Browse files
fix: make text index reinserts atomic (#234)
1 parent 8fdc30b commit 99fbe1a

3 files changed

Lines changed: 146 additions & 20 deletions

File tree

src/git/versioned_store/namespaced.rs

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2680,25 +2680,38 @@ where
26802680
M: MetadataBackend,
26812681
{
26822682
/// Insert or update `(id, text)`. The text is split through the
2683-
/// configured chunker; each chunk is embedded and stored under its own
2684-
/// chunk-id. Previous chunks for this `id` are removed first so that a
2685-
/// re-insert with fewer chunks doesn't leak stale ones.
2683+
/// configured chunker; replacement chunks are embedded first, then previous
2684+
/// chunks for this `id` are removed so failed embeds leave the old document
2685+
/// searchable. A successful empty chunk set removes the document.
26862686
pub fn insert(&mut self, id: &[u8], text: &str) -> Result<(), TextIndexError> {
2687-
// Drop any previous chunks for this doc_id.
2688-
self.delete_chunks_for_doc(id);
2689-
26902687
let chunks = self.chunker.split(text);
26912688
if chunks.is_empty() {
2689+
self.delete_chunks_for_doc(id);
26922690
return Ok(());
26932691
}
2692+
if self.embedder.dim() == 0 {
2693+
return Err(TextIndexError::Proximity(ProximityError::ZeroDim));
2694+
}
2695+
let mut replacements = Vec::with_capacity(chunks.len());
2696+
for (chunk_idx, chunk_text) in chunks.iter().enumerate() {
2697+
let vec = self.embedder.embed(chunk_text)?;
2698+
if vec.len() as u16 != self.embedder.dim() {
2699+
return Err(TextIndexError::DimensionMismatch {
2700+
stored: self.embedder.dim(),
2701+
got: vec.len() as u16,
2702+
});
2703+
}
2704+
let chunk_id = make_chunk_id(id, chunk_idx as u32);
2705+
replacements.push((chunk_id, vec));
2706+
}
2707+
2708+
self.delete_chunks_for_doc(id);
26942709
let idx = self
26952710
.store
26962711
.proximity_indexes
26972712
.get_mut(&self.inner_idx_key)
26982713
.expect("inner proximity index must be loaded");
2699-
for (chunk_idx, chunk_text) in chunks.iter().enumerate() {
2700-
let vec = self.embedder.embed(chunk_text)?;
2701-
let chunk_id = make_chunk_id(id, chunk_idx as u32);
2714+
for (chunk_id, vec) in replacements {
27022715
idx.insert(chunk_id, vec)?;
27032716
}
27042717
self.store

src/proximity/text_index.rs

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -446,22 +446,23 @@ impl<const N: usize, E: Embedder, S: NodeStorage<N>> TextIndex<N, E, S> {
446446
/// `Chunker` into one or more chunks; each chunk is embedded and stored
447447
/// under its own chunk-id.
448448
///
449-
/// **Re-insert semantics:** existing chunks for this `id` are removed
450-
/// first, so that switching to a chunker that produces fewer chunks
451-
/// doesn't leak stale ones.
449+
/// **Re-insert semantics:** replacement chunks are embedded first, then
450+
/// existing chunks for this `id` are removed, so a failed embed leaves the
451+
/// previous document searchable. A successful empty chunk set removes the
452+
/// document from the index.
452453
pub fn insert(&mut self, id: &[u8], text: &str) -> Result<(), TextIndexError> {
453-
// Drop any previous chunks for this doc_id so the new chunk count
454-
// overwrites cleanly (handles "re-chunk after switching chunkers"
455-
// as well as plain upserts).
456-
self.delete_chunks_for_doc(id);
457-
458454
let chunks = self.chunker.split(text);
459455
if chunks.is_empty() {
460456
// The chunker explicitly opted out (e.g. an empty document under
461457
// LineChunker). Treat as "don't index" rather than as an error —
462458
// matches the cascade transformer's `None` semantics.
459+
self.delete_chunks_for_doc(id);
463460
return Ok(());
464461
}
462+
if self.embedder.dim() == 0 {
463+
return Err(TextIndexError::Proximity(ProximityError::ZeroDim));
464+
}
465+
let mut replacements = Vec::with_capacity(chunks.len());
465466
for (idx, chunk_text) in chunks.iter().enumerate() {
466467
let vec = self.embedder.embed(chunk_text)?;
467468
if vec.len() != usize::from(self.embedder.dim()) {
@@ -471,6 +472,11 @@ impl<const N: usize, E: Embedder, S: NodeStorage<N>> TextIndex<N, E, S> {
471472
}));
472473
}
473474
let chunk_id = make_chunk_id(id, idx as u32);
475+
replacements.push((chunk_id, vec));
476+
}
477+
478+
self.delete_chunks_for_doc(id);
479+
for (chunk_id, vec) in replacements {
474480
self.inner.insert(chunk_id, vec)?;
475481
}
476482
Ok(())
@@ -604,6 +610,42 @@ mod tests {
604610
TextIndexConfig::new(HashEmbedder::new(dim, 0))
605611
}
606612

613+
#[derive(Debug, Clone)]
614+
struct FailsOnNeedleEmbedder {
615+
inner: HashEmbedder,
616+
needle: &'static str,
617+
}
618+
619+
impl FailsOnNeedleEmbedder {
620+
fn new(dim: u16, needle: &'static str) -> Self {
621+
Self {
622+
inner: HashEmbedder::new(dim, 0),
623+
needle,
624+
}
625+
}
626+
}
627+
628+
impl Embedder for FailsOnNeedleEmbedder {
629+
fn id(&self) -> &str {
630+
self.inner.id()
631+
}
632+
633+
fn version(&self) -> &str {
634+
self.inner.version()
635+
}
636+
637+
fn dim(&self) -> u16 {
638+
self.inner.dim()
639+
}
640+
641+
fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
642+
if text.contains(self.needle) {
643+
return Err(EmbedError::Failure("forced insert failure".to_string()));
644+
}
645+
self.inner.embed(text)
646+
}
647+
}
648+
607649
#[test]
608650
fn insert_and_search_finds_exact_match() {
609651
let storage = InMemoryNodeStorage::<32>::new();
@@ -623,6 +665,23 @@ mod tests {
623665
);
624666
}
625667

668+
#[test]
669+
fn failed_reinsert_preserves_existing_document() {
670+
let storage = InMemoryNodeStorage::<32>::new();
671+
let mut idx = TextIndex::new(
672+
storage,
673+
TextIndexConfig::new(FailsOnNeedleEmbedder::new(8, "fail-insert")),
674+
);
675+
idx.insert(b"doc:1", "stable text").unwrap();
676+
677+
let err = idx.insert(b"doc:1", "please fail-insert").unwrap_err();
678+
assert!(err.to_string().contains("forced insert failure"));
679+
680+
let hits = idx.search("stable text", 1).unwrap();
681+
assert_eq!(hits[0].id, b"doc:1".to_vec());
682+
assert!(hits[0].score < 1e-4);
683+
}
684+
626685
#[test]
627686
fn delete_removes_document_from_search() {
628687
let storage = InMemoryNodeStorage::<32>::new();

tests/text_index_namespaced.rs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,54 @@ mod common;
2525

2626
use common::setup_repo_and_dataset;
2727
use prollytree::git::versioned_store::FileNamespacedKvStore;
28-
use prollytree::proximity::{HashEmbedder, TextIndexConfig, TextIndexError};
28+
use prollytree::proximity::{EmbedError, Embedder, HashEmbedder, TextIndexConfig, TextIndexError};
2929

3030
const N: usize = 32;
3131

3232
fn cfg(dim: u16, seed: u64) -> TextIndexConfig<HashEmbedder> {
3333
TextIndexConfig::new(HashEmbedder::new(dim, seed))
3434
}
3535

36+
#[derive(Debug, Clone)]
37+
struct FailsOnNeedleEmbedder {
38+
inner: HashEmbedder,
39+
needle: &'static str,
40+
}
41+
42+
impl FailsOnNeedleEmbedder {
43+
fn new(dim: u16, seed: u64, needle: &'static str) -> Self {
44+
Self {
45+
inner: HashEmbedder::new(dim, seed),
46+
needle,
47+
}
48+
}
49+
}
50+
51+
impl Embedder for FailsOnNeedleEmbedder {
52+
fn id(&self) -> &str {
53+
self.inner.id()
54+
}
55+
56+
fn version(&self) -> &str {
57+
self.inner.version()
58+
}
59+
60+
fn dim(&self) -> u16 {
61+
self.inner.dim()
62+
}
63+
64+
fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
65+
if text.contains(self.needle) {
66+
return Err(EmbedError::Failure("forced insert failure".to_string()));
67+
}
68+
self.inner.embed(text)
69+
}
70+
}
71+
72+
fn failing_cfg(dim: u16, seed: u64) -> TextIndexConfig<FailsOnNeedleEmbedder> {
73+
TextIndexConfig::new(FailsOnNeedleEmbedder::new(dim, seed, "fail-insert"))
74+
}
75+
3676
#[test]
3777
fn text_index_insert_search_basic() {
3878
let (_temp, dataset) = setup_repo_and_dataset();
@@ -50,6 +90,22 @@ fn text_index_insert_search_basic() {
5090
assert!(hits[0].score < 1e-4);
5191
}
5292

93+
#[test]
94+
fn text_index_failed_reinsert_preserves_existing_document() {
95+
let (_temp, dataset) = setup_repo_and_dataset();
96+
let mut store = FileNamespacedKvStore::<N>::init(&dataset).unwrap();
97+
let mut personal = store.namespace("personal");
98+
let mut docs = personal.text_index("docs", failing_cfg(8, 0)).unwrap();
99+
100+
docs.insert(b"doc:1", "stable text").unwrap();
101+
let err = docs.insert(b"doc:1", "please fail-insert").unwrap_err();
102+
assert!(err.to_string().contains("forced insert failure"));
103+
104+
let hits = docs.search("stable text", 1).unwrap();
105+
assert_eq!(hits[0].id, b"doc:1".to_vec());
106+
assert!(hits[0].score < 1e-4);
107+
}
108+
53109
#[test]
54110
fn text_index_survives_commit_and_reopen() {
55111
let (_temp, dataset) = setup_repo_and_dataset();
@@ -79,8 +135,6 @@ fn text_index_survives_commit_and_reopen() {
79135
#[test]
80136
fn reopen_with_different_embedder_id_returns_mismatch() {
81137
// Different embedder family at re-open time → EmbedderMismatch.
82-
use prollytree::proximity::{EmbedError, Embedder};
83-
84138
struct DifferentFamily(HashEmbedder);
85139
impl Embedder for DifferentFamily {
86140
fn id(&self) -> &str {

0 commit comments

Comments
 (0)