From 895ea543a96b9451c942e6fef8197c0e875fa67c Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Fri, 22 May 2026 13:40:43 +0200 Subject: [PATCH] fix(core): commit/reload barrier before dedup GC reference count cleanup_unused_content decides whether to delete a deduplicated blob by running a Tantivy Count of envelopes referencing each content_hash. The searcher it used reflected only the committed index state at the time it was built, so an envelope that shared a content hash but was still sitting uncommitted in the IndexWriter buffer (for example added by the background ingest task before the delete operation acquired the writer lock) was invisible to the count. The count read 0 and a still-referenced blob was deleted, permanently 404ing that envelope's download-message. Pass the locked IndexWriter into cleanup_unused_content and fatal_commit it immediately before creating the searcher. create_searcher already reloads the reader, so the Count is now evaluated against a fully committed, freshly-reloaded index state. The barrier is local to the GC path and self-contained, independent of what the caller committed. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/store/tantivy/envelope.rs | 27 ++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/core/src/store/tantivy/envelope.rs b/crates/core/src/store/tantivy/envelope.rs index 1ed38ab..1981e30 100644 --- a/crates/core/src/store/tantivy/envelope.rs +++ b/crates/core/src/store/tantivy/envelope.rs @@ -733,7 +733,11 @@ impl IndexManager { .await?; if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() { - self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?; + self.cleanup_unused_content( + &mut writer, + eml_content_hashes, + attachments_content_hashes, + )?; } Ok(()) } @@ -772,7 +776,11 @@ impl IndexManager { .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() { - self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?; + self.cleanup_unused_content( + &mut writer, + eml_content_hashes, + attachments_content_hashes, + )?; } Ok(()) } @@ -817,9 +825,18 @@ impl IndexManager { fn cleanup_unused_content( &self, + writer: &mut IndexWriter, eml_content_hashes: HashSet, attachments_content_hashes: HashSet, ) -> BichonResult<()> { + // Reference-count barrier: commit the writer and reload the reader so the + // `Count` below is evaluated against a fully committed, freshly-reloaded + // index state. Without this, an envelope that shares a content hash but + // is still sitting uncommitted in the writer buffer (e.g. added by the + // background ingest task before this delete acquired the writer lock) + // would be invisible to the searcher, the count would read 0, and a + // still-referenced blob would be deleted. + fatal_commit(writer); let searcher = self.create_searcher()?; let fields = SchemaTools::email_fields(); let mut eml: HashSet = HashSet::new(); @@ -900,7 +917,11 @@ impl IndexManager { .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; if !eml_content_hashes.is_empty() || !attachments_content_hashes.is_empty() { - self.cleanup_unused_content(eml_content_hashes, attachments_content_hashes)?; + self.cleanup_unused_content( + &mut writer, + eml_content_hashes, + attachments_content_hashes, + )?; } Ok(())