1use std::collections::HashSet;
5use std::sync::Arc;
6
7use anyhow::{ensure, Context, Result};
8use serde::Serialize;
9
10use super::{mutation::Mutation, WarmCore};
11use crate::core::documents::StoredDocument;
12use crate::core::storage::StagedArena;
13
14struct StagingDir {
16 path: std::path::PathBuf,
17 armed: std::cell::Cell<bool>,
18}
19
20impl StagingDir {
21 fn new(path: std::path::PathBuf) -> Self {
22 Self {
23 path,
24 armed: std::cell::Cell::new(true),
25 }
26 }
27 fn path(&self) -> &std::path::Path {
28 &self.path
29 }
30 fn disarm(&self) {
31 self.armed.set(false);
32 }
33}
34
35impl Drop for StagingDir {
36 fn drop(&mut self) {
37 if self.armed.get() {
38 let _ = std::fs::remove_dir_all(&self.path);
41 }
42 }
43}
44
45struct Snapshot {
46 checkpoint: (String, usize),
47 mutations: Vec<Mutation>,
48 documents: Vec<Arc<StoredDocument>>,
49 text: Option<Arc<crate::core::documents::TextSegment>>,
52 document_checkpoint: Option<Vec<u8>>,
55}
56
57#[derive(Serialize)]
58enum DocumentMutation<'a> {
59 Document(crate::core::documents::DocumentWithText<'a>),
60}
61
62impl WarmCore {
63 pub fn compact(&self) -> Result<()> {
64 self.compact_impl(|| {}, || {}, false)
65 }
66
67 pub fn compact_scrubbing(&self) -> Result<()> {
76 self.sync_text_segment()?;
77 self.compact_impl(|| {}, || {}, true)
78 }
79
80 fn canonicalize_derived_state(&self, documents: bool) -> Result<()> {
86 if documents {
87 self.canonicalize_documents()?;
88 }
89 if let Some(snapshot) = self.vector_index.get() {
90 let graph_min_rows = if snapshot.has_graph() { 0 } else { usize::MAX };
91 self.rebuild_vector_index_waiting(graph_min_rows)?;
92 }
93 let rebuild_dense = {
94 let mut documents = self.documents.write();
95 documents.discard_stale_dense_index();
96 documents.dense_index_recommended()
97 };
98 if rebuild_dense {
99 self.train_dense_index()?;
100 }
101 Ok(())
102 }
103
104 fn canonicalize_documents(&self) -> Result<()> {
106 self.hydrate_documents()?;
107 let _transaction = self.mutation_gate.maintenance();
108 self.documents
109 .write()
110 .rebuild_canonical(&self.storage_path.join("documents.text"))
111 }
112
113 fn compact_impl(
114 &self,
115 after_snapshot: impl FnOnce(),
116 before_publication: impl FnOnce(),
117 scrub: bool,
118 ) -> Result<()> {
119 let _compaction = self.compaction.lock();
120 let canonical_first = scrub && self.takes_checkpoint();
124 if canonical_first {
125 self.canonicalize_documents()?;
126 }
127 let staging = StagingDir::new(self.arena.new_generation_dir()?);
128 let staged_name = staging
129 .path()
130 .file_name()
131 .map(|name| name.to_string_lossy().into_owned())
132 .context("staging generation has no name")?;
133 let snapshot = self.compaction_snapshot(&staged_name)?;
134 let (generation, mut cursor) = snapshot.checkpoint;
135 after_snapshot();
136 let mut arena = StagedArena::new(staging.path(), Some(&self.arena))?;
137 if let Some(bytes) = &snapshot.document_checkpoint {
138 crate::core::durable_file::atomic_write(
140 &staging.path().join(super::image::FILE),
141 bytes,
142 )?;
143 }
144 for mutation in snapshot.mutations {
145 self.write_compaction_frame(&mut arena, &[mutation])?;
146 }
147 let mut payload = Vec::new();
148 for document in &snapshot.documents {
149 {
152 let store = self.documents();
153 if store.is_deleted(document) {
154 continue;
155 }
156 let binary = match self.journal {
157 super::journal::JournalFormat::Binary => {
158 super::journal::binary_document_payload(document, snapshot.text.as_deref())?
159 }
160 super::journal::JournalFormat::Json => None,
161 };
162 if let Some(binary) = binary {
163 payload = binary;
164 } else {
165 payload.clear();
167 payload.extend_from_slice(super::mutation::TRANSACTION_MAGIC);
168 serde_json::to_writer(
169 &mut payload,
170 &[DocumentMutation::Document(
171 crate::core::documents::DocumentWithText {
172 document,
173 segment: snapshot.text.as_deref(),
174 },
175 )],
176 )?;
177 }
178 }
179 arena.write_slice(&self.maybe_encrypt(&payload)?)?;
180 }
181 let mut prepared = arena.finish()?.prepare_compacted()?;
182 for _ in 0..2 {
183 let end = {
184 let _transaction = self.mutation_gate.maintenance();
185 self.compaction_end(&generation)?
186 };
187 self.arena
188 .replay_compaction_tail(&mut prepared, &mut cursor, end)?;
189 }
190 before_publication();
191 let _transaction = self.mutation_gate.maintenance();
192 let end = self.compaction_end(&generation)?;
193 self.arena
194 .replay_compaction_tail(&mut prepared, &mut cursor, end)?;
195 staging.disarm();
196 self.arena.replace_with_compacted(prepared, scrub)?;
197 self.revision
198 .fetch_add(1, std::sync::atomic::Ordering::Release);
199 let checkpoint = self.arena.checkpoint();
204 _transaction.release();
205 crate::core::error::after_commit(
206 "compaction index persistence",
207 self.persist_indices_detached(checkpoint),
208 )?;
209 crate::core::error::after_commit(
210 "compaction index persistence",
211 self.persist_indices(&self.shards.read()),
212 )?;
213 if let Some(audit) = &self.audit {
214 crate::core::error::after_commit(
215 "compaction audit append",
216 audit.record(
217 crate::core::audit::AuditOp::Compact,
218 "",
219 self.sign_fn().as_deref(),
220 ),
221 )?;
222 }
223 if scrub {
224 crate::core::error::after_commit(
227 "compaction derived-state reconciliation",
228 self.canonicalize_derived_state(!canonical_first),
229 )?;
230 }
231 Ok(())
232 }
233
234 fn compaction_snapshot(&self, staged_name: &str) -> Result<Snapshot> {
235 self.hydrate_documents()?;
238 let _transaction = self.mutation_gate.maintenance();
239 let shards = self.shards.read();
240 let shard_vectors = shards.count();
241 let mut vectors = Vec::new();
242 let composite_ids: HashSet<_> = self
243 .composite_memory
244 .as_ref()
245 .map(|memory| {
246 memory
247 .inner()
248 .all_vectors()
249 .into_iter()
250 .map(|(_, id, _)| id)
251 .collect()
252 })
253 .unwrap_or_default();
254 shards.try_for_each_shard(|shard| {
255 for (id, vector) in shard.vectors.read().iter() {
256 if !id.starts_with(crate::core::documents::CHUNK_PREFIX)
257 && !composite_ids.contains(id)
258 {
259 vectors.push((id.clone(), vector.clone()));
260 }
261 }
262 Ok(())
263 })?;
264 vectors.sort_unstable_by(|a, b| a.0.cmp(&b.0));
267 let atoms = self
268 .atom_memory
269 .as_ref()
270 .map(|memory| memory.inner().all_vectors());
271 let atom_ids: HashSet<_> = atoms
272 .iter()
273 .flatten()
274 .map(|(_, id, _)| id.as_str())
275 .collect();
276 let mut mutations = Vec::new();
277 for (id, vector) in vectors {
278 let absent_atom =
279 (atoms.is_some() && !atom_ids.contains(id.as_str())).then(|| id.clone());
280 mutations.push(Mutation::Vector { id, vector });
281 if let Some(id) = absent_atom {
282 anyhow::ensure!(
283 self.schema >= crate::core::format::MEANING_MAINTENANCE_SCHEMA_VERSION,
284 "preserving scoped meaning state requires schema 5; use warm-format upgrade"
285 );
286 mutations.push(Mutation::DeleteAtom { id });
287 }
288 }
289 drop(atom_ids);
290 let (documents, text, document_checkpoint) = {
291 let store = self.documents.read();
292 let checkpoint = if shard_vectors == 0 {
293 self.encode_checkpoint(&store, staged_name)?
294 } else {
295 None
296 };
297 let documents = if checkpoint.is_some() {
298 Vec::new()
299 } else {
300 store.shared_values().collect()
301 };
302 (documents, store.text_segment(), checkpoint)
303 };
304 mutations.extend(self.graph.snapshot().into_iter().map(Mutation::Relation));
305 mutations.extend(
306 self.graph
307 .type_snapshot()
308 .into_iter()
309 .map(Mutation::RelationType),
310 );
311 if let Some(atoms) = atoms {
312 mutations.extend(
313 atoms
314 .into_iter()
315 .map(|(_, id, vector)| Mutation::Atom { id, vector }),
316 );
317 }
318 if let Some(composites) = &self.composite_memory {
319 mutations.extend(
320 composites
321 .inner()
322 .all_vectors()
323 .into_iter()
324 .map(|(_, id, vector)| Mutation::Composite { id, vector }),
325 );
326 }
327 if let Some(triples) = &self.triple_store {
328 mutations.extend(triples.snapshot().into_iter().map(Mutation::StoredTriple));
329 }
330 if let Some(rules) = &self.rule_store {
331 mutations.extend(rules.all_rules().into_iter().map(Mutation::Rule));
332 }
333 mutations.extend(
334 self.derived
335 .read()
336 .iter()
337 .map(|(id, children)| Mutation::Derived {
338 id: id.clone(),
339 children: children.clone(),
340 }),
341 );
342 Ok(Snapshot {
343 checkpoint: self.arena.checkpoint(),
344 mutations,
345 documents,
346 text,
347 document_checkpoint,
348 })
349 }
350
351 fn write_compaction_frame(
352 &self,
353 arena: &mut StagedArena,
354 mutations: &[Mutation],
355 ) -> Result<()> {
356 let payload = self.transaction_payload(mutations)?;
357 arena.write_slice(&self.maybe_encrypt(&payload)?)?;
358 Ok(())
359 }
360
361 fn compaction_end(&self, generation: &str) -> Result<usize> {
362 let checkpoint = self.arena.checkpoint();
363 ensure!(
364 checkpoint.0 == generation,
365 "arena generation changed during compaction"
366 );
367 Ok(checkpoint.1)
368 }
369
370 fn persist_indices_detached(&self, checkpoint: (String, usize)) -> Result<()> {
373 let encoded = self.encode_indices(&self.shards.read(), checkpoint)?;
374 self.write_indices(encoded)
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use std::sync::mpsc;
382 use std::time::Duration;
383
384 use crate::core::documents::{DocumentInput, SearchOptions};
385 use crate::core::WarmConfig;
386 use serde_json::json;
387
388 fn config() -> WarmConfig {
389 super::super::fixture_config(2)
390 }
391
392 fn configurations() -> impl Iterator<Item = WarmConfig> {
393 [false, true]
394 .into_iter()
395 .filter(|encrypted| !encrypted || cfg!(feature = "security"))
396 .map(|encrypted| {
397 let mut config = config();
398 config.security.encryption_enabled = encrypted;
399 if encrypted {
400 config.security.encryption_passphrase =
401 Some("compaction-replay-fixture".into());
402 }
403 config
404 })
405 }
406
407 fn document(id: &str, version: &str) -> DocumentInput {
408 serde_json::from_value(json!({
409 "id": id, "version": version, "text": "needle durable source",
410 "sourceUri": format!("source:{version}"), "metadata": {"version": version},
411 "embeddings": [[1.0, 0.0]],
412 }))
413 .unwrap()
414 }
415
416 #[test]
417 fn scrubbing_releases_deleted_dense_owners_even_below_training_threshold() -> Result<()> {
418 for count in [128, 129] {
419 for config in configurations() {
420 let directory = tempfile::tempdir()?;
421 let path = directory.path().display().to_string();
422 let core = WarmCore::new(1024, Some(path.clone()), Some(config.clone()))?;
423 core.memorize_documents(
424 (0..count)
425 .map(|i| document(&format!("row-{i:03}"), "1"))
426 .collect(),
427 )?;
428 core.train_document_index()?;
429 let owner = Arc::clone(core.documents.read().get("row-000").unwrap());
430 core.delete_document("row-000")?;
431 assert!(Arc::strong_count(&owner) > 1);
432 core.compact_scrubbing()?;
433 assert_eq!(Arc::strong_count(&owner), 1);
434 assert_eq!(core.documents.read().dense_index_trained(), count > 128);
435 drop(core);
436 let reopened = WarmCore::new(1024, Some(path), Some(config))?;
437 assert!(!reopened.has_document("row-000"));
438 assert_eq!(reopened.document_count(), count - 1);
439 }
440 }
441 Ok(())
442 }
443
444 #[test]
445 fn published_compaction_failure_reports_a_committed_outcome_and_reopens() -> Result<()> {
446 let dir = tempfile::tempdir()?;
447 let path = Some(dir.path().display().to_string());
448 let core = WarmCore::new(256, path.clone(), Some(config()))?;
449 core.memorize_document(document("kept", "v1"))?;
450 let before = core.revision();
451 let error = core
452 .compact_impl(
453 || {},
454 || {
455 std::fs::create_dir(dir.path().join("indices.json")).unwrap();
456 },
457 false,
458 )
459 .unwrap_err();
460 assert!(error.is::<crate::CommittedMutationError>(), "{error:#}");
461 assert!(core.revision() != before);
462 assert!(core.has_document("kept"));
463 std::fs::remove_dir(dir.path().join("indices.json"))?;
464 drop(core);
465 let core = WarmCore::new(256, path, Some(config()))?;
466 assert_eq!(
467 core.get_document("kept").unwrap().text.as_deref(),
468 Some("needle durable source")
469 );
470 core.compact()?;
471 Ok(())
472 }
473
474 fn seed(core: &WarmCore) -> Result<()> {
475 core.memorize_document(document("replace", "old"))?;
476 core.memorize_document(document("delete", "old"))?;
477 core.memorize("vector".into(), core.encode_text("original"))?;
478 core.memorize("remove".into(), core.encode_text("removed"))?;
479 core.add_relation(&serde_json::from_value(json!({
480 "sourceId": "a", "relationType": "knows", "targetId": "b",
481 "properties": null, "validFrom": 0, "validTo": 0,
482 }))?)?;
483 Ok(())
484 }
485
486 fn mutate(core: &WarmCore) -> Result<()> {
487 core.memorize_documents(vec![document("replace", "new"), document("insert", "new")])?;
488 core.delete_document("delete")?;
489 core.memorize("vector".into(), core.encode_text("replacement"))?;
490 core.delete("remove")?;
491 core.remove_relation("a", "knows", "b")?;
492 Ok(())
493 }
494
495 fn assert_current(core: &WarmCore) -> Result<serde_json::Value> {
496 let options = SearchOptions {
497 embedding: Some(vec![1.0, 0.0]),
498 source_uri: Some("source:new".into()),
499 filter: Some(json!({"version": "new"})),
500 ..Default::default()
501 };
502 let results = core.search_documents("needle", &options)?;
503 assert_eq!(results.len(), 2);
504 assert!(results.iter().all(|r| r.version == "new"
505 && r.source_uri.as_deref() == Some("source:new")
506 && r.semantic_score == Some(1.0)));
507 assert_eq!(core.relation_count(), 0);
508 assert_eq!(core.vector_count(), 3);
509 assert_eq!(
510 core.query(&core.encode_text("replacement"), 1)[0].id,
511 "vector"
512 );
513 Ok(serde_json::to_value(results)?)
514 }
515
516 #[test]
517 fn compaction_replays_acknowledged_writes_and_preserves_batch_frames() -> Result<()> {
518 for config in configurations() {
519 let directory = tempfile::tempdir()?;
520 let path = directory.path().display().to_string();
521 let core = Arc::new(WarmCore::new(
522 1024,
523 Some(path.clone()),
524 Some(config.clone()),
525 )?);
526 seed(&core)?;
527 let writer_core = Arc::clone(&core);
528 let (start_tx, start_rx) = mpsc::channel();
529 let (done_tx, done_rx) = mpsc::channel();
530 let writer = std::thread::spawn(move || -> Result<()> {
531 start_rx.recv()?;
532 mutate(&writer_core)?;
533 assert_current(&writer_core)?;
534 done_tx.send(())?;
535 Ok(())
536 });
537 let compacted = core.compact_impl(
538 || {
539 start_tx.send(()).unwrap();
540 done_rx
541 .recv_timeout(Duration::from_secs(5))
542 .expect("compaction snapshot blocked mutations or queries");
543 },
544 || {},
545 false,
546 );
547 writer.join().unwrap()?;
548 compacted?;
549 let expected = assert_current(&core)?;
550 let mut offset = 0;
551 let mut batch_frames = 0;
552 while offset < core.arena.stats().used_bytes {
553 let (data, _) = core.arena_read_frame(offset)?;
554 let operations = super::super::journal::decode(&data)?;
555 if operations.len() == 2 {
556 batch_frames += 1;
557 assert!(operations
558 .iter()
559 .all(|op| matches!(op, Mutation::Document(_))));
560 }
561 offset = core.arena.next_offset(offset)?;
562 }
563 assert_eq!(batch_frames, 1);
564 drop(core);
565 let restored = WarmCore::new(1024, Some(path), Some(config))?;
566 assert_eq!(assert_current(&restored)?, expected);
567 }
568 Ok(())
569 }
570
571 #[test]
572 fn compaction_snapshot_shares_document_payloads_after_replacement() -> Result<()> {
573 let directory = tempfile::tempdir()?;
574 let core = WarmCore::new(
575 1024,
576 Some(directory.path().display().to_string()),
577 Some(config()),
578 )?;
579 core.memorize_document(document("replace", "old"))?;
580 let snapshot = core.compaction_snapshot("gen-test")?;
581 let owner = core.documents.read().shared_values().next().unwrap();
582 assert!(Arc::ptr_eq(&owner, &snapshot.documents[0]));
583 let dense_pointer = owner.chunks[0].embedding.as_ref().unwrap().as_ptr();
584 core.memorize_document(document("replace", "new"))?;
585 assert_eq!(snapshot.documents[0].version, "old");
586 assert_eq!(
587 snapshot.documents[0].chunks[0]
588 .embedding
589 .as_ref()
590 .unwrap()
591 .as_ptr(),
592 dense_pointer
593 );
594 Ok(())
595 }
596
597 #[test]
598 fn compaction_replay_crash_child() -> Result<()> {
599 let Ok(root) = crate::compatibility::var("WARM_TEST_CORE_REPLAY_ROOT") else {
600 return Ok(());
601 };
602 let encrypted =
603 crate::compatibility::var("WARM_TEST_CORE_REPLAY_ENCRYPTED").as_deref() == Ok("true");
604 let config = configurations()
605 .find(|config| config.security.encryption_enabled == encrypted)
606 .unwrap();
607 let core = WarmCore::new(1024, Some(root), Some(config))?;
608 seed(&core)?;
609 core.compact_impl(|| mutate(&core).unwrap(), || {}, false)
610 }
611
612 #[test]
613 fn compaction_replayed_writes_survive_each_publication_interruption() -> Result<()> {
614 for config in configurations() {
615 for phase in ["staged", "published", "mapped"] {
616 let directory = tempfile::tempdir()?;
617 let output = std::process::Command::new(std::env::current_exe()?)
618 .args([
619 "--exact",
620 "core::engine::compaction::tests::compaction_replay_crash_child",
621 ])
622 .env("WARM_TEST_CORE_REPLAY_ROOT", directory.path())
623 .env(
624 "WARM_TEST_CORE_REPLAY_ENCRYPTED",
625 config.security.encryption_enabled.to_string(),
626 )
627 .env("WARM_TEST_COMPACTION_PHASE", phase)
628 .output()?;
629 assert_eq!(output.status.code(), Some(73), "{phase}: {output:?}");
630 let core = WarmCore::new(
631 1024,
632 Some(directory.path().display().to_string()),
633 Some(config.clone()),
634 )?;
635 assert_current(&core)?;
636 core.memorize_document(document("after-recovery", "recovered"))?;
637 core.compact()?;
638 }
639 }
640 Ok(())
641 }
642
643 fn lexical(id: &str, version: &str, text: &str) -> DocumentInput {
644 serde_json::from_value(json!({"id": id, "version": version, "text": text})).unwrap()
645 }
646
647 fn lexical_options() -> crate::core::documents::IngestOptions {
648 crate::core::documents::IngestOptions {
649 encode: crate::core::documents::EncodeMode::Lexical,
650 ..Default::default()
651 }
652 }
653
654 fn lexical_answers(core: &WarmCore) -> Result<Vec<String>> {
656 let mut out = Vec::new();
657 for query in ["alpha", "beta gamma", "delta", "after"] {
658 for hit in core.search_documents(query, &SearchOptions::default())? {
659 out.push(format!(
660 "{query}:{}:{}:{:x}",
661 hit.document_id,
662 hit.version,
663 hit.score.to_bits()
664 ));
665 }
666 }
667 Ok(out)
668 }
669
670 #[test]
671 fn checkpoint_compaction_crash_child() -> Result<()> {
672 let Ok(root) = crate::compatibility::var("WARM_TEST_CHECKPOINT_ROOT") else {
673 return Ok(());
674 };
675 let encrypted =
676 crate::compatibility::var("WARM_TEST_CHECKPOINT_ENCRYPTED").as_deref() == Ok("true");
677 let mut config = configurations()
678 .find(|config| config.security.encryption_enabled == encrypted)
679 .unwrap();
680 config.embedding_space = None;
681 let core = WarmCore::new(1024, Some(root), Some(config))?;
683 let options = lexical_options();
684 core.compact_impl(
685 || {
686 core.memorize_documents_with(vec![lexical("replace", "2", "beta beta")], &options)
687 .unwrap();
688 core.delete_document("delete").unwrap();
689 },
690 || {},
691 false,
692 )
693 }
694
695 #[test]
698 fn checkpoint_compaction_survives_each_publication_interruption() -> Result<()> {
699 for config in configurations() {
700 let mut config = config;
701 config.embedding_space = None;
702 let reference = {
703 let directory = tempfile::tempdir()?;
704 let mut plain = config.clone();
705 plain.document_checkpoint.enabled = false;
706 let core = WarmCore::new(
707 1024,
708 Some(directory.path().display().to_string()),
709 Some(plain),
710 )?;
711 let options = lexical_options();
712 core.memorize_documents_with(
713 vec![
714 lexical("keep", "1", "alpha beta gamma"),
715 lexical("replace", "2", "beta beta"),
716 ],
717 &options,
718 )?;
719 lexical_answers(&core)?
720 };
721 for phase in ["staged", "published", "mapped"] {
722 let directory = tempfile::tempdir()?;
723 {
724 let core = WarmCore::new(
725 1024,
726 Some(directory.path().display().to_string()),
727 Some(config.clone()),
728 )?;
729 core.memorize_documents_with(
730 vec![
731 lexical("keep", "1", "alpha beta gamma"),
732 lexical("replace", "1", "alpha delta"),
733 lexical("delete", "1", "gamma delta delta"),
734 ],
735 &lexical_options(),
736 )?;
737 core.compact()?;
738 }
739 let output = std::process::Command::new(std::env::current_exe()?)
740 .args([
741 "--exact",
742 "core::engine::compaction::tests::checkpoint_compaction_crash_child",
743 ])
744 .env("WARM_TEST_CHECKPOINT_ROOT", directory.path())
745 .env(
746 "WARM_TEST_CHECKPOINT_ENCRYPTED",
747 config.security.encryption_enabled.to_string(),
748 )
749 .env("WARM_TEST_COMPACTION_PHASE", phase)
750 .output()?;
751 assert_eq!(output.status.code(), Some(73), "{phase}: {output:?}");
752 let generation = crate::core::durable_file::arena_generation(
753 &directory.path().join("vectors_data.bin"),
754 )?;
755 assert!(
756 generation.join(super::super::image::FILE).exists(),
757 "{phase}: the live generation holds a checkpoint"
758 );
759 let core = WarmCore::new(
760 1024,
761 Some(directory.path().display().to_string()),
762 Some(config.clone()),
763 )?;
764 assert_eq!(lexical_answers(&core)?, reference, "{phase}");
765 assert!(!core.has_document("delete"), "{phase}");
766 }
767 }
768 Ok(())
769 }
770
771 #[test]
772 fn concurrent_compactors_serialize_without_losing_intervening_mutations() -> Result<()> {
773 let directory = tempfile::tempdir()?;
774 let path = directory.path().display().to_string();
775 let core = Arc::new(WarmCore::new(1024, Some(path.clone()), Some(config()))?);
776 seed(&core)?;
777 let other_core = Arc::clone(&core);
778 let (start_tx, start_rx) = mpsc::channel();
779 let other = std::thread::spawn(move || -> Result<()> {
780 start_rx.recv()?;
781 other_core.compact()
782 });
783 core.compact_impl(
784 || {
785 start_tx.send(()).unwrap();
786 assert!(core.compaction.try_lock().is_none());
787 mutate(&core).unwrap();
788 },
789 || {},
790 false,
791 )?;
792 other.join().unwrap()?;
793 let expected = assert_current(&core)?;
794 drop(core);
795 let restored = WarmCore::new(1024, Some(path), Some(config()))?;
796 assert_eq!(assert_current(&restored)?, expected);
797 Ok(())
798 }
799
800 #[test]
801 fn replay_rejects_partial_frames_and_accepts_exact_capture_boundaries() -> Result<()> {
802 for delta in [-1_isize, 0, 1] {
803 let directory = tempfile::tempdir()?;
804 let core = WarmCore::new(1024, Some(directory.path().display().to_string()), None)?;
805 let mut cursor = core.arena.stats().used_bytes;
806 core.memorize("after".into(), core.encode_text("acknowledged"))?;
807 let stage = tempfile::tempdir_in(directory.path())?;
808 let staged = StagedArena::new(stage.path(), None)?.finish()?;
809 let mut prepared = staged.prepare_compacted()?;
810 let end = core
811 .arena
812 .stats()
813 .used_bytes
814 .checked_add_signed(delta)
815 .unwrap();
816 let result = core
817 .arena
818 .replay_compaction_tail(&mut prepared, &mut cursor, end);
819 assert_eq!(result.is_ok(), delta == 0);
820 assert!(!core.storage_path.join("vectors_data.bin/CURRENT").exists());
821 assert_eq!(
822 core.query(&core.encode_text("acknowledged"), 1)[0].id,
823 "after"
824 );
825 }
826 Ok(())
827 }
828
829 #[test]
830 fn compaction_writes_after_catchup_survive_publication_and_restart() -> Result<()> {
831 for config in configurations() {
832 let directory = tempfile::tempdir()?;
833 let path = directory.path().display().to_string();
834 let core = WarmCore::new(1024, Some(path.clone()), Some(config.clone()))?;
835 let initial = crate::EntangledHVec::new_deterministic(1024, 1);
836 let during = crate::EntangledHVec::new_deterministic(1024, 2);
837 let late = crate::EntangledHVec::new_deterministic(1024, 3);
838 assert_ne!(initial.indices(), late.indices());
839 assert_ne!(during.indices(), late.indices());
840 core.memorize("vector".into(), initial)?;
841 core.compact_impl(
842 || core.memorize("vector".into(), during).unwrap(),
843 || {
844 core.memorize("vector".into(), late.clone()).unwrap();
845 core.memorize_document(document("late", "late")).unwrap();
846 },
847 false,
848 )?;
849 let expected =
850 serde_json::to_value(core.search_documents("needle", &Default::default())?)?;
851 assert_eq!(expected[0]["version"], "late");
852 drop(core);
853 let restored = WarmCore::new(1024, Some(path), Some(config))?;
854 assert_eq!(
855 restored
856 .shards
857 .read()
858 .get_vector("vector")
859 .unwrap()
860 .indices(),
861 late.indices()
862 );
863 assert_eq!(
864 serde_json::to_value(restored.search_documents("needle", &Default::default())?)?,
865 expected
866 );
867 }
868 Ok(())
869 }
870}