Skip to main content

writerslogic_warm/core/engine/
compaction.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
14/// Removes an abandoned staging generation; disarmed once publication begins.
15struct 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            // An abandoned generation is never published, and a leftover is scrubbed or ignored
39            // by the next compaction, so a failed removal only costs disk space.
40            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    /// The store's text segment at snapshot time: chunk text moved there is written back
50    /// inline into the compaction frames.
51    text: Option<Arc<crate::core::documents::TextSegment>>,
52    /// The document store as the new generation's checkpoint (sealed for an encrypted store),
53    /// in place of `documents` frames, when the store takes one (`image::encode_checkpoint`).
54    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    /// Compacts and then physically erases what the compaction replaced: the old generation
68    /// and any unreferenced leftovers are overwritten with zeros and synced before they are
69    /// unlinked. The text segment is scrubbed per deleted document at delete time.
70    ///
71    /// Afterwards nothing derived depends on the purged documents or on the order documents
72    /// arrived in (see `canonicalize_derived_state`), so every query, status field
73    /// other than the opaque counters, and file other than the generation's name match a store
74    /// that never held them and was purged at the same point.
75    pub fn compact_scrubbing(&self) -> Result<()> {
76        self.sync_text_segment()?;
77        self.compact_impl(|| {}, || {}, true)
78    }
79
80    /// Rebuilds the state a delete leaves shaped by history rather than by the live set: the
81    /// text segment (zeroed records of deleted text, offsets that count it) and the slots, in
82    /// id order as a reopen of the compacted journal lays them out; a published vector
83    /// snapshot, whose graph still holds the deleted rows' vertices and the edges they shaped
84    /// (rebuilt with the same graph choice); and the dense document index a delete dropped.
85    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    /// The text segment and slots of [`Self::canonicalize_derived_state`].
105    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        // A checkpoint records the slots as they are, so a scrubbing compaction that writes one
121        // lays them out canonically first: its checkpoint is then the one a store that never
122        // held the purged documents writes.
123        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            // Durable (file and directory synced) before the generation can be published.
139            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            // A document deleted since the snapshot has had its text zeroed; the delete is in
150            // the tail, which the replay below applies, so it is simply not carried over.
151            {
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                    // The JSON form carries any document; a binary store reads both.
166                    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        // The arena pointer has moved, so writers may continue: the lease ends here. Index
200        // persistence binds the checkpoint read under the lease, holds the shards only while
201        // encoding, and writes the files with no lock held; the persist call that follows
202        // then finds identical metadata and writes nothing.
203        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            // Still under the compaction lock: a concurrent compaction must not capture
225            // documents whose text offsets the canonical rebuild is replacing.
226            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        // Built before any lock is held: the snapshot below reads the store under the shards
236        // guard, and a store still served from its checkpoint must not be rebuilt there.
237        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        // The shard map's iteration order depends on its insertion and removal history; id
265        // order makes the compacted journal a function of the live vectors alone.
266        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    /// Persists the indices for `checkpoint` without holding the writer lease: the shards are
371    /// read only while the indices are encoded, and the files are written afterwards.
372    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    /// Hits of a few queries over every lexical fixture document, scores as bits.
655    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        // The parent wrote the first checkpoint; this compaction replaces it and is cut short.
682        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    /// A checkpoint compaction interrupted at each publication step reopens to the state every
696    /// acknowledged write reached, exactly as a store without checkpoints replays it.
697    #[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}