1use std::collections::HashSet;
5use std::sync::Arc;
6
7use anyhow::{ensure, Result};
8use serde::Serialize;
9
10use super::{mutation::Mutation, HmsCore};
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);
39 }
40 }
41}
42
43struct Snapshot {
44 checkpoint: (String, usize),
45 mutations: Vec<Mutation>,
46 documents: Vec<Arc<StoredDocument>>,
47 text: Option<Arc<crate::core::documents::TextSegment>>,
50}
51
52#[derive(Serialize)]
53enum DocumentMutation<'a> {
54 Document(crate::core::documents::DocumentWithText<'a>),
55}
56
57impl HmsCore {
58 pub fn compact(&self) -> Result<()> {
59 self.compact_with_hook(|| {})
60 }
61
62 pub fn compact_scrubbing(&self) -> Result<()> {
71 self.sync_text_segment()?;
72 self.compact_impl(|| {}, || {}, true)
73 }
74
75 fn canonicalize_derived_state(&self) -> Result<()> {
81 {
82 let _transaction = self.mutation_gate.maintenance();
83 self.documents
84 .write()
85 .rebuild_canonical(&self.storage_path.join("documents.text"))?;
86 }
87 if let Some(snapshot) = self.vector_index.get() {
88 let graph_min_rows = if snapshot.has_graph() { 0 } else { usize::MAX };
89 self.rebuild_vector_index_waiting(graph_min_rows)?;
90 }
91 if self.documents.read().dense_index_recommended() {
92 self.train_dense_index()?;
93 }
94 Ok(())
95 }
96
97 fn compact_with_hook(&self, after_snapshot: impl FnOnce()) -> Result<()> {
98 self.compact_with_hooks(after_snapshot, || {})
99 }
100
101 fn compact_with_hooks(
102 &self,
103 after_snapshot: impl FnOnce(),
104 before_publication: impl FnOnce(),
105 ) -> Result<()> {
106 self.compact_impl(after_snapshot, before_publication, false)
107 }
108
109 fn compact_impl(
110 &self,
111 after_snapshot: impl FnOnce(),
112 before_publication: impl FnOnce(),
113 scrub: bool,
114 ) -> Result<()> {
115 let _compaction = self.compaction.lock();
116 let staging = StagingDir::new(self.arena.new_generation_dir()?);
117 let snapshot = self.compaction_snapshot()?;
118 let (generation, mut cursor) = snapshot.checkpoint;
119 after_snapshot();
120 let mut arena = StagedArena::new(staging.path(), Some(&self.arena))?;
121 for mutation in snapshot.mutations {
122 self.write_compaction_frame(&mut arena, &[mutation])?;
123 }
124 let mut payload = Vec::new();
125 for document in &snapshot.documents {
126 {
129 let store = self.documents.read();
130 if store.is_deleted(document) {
131 continue;
132 }
133 let binary = match self.journal {
134 super::journal::JournalFormat::Binary => {
135 super::journal::binary_document_payload(document, snapshot.text.as_deref())?
136 }
137 super::journal::JournalFormat::Json => None,
138 };
139 if let Some(binary) = binary {
140 payload = binary;
141 } else {
142 payload.clear();
144 payload.extend_from_slice(super::mutation::TRANSACTION_MAGIC);
145 serde_json::to_writer(
146 &mut payload,
147 &[DocumentMutation::Document(
148 crate::core::documents::DocumentWithText {
149 document,
150 segment: snapshot.text.as_deref(),
151 },
152 )],
153 )?;
154 }
155 }
156 arena.write_slice(&self.maybe_encrypt(&payload)?)?;
157 }
158 let mut prepared = arena.finish()?.prepare_compacted()?;
159 for _ in 0..2 {
160 let end = {
161 let _transaction = self.mutation_gate.maintenance();
162 self.compaction_end(&generation)?
163 };
164 self.arena
165 .replay_compaction_tail(&mut prepared, &mut cursor, end)?;
166 }
167 before_publication();
168 let _transaction = self.mutation_gate.maintenance();
169 let end = self.compaction_end(&generation)?;
170 self.arena
171 .replay_compaction_tail(&mut prepared, &mut cursor, end)?;
172 staging.disarm();
173 if scrub {
174 self.arena.replace_with_compacted_scrubbing(prepared)?;
175 } else {
176 self.arena.replace_with_compacted(prepared)?;
177 }
178 self.revision
179 .fetch_add(1, std::sync::atomic::Ordering::Release);
180 let checkpoint = self.arena.checkpoint();
185 _transaction.release();
186 self.persist_indices_detached(checkpoint)?;
187 self.persist_indices(&self.shards.read())?;
188 if let Some(audit) = &self.audit {
189 audit.record(
190 crate::core::audit::AuditOp::Compact,
191 "",
192 self.sign_fn().as_deref(),
193 )?;
194 }
195 if scrub {
196 self.canonicalize_derived_state()?;
199 }
200 Ok(())
201 }
202
203 fn compaction_snapshot(&self) -> Result<Snapshot> {
204 let _transaction = self.mutation_gate.maintenance();
205 let shards = self.shards.read();
206 let mut vectors = Vec::new();
207 let composite_ids: HashSet<_> = self
208 .composite_memory
209 .as_ref()
210 .map(|memory| {
211 memory
212 .inner()
213 .all_vectors()
214 .into_iter()
215 .map(|(_, id, _)| id)
216 .collect()
217 })
218 .unwrap_or_default();
219 shards.try_for_each_shard(|shard| {
220 for (id, vector) in shard.vectors.read().iter() {
221 if !id.starts_with(crate::core::documents::CHUNK_PREFIX)
222 && !composite_ids.contains(id)
223 {
224 vectors.push((id.clone(), vector.clone()));
225 }
226 }
227 Ok(())
228 })?;
229 vectors.sort_unstable_by(|a, b| a.0.cmp(&b.0));
232 let mut mutations: Vec<Mutation> = vectors
233 .into_iter()
234 .map(|(id, vector)| Mutation::Vector { id, vector })
235 .collect();
236 let (documents, text) = {
237 let store = self.documents.read();
238 (store.shared_values().collect(), store.text_segment())
239 };
240 mutations.extend(self.graph.snapshot().into_iter().map(Mutation::Relation));
241 mutations.extend(
242 self.graph
243 .type_snapshot()
244 .into_iter()
245 .map(Mutation::RelationType),
246 );
247 if let Some(atoms) = &self.atom_memory {
248 mutations.extend(
249 atoms
250 .inner()
251 .all_vectors()
252 .into_iter()
253 .map(|(_, id, vector)| Mutation::Atom { id, vector }),
254 );
255 }
256 if let Some(composites) = &self.composite_memory {
257 mutations.extend(
258 composites
259 .inner()
260 .all_vectors()
261 .into_iter()
262 .map(|(_, id, vector)| Mutation::Composite { id, vector }),
263 );
264 }
265 if let Some(triples) = &self.triple_store {
266 mutations.extend(triples.snapshot().into_iter().map(Mutation::StoredTriple));
267 }
268 if let Some(rules) = &self.rule_store {
269 mutations.extend(rules.all_rules().into_iter().map(Mutation::Rule));
270 }
271 mutations.extend(
272 self.derived
273 .read()
274 .iter()
275 .map(|(id, children)| Mutation::Derived {
276 id: id.clone(),
277 children: children.clone(),
278 }),
279 );
280 Ok(Snapshot {
281 checkpoint: self.arena.checkpoint(),
282 mutations,
283 documents,
284 text,
285 })
286 }
287
288 fn write_compaction_frame(
289 &self,
290 arena: &mut StagedArena,
291 mutations: &[Mutation],
292 ) -> Result<()> {
293 let payload = self.transaction_payload(mutations)?;
294 arena.write_slice(&self.maybe_encrypt(&payload)?)?;
295 Ok(())
296 }
297
298 fn compaction_end(&self, generation: &str) -> Result<usize> {
299 let checkpoint = self.arena.checkpoint();
300 ensure!(
301 checkpoint.0 == generation,
302 "arena generation changed during compaction"
303 );
304 Ok(checkpoint.1)
305 }
306
307 fn persist_indices_detached(&self, checkpoint: (String, usize)) -> Result<()> {
310 let encoded = self.encode_indices(&self.shards.read(), checkpoint)?;
311 self.write_indices(encoded)
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use std::sync::mpsc;
319 use std::time::Duration;
320
321 use crate::core::documents::{DocumentInput, SearchOptions};
322 use crate::core::{EmbeddingSpace, HmsConfig};
323 use serde_json::json;
324
325 fn config() -> HmsConfig {
326 HmsConfig {
327 embedding_space: Some(EmbeddingSpace {
328 model: "fixture".into(),
329 revision: "1".into(),
330 dimensions: 2,
331 normalization: "l2".into(),
332 metric: "cosine".into(),
333 }),
334 ..Default::default()
335 }
336 }
337
338 fn configurations() -> impl Iterator<Item = HmsConfig> {
339 [false, true]
340 .into_iter()
341 .filter(|encrypted| !encrypted || cfg!(feature = "security"))
342 .map(|encrypted| {
343 let mut config = config();
344 config.security.encryption_enabled = encrypted;
345 if encrypted {
346 config.security.encryption_passphrase =
347 Some("compaction-replay-fixture".into());
348 }
349 config
350 })
351 }
352
353 fn document(id: &str, version: &str) -> DocumentInput {
354 serde_json::from_value(json!({
355 "id": id, "version": version, "text": "needle durable source",
356 "sourceUri": format!("source:{version}"), "metadata": {"version": version},
357 "embeddings": [[1.0, 0.0]],
358 }))
359 .unwrap()
360 }
361
362 fn seed(core: &HmsCore) -> Result<()> {
363 core.memorize_document(document("replace", "old"))?;
364 core.memorize_document(document("delete", "old"))?;
365 core.memorize("vector".into(), core.encode_text("original"))?;
366 core.memorize("remove".into(), core.encode_text("removed"))?;
367 core.add_relation(&serde_json::from_value(json!({
368 "sourceId": "a", "relationType": "knows", "targetId": "b",
369 "properties": null, "validFrom": 0, "validTo": 0,
370 }))?)?;
371 Ok(())
372 }
373
374 fn mutate(core: &HmsCore) -> Result<()> {
375 core.memorize_documents(vec![document("replace", "new"), document("insert", "new")])?;
376 core.delete_document("delete")?;
377 core.memorize("vector".into(), core.encode_text("replacement"))?;
378 core.delete("remove")?;
379 core.remove_relation("a", "knows", "b")?;
380 Ok(())
381 }
382
383 fn assert_current(core: &HmsCore) -> Result<serde_json::Value> {
384 let options = SearchOptions {
385 embedding: Some(vec![1.0, 0.0]),
386 source_uri: Some("source:new".into()),
387 filter: Some(json!({"version": "new"})),
388 ..Default::default()
389 };
390 let results = core.search_documents("needle", &options)?;
391 assert_eq!(results.len(), 2);
392 assert!(results.iter().all(|r| r.version == "new"
393 && r.source_uri.as_deref() == Some("source:new")
394 && r.semantic_score == Some(1.0)));
395 assert_eq!(core.relation_count(), 0);
396 assert_eq!(core.vector_count(), 3);
397 assert_eq!(
398 core.query(&core.encode_text("replacement"), 1)[0].id,
399 "vector"
400 );
401 Ok(serde_json::to_value(results)?)
402 }
403
404 #[test]
405 fn compaction_replays_acknowledged_writes_and_preserves_batch_frames() -> Result<()> {
406 for config in configurations() {
407 let directory = tempfile::tempdir()?;
408 let path = directory.path().display().to_string();
409 let core = Arc::new(HmsCore::new(
410 1024,
411 Some(path.clone()),
412 Some(config.clone()),
413 )?);
414 seed(&core)?;
415 let writer_core = Arc::clone(&core);
416 let (start_tx, start_rx) = mpsc::channel();
417 let (done_tx, done_rx) = mpsc::channel();
418 let writer = std::thread::spawn(move || -> Result<()> {
419 start_rx.recv()?;
420 mutate(&writer_core)?;
421 assert_current(&writer_core)?;
422 done_tx.send(())?;
423 Ok(())
424 });
425 let compacted = core.compact_with_hook(|| {
426 start_tx.send(()).unwrap();
427 done_rx
428 .recv_timeout(Duration::from_secs(5))
429 .expect("compaction snapshot blocked mutations or queries");
430 });
431 writer.join().unwrap()?;
432 compacted?;
433 let expected = assert_current(&core)?;
434 let mut offset = 0;
435 let mut batch_frames = 0;
436 while offset < core.arena.stats().used_bytes {
437 let (data, _) = core.arena_read_frame(offset)?;
438 let operations = super::super::journal::decode(&data)?;
439 if operations.len() == 2 {
440 batch_frames += 1;
441 assert!(operations
442 .iter()
443 .all(|op| matches!(op, Mutation::Document(_))));
444 }
445 offset = core.arena.next_offset(offset)?;
446 }
447 assert_eq!(batch_frames, 1);
448 drop(core);
449 let restored = HmsCore::new(1024, Some(path), Some(config))?;
450 assert_eq!(assert_current(&restored)?, expected);
451 }
452 Ok(())
453 }
454
455 #[test]
456 fn compaction_snapshot_shares_document_payloads_after_replacement() -> Result<()> {
457 let directory = tempfile::tempdir()?;
458 let core = HmsCore::new(
459 1024,
460 Some(directory.path().display().to_string()),
461 Some(config()),
462 )?;
463 core.memorize_document(document("replace", "old"))?;
464 let snapshot = core.compaction_snapshot()?;
465 let owner = core.documents.read().shared_values().next().unwrap();
466 assert!(Arc::ptr_eq(&owner, &snapshot.documents[0]));
467 let dense_pointer = owner.chunks[0].embedding.as_ref().unwrap().as_ptr();
468 core.memorize_document(document("replace", "new"))?;
469 assert_eq!(snapshot.documents[0].version, "old");
470 assert_eq!(
471 snapshot.documents[0].chunks[0]
472 .embedding
473 .as_ref()
474 .unwrap()
475 .as_ptr(),
476 dense_pointer
477 );
478 Ok(())
479 }
480
481 #[test]
482 fn compaction_replay_crash_child() -> Result<()> {
483 let Ok(root) = std::env::var("HMS_TEST_CORE_REPLAY_ROOT") else {
484 return Ok(());
485 };
486 let encrypted = std::env::var("HMS_TEST_CORE_REPLAY_ENCRYPTED").as_deref() == Ok("true");
487 let config = configurations()
488 .find(|config| config.security.encryption_enabled == encrypted)
489 .unwrap();
490 let core = HmsCore::new(1024, Some(root), Some(config))?;
491 seed(&core)?;
492 core.compact_with_hook(|| mutate(&core).unwrap())
493 }
494
495 #[test]
496 fn compaction_replayed_writes_survive_each_publication_interruption() -> Result<()> {
497 for config in configurations() {
498 for phase in ["staged", "published", "mapped"] {
499 let directory = tempfile::tempdir()?;
500 let output = std::process::Command::new(std::env::current_exe()?)
501 .args([
502 "--exact",
503 "core::engine::compaction::tests::compaction_replay_crash_child",
504 ])
505 .env("HMS_TEST_CORE_REPLAY_ROOT", directory.path())
506 .env(
507 "HMS_TEST_CORE_REPLAY_ENCRYPTED",
508 config.security.encryption_enabled.to_string(),
509 )
510 .env("HMS_TEST_COMPACTION_PHASE", phase)
511 .output()?;
512 assert_eq!(output.status.code(), Some(73), "{phase}: {output:?}");
513 let core = HmsCore::new(
514 1024,
515 Some(directory.path().display().to_string()),
516 Some(config.clone()),
517 )?;
518 assert_current(&core)?;
519 core.memorize_document(document("after-recovery", "recovered"))?;
520 core.compact()?;
521 }
522 }
523 Ok(())
524 }
525
526 #[test]
527 fn concurrent_compactors_serialize_without_losing_intervening_mutations() -> Result<()> {
528 let directory = tempfile::tempdir()?;
529 let path = directory.path().display().to_string();
530 let core = Arc::new(HmsCore::new(1024, Some(path.clone()), Some(config()))?);
531 seed(&core)?;
532 let other_core = Arc::clone(&core);
533 let (start_tx, start_rx) = mpsc::channel();
534 let other = std::thread::spawn(move || -> Result<()> {
535 start_rx.recv()?;
536 other_core.compact()
537 });
538 core.compact_with_hook(|| {
539 start_tx.send(()).unwrap();
540 assert!(core.compaction.try_lock().is_none());
541 mutate(&core).unwrap();
542 })?;
543 other.join().unwrap()?;
544 let expected = assert_current(&core)?;
545 drop(core);
546 let restored = HmsCore::new(1024, Some(path), Some(config()))?;
547 assert_eq!(assert_current(&restored)?, expected);
548 Ok(())
549 }
550
551 #[test]
552 fn replay_rejects_partial_frames_and_accepts_exact_capture_boundaries() -> Result<()> {
553 for delta in [-1_isize, 0, 1] {
554 let directory = tempfile::tempdir()?;
555 let core = HmsCore::new(1024, Some(directory.path().display().to_string()), None)?;
556 let mut cursor = core.arena.stats().used_bytes;
557 core.memorize("after".into(), core.encode_text("acknowledged"))?;
558 let stage = tempfile::tempdir_in(directory.path())?;
559 let staged = StagedArena::new(stage.path(), None)?.finish()?;
560 let mut prepared = staged.prepare_compacted()?;
561 let end = core
562 .arena
563 .stats()
564 .used_bytes
565 .checked_add_signed(delta)
566 .unwrap();
567 let result = core
568 .arena
569 .replay_compaction_tail(&mut prepared, &mut cursor, end);
570 assert_eq!(result.is_ok(), delta == 0);
571 assert!(!core.storage_path.join("vectors_data.bin/CURRENT").exists());
572 assert_eq!(
573 core.query(&core.encode_text("acknowledged"), 1)[0].id,
574 "after"
575 );
576 }
577 Ok(())
578 }
579
580 #[test]
581 fn compaction_writes_after_catchup_survive_publication_and_restart() -> Result<()> {
582 for config in configurations() {
583 let directory = tempfile::tempdir()?;
584 let path = directory.path().display().to_string();
585 let core = HmsCore::new(1024, Some(path.clone()), Some(config.clone()))?;
586 let initial = crate::EntangledHVec::new_deterministic(1024, 1);
587 let during = crate::EntangledHVec::new_deterministic(1024, 2);
588 let late = crate::EntangledHVec::new_deterministic(1024, 3);
589 assert_ne!(initial.indices(), late.indices());
590 assert_ne!(during.indices(), late.indices());
591 core.memorize("vector".into(), initial)?;
592 core.compact_with_hooks(
593 || core.memorize("vector".into(), during).unwrap(),
594 || {
595 core.memorize("vector".into(), late.clone()).unwrap();
596 core.memorize_document(document("late", "late")).unwrap();
597 },
598 )?;
599 let expected =
600 serde_json::to_value(core.search_documents("needle", &Default::default())?)?;
601 assert_eq!(expected[0]["version"], "late");
602 drop(core);
603 let restored = HmsCore::new(1024, Some(path), Some(config))?;
604 assert_eq!(
605 restored
606 .shards
607 .read()
608 .get_vector("vector")
609 .unwrap()
610 .indices(),
611 late.indices()
612 );
613 assert_eq!(
614 serde_json::to_value(restored.search_documents("needle", &Default::default())?)?,
615 expected
616 );
617 }
618 Ok(())
619 }
620}