Skip to main content

haematite_cli/serve/
storage.rs

1//! Durable collections and documents over the engine's branch machinery.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5use std::sync::Arc;
6use std::time::Duration;
7
8use haematite::branch::history::SERVICE_BRANCH as SERVICE;
9use haematite::branch::{
10    KeptLimit, Kind as BranchKind, RefRecord as BranchRefRecord, RefStore as BranchRefStore,
11    Registry as BranchRegistry, ShardRef as BranchShardRef, create_branch_with_kind,
12    current_timestamp, fork_from_with_kind, open as open_branch,
13};
14use haematite::db::vacuum::CANONICAL_REFS_DIR;
15use haematite::db::{
16    Database, DatabaseConfig, DatabaseError, ReadOnlyDatabase, branches::BranchDatabase,
17};
18use haematite::store::NodeCacheBudget;
19use haematite::{Retention, RuntimeOptions};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use super::protocol::{Selection, Version};
24
25mod admission;
26pub use admission::Admission;
27
28pub mod attribution;
29pub mod embed_inherit;
30pub mod embed_job;
31pub mod embed_pending;
32pub mod embed_prune;
33pub mod embed_rewalk;
34pub mod embed_search;
35pub mod embedded;
36
37#[cfg(test)]
38#[path = "storage/envelope_tests.rs"]
39mod envelope_tests;
40#[cfg(test)]
41#[path = "storage/retention_tests.rs"]
42mod retention_tests;
43#[cfg(test)]
44#[path = "storage/span_measure_tests.rs"]
45mod span_measure_tests;
46#[cfg(test)]
47#[path = "storage/write_cost_tests.rs"]
48mod write_cost_tests;
49
50#[cfg(test)]
51#[path = "storage/batch_test_support.rs"]
52mod batch_test_support;
53#[cfg(test)]
54#[path = "storage/documents_delete_many_tests.rs"]
55mod documents_delete_many_tests;
56#[cfg(test)]
57#[path = "storage/edges_batch_cost_tests.rs"]
58mod edges_batch_cost_tests;
59#[cfg(test)]
60#[path = "storage/vectors_many_tests.rs"]
61mod vectors_many_tests;
62
63mod branches;
64mod catalogue;
65mod discards;
66mod document_delete_many;
67pub use discards::DiscardPasses;
68pub(super) mod edges;
69pub(super) mod field_index;
70pub(super) mod graph;
71mod history;
72pub use history::{KEPT_VERSIONS_DEFAULT, KEPT_VERSIONS_MOST};
73mod counts;
74mod dead;
75mod merge_holders;
76mod merge_journal;
77mod merges;
78mod mutations;
79mod retention;
80pub use retention::AppendPruneLimits;
81pub(in crate::serve) use retention::append_prune_address;
82pub mod reads;
83pub(super) mod relations;
84mod release;
85pub(super) mod review;
86mod vector_import;
87pub(super) mod vectors;
88pub use branches::ForkProvenance;
89#[cfg(test)]
90pub use catalogue::after_held;
91pub use catalogue::{Catalogue, Held};
92pub use merge_holders::{MergePin, MergePlan, MergeState};
93pub use merge_journal::{
94    CandidateAction, CandidateAdvance, MergeChoice, MergeChoiceRow, MergeChoices, MergeChoose,
95    MergeCoverage, MergeOwner, MissingChoice, RecordedChoice,
96};
97pub use release::{
98    Answer as ReleaseAnswer, HeldBy, IntentListing, Keep, Rules as ReleaseRules,
99    Source as ReleaseSource, UndatedGeneration,
100};
101#[cfg(test)]
102pub use release::{Crash, crash_at};
103
104const BRANCH_PREFIX: &str = haematite::branch::history::DATA_BRANCH_PREFIX;
105
106mod failure;
107pub use failure::Failure;
108#[cfg(test)]
109#[path = "storage/failure_tests.rs"]
110mod failure_tests;
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Receipt {
115    pub request_id: String,
116    pub fingerprint: String,
117    pub branch: String,
118    pub version: Version,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub retention: Option<super::protocol::RetentionResult>,
121}
122
123#[derive(Debug, Serialize, Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct Collection {
126    pub name: String,
127    pub schema: Option<Value>,
128    pub changed_in: String,
129    pub changed_at: Version,
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct DocumentRevision {
135    pub collection_branch: String,
136    pub collection_version: Version,
137    pub written_in: String,
138    pub written_at: Version,
139}
140
141/// A captured read selection contains every physical shard root.
142#[derive(Debug, Clone)]
143pub struct Captured {
144    pub branch: String,
145    pub version: Version,
146    pub shards: Vec<BranchShardRef>,
147}
148
149/// Mutation admission is serialized by the caller. Read-only work uses a
150/// captured selection and independent reader after releasing that admission.
151pub struct Storage {
152    refs: BranchRefStore,
153    visible: Catalogue,
154    registry: BranchRegistry,
155    reader: Arc<ReadOnlyDatabase>,
156    writer: BranchDatabase,
157    pending_discards: BTreeSet<String>,
158    confirmed_candidates: BTreeMap<String, Version>,
159    /// STOR-003 `MD5a`: the kept window's limit and the rows known in it.
160    kept_rows: history::KeptRows,
161    /// What came of the last discard's ask for a reclaim pass.
162    discard_passes: DiscardPasses,
163}
164
165pub(super) const fn runtime_options(
166    long_wait_interval: Duration,
167) -> Result<RuntimeOptions, DatabaseError> {
168    if long_wait_interval.is_zero() {
169        return Err(DatabaseError::InvalidLongWaitInterval {
170            given: long_wait_interval,
171        });
172    }
173    Ok(RuntimeOptions {
174        long_wait_interval,
175        reclaim_start: haematite::ReclaimStart::AtOpen,
176    })
177}
178
179impl Storage {
180    /// Create only in a previously absent directory. Never adopt application data.
181    ///
182    /// # Errors
183    ///
184    /// Refuses unsupported writer policies before branch references are created.
185    /// Propagates any error from `try_exists`, `open` or `create_branch_with_kind`.
186    pub fn create(
187        config: DatabaseConfig,
188        branch_cache: NodeCacheBudget,
189        long_wait_interval: Duration,
190    ) -> Result<Self, Failure> {
191        Self::create_kept(
192            config,
193            branch_cache,
194            history::default_kept()?,
195            long_wait_interval,
196        )
197    }
198
199    /// [`Self::create`], each branch record keeping `kept` previous versions
200    /// (STOR-003 `MD5a`).
201    ///
202    /// # Errors
203    ///
204    /// As [`Self::create`].
205    pub fn create_kept(
206        config: DatabaseConfig,
207        branch_cache: NodeCacheBudget,
208        kept: KeptLimit,
209        long_wait_interval: Duration,
210    ) -> Result<Self, Failure> {
211        let options = runtime_options(long_wait_interval).map_err(Failure::from)?;
212        if config.data_dir.try_exists().map_err(Failure::storage)? {
213            return Err(Failure::new(
214                "directory_exists",
215                "create requires a new directory",
216            ));
217        }
218        let database = Database::create(config, options).map_err(Failure::from)?;
219        database.require_path_writer().map_err(Failure::from)?;
220        let mut writer = BranchDatabase::new(database, branch_cache).map_err(Failure::database)?;
221        let mut refs = BranchRefStore::open_writing(
222            writer.data_dir().join(CANONICAL_REFS_DIR),
223            writer.writer_lock(),
224        )
225        .map_err(Failure::branch_ref)?;
226        refs.keep_versions(kept);
227        let registry = BranchRegistry::new();
228        create_branch_with_kind(
229            SERVICE,
230            writer.base_roots().map_err(Failure::database)?,
231            BranchKind::Namespace,
232            &mut refs,
233            &registry,
234            current_timestamp(),
235        )
236        .map_err(Failure::branch_commit)?;
237        counts::initialize_new_store(&mut writer, &mut refs, &registry)?;
238        Self::finish_open(writer, refs, registry, kept)
239    }
240
241    /// Reopen a served directory under its existing exclusive writer lock.
242    ///
243    /// # Errors
244    ///
245    /// Refuses unsupported writer policies before engine startup.
246    /// Propagates any error from `open`.
247    pub fn open(
248        path: &Path,
249        branch_cache: NodeCacheBudget,
250        long_wait_interval: Duration,
251    ) -> Result<Self, Failure> {
252        Self::open_kept(
253            path,
254            branch_cache,
255            history::default_kept()?,
256            long_wait_interval,
257        )
258    }
259
260    /// [`Self::open`], each branch record keeping `kept` previous versions
261    /// (STOR-003 `MD5a`).
262    ///
263    /// # Errors
264    ///
265    /// As [`Self::open`].
266    pub fn open_kept(
267        path: &Path,
268        branch_cache: NodeCacheBudget,
269        kept: KeptLimit,
270        long_wait_interval: Duration,
271    ) -> Result<Self, Failure> {
272        let options = runtime_options(long_wait_interval).map_err(Failure::from)?;
273        if !path.join(CANONICAL_REFS_DIR).is_dir() {
274            return Err(Failure::new(
275                "not_served",
276                "directory has no served branch references",
277            ));
278        }
279        // The service's own record, read from disk with no ref store opened
280        // and nothing attached: only a store the service made is moved below.
281        match BranchRefStore::read_record(path.join(CANONICAL_REFS_DIR), SERVICE) {
282            Ok(Some(_record)) => {}
283            Ok(None) => {
284                return Err(Failure::new(
285                    "not_served",
286                    "directory is not a haem/1 service database",
287                ));
288            }
289            Err(error) => {
290                return Err(Failure::new(
291                    "service_record_unreadable",
292                    format!("the service's branch record cannot be read: {error}"),
293                ));
294            }
295        }
296        let database = Database::open_path_writer(path, options).map_err(Failure::from)?;
297        let database = if database.reclaim_mark_bytes().is_some() {
298            database
299        } else {
300            // Moved once, from KeepAll to reclaiming under the default bound
301            // (STOR-003 H1a): the store's config records it, so every later
302            // open finds Reclaim and moves nothing.
303            drop(database);
304            let moved = Database::open_with_retention(
305                path,
306                runtime_options(long_wait_interval).map_err(Failure::from)?,
307                Retention::reclaim(0),
308            )
309            .map_err(Failure::from)?;
310            moved.require_path_writer().map_err(Failure::from)?;
311            moved
312        };
313        let writer = BranchDatabase::new(database, branch_cache).map_err(Failure::database)?;
314        let mut refs =
315            BranchRefStore::open_writing(path.join(CANONICAL_REFS_DIR), writer.writer_lock())
316                .map_err(Failure::branch_ref)?;
317        refs.keep_versions(kept);
318        if refs.get(SERVICE).is_none() {
319            return Err(Failure::new(
320                "not_served",
321                "directory is not a haem/1 service database",
322            ));
323        }
324        Self::finish_open(writer, refs, BranchRegistry::new(), kept)
325    }
326
327    fn finish_open(
328        writer: BranchDatabase,
329        mut refs: BranchRefStore,
330        registry: BranchRegistry,
331        kept: KeptLimit,
332    ) -> Result<Self, Failure> {
333        if refs.get(SERVICE).is_none() {
334            return Err(Failure::new("not_served", "missing service namespace"));
335        }
336        let reader =
337            Arc::new(ReadOnlyDatabase::open(writer.data_dir()).map_err(Failure::observer)?);
338        history::require_file_rows(&reader, &refs)?;
339        discards::require_file_rows(&reader, &refs)?;
340        let main = branch_name("main");
341        if refs.get(&main).is_none() {
342            let parent = open_branch(SERVICE, &refs, &registry).map_err(Failure::branch_commit)?;
343            fork_from_with_kind(
344                &parent,
345                &main,
346                BranchKind::Work,
347                &mut refs,
348                &registry,
349                current_timestamp(),
350            )
351            .map_err(Failure::branch_commit)?;
352        }
353        // Recovered bytes are not, by themselves, an acknowledged fence.
354        // The fence changes no record, and this open owns the store, so the
355        // listing taken after it is the listing it fenced.
356        refs.confirm_directory().map_err(Failure::branch_ref)?;
357        let visible: BTreeMap<_, _> = refs
358            .list()
359            .map(|record| {
360                let kept = refs.kept(&record.name).to_vec();
361                (record.name.clone(), (record.clone(), kept))
362            })
363            .collect();
364        // The holder of `writer`, the database this storage keeps: after a
365        // move at open it is the reopened one's, never the first's (H1b).
366        let holder = writer.read_holder();
367        let mut storage = Self {
368            refs,
369            visible: Catalogue::new(visible, Arc::clone(&reader), holder),
370            registry,
371            reader,
372            writer,
373            pending_discards: BTreeSet::new(),
374            confirmed_candidates: BTreeMap::new(),
375            kept_rows: history::KeptRows::new(kept),
376            discard_passes: DiscardPasses::default(),
377        };
378        storage.reconcile_discards()?;
379        Ok(storage)
380    }
381
382    /// # Errors
383    ///
384    /// Returns `Failure` if `capture` fails.
385    pub fn capture(&self, selection: &Selection) -> Result<Captured, Failure> {
386        self.visible.capture(selection)
387    }
388
389    pub(in crate::serve) fn activate_reclaim(&self) -> Result<(), Failure> {
390        self.writer.activate_reclaim().map_err(Failure::from)
391    }
392
393    /// Run each shard's reclaim to rest (STOR-003 H1c).
394    ///
395    /// # Errors
396    /// Propagates a failure while draining shard reclaim work.
397    #[cfg(test)]
398    pub fn quiesce_reclaim(&self) -> Result<(), Failure> {
399        self.writer.quiesce_reclaim().map_err(Failure::from)
400    }
401
402    /// Each shard's reclaim, as the database answers it (SL2: a test's
403    /// diagnosis).
404    #[cfg(test)]
405    pub fn reclaim_status(&self) -> haematite::db::ReclaimStatus {
406        self.writer.reclaim_status()
407    }
408
409    /// How many read holds live on this storage's database (STOR-003 H1c).
410    #[cfg(test)]
411    pub fn live_read_holds(&self) -> usize {
412        self.writer.read_holder().status().live
413    }
414
415    pub fn catalogue(&self) -> Catalogue {
416        self.visible.clone()
417    }
418
419    /// What came of the last discard's ask for a reclaim pass, shared with
420    /// whoever answers `store.stats`.
421    #[must_use]
422    pub fn discard_passes(&self) -> DiscardPasses {
423        self.discard_passes.clone()
424    }
425
426    /// A separate immutable reader can perform captured reads concurrently.
427    ///
428    /// # Errors
429    ///
430    /// Returns `Failure` if `open` or `data_dir` fails.
431    pub fn reader(&self) -> Result<ReadOnlyDatabase, Failure> {
432        ReadOnlyDatabase::open(self.writer.data_dir()).map_err(Failure::observer)
433    }
434
435    fn retain_current(&mut self, branch: &str, record: &BranchRefRecord) -> Result<(), Failure> {
436        self.retain_history_row(branch, record)
437    }
438
439    fn publish_record(&self, name: &str) -> Result<(), Failure> {
440        let record = self
441            .refs
442            .get(name)
443            .ok_or_else(|| Failure::new("reference_missing", "committed reference disappeared"))?;
444        self.visible
445            .publish(record.clone(), self.refs.kept(name).to_vec())
446    }
447}
448
449const fn version(record: &BranchRefRecord) -> Version {
450    Version {
451        generation: record.created,
452        sequence: record.seq,
453    }
454}
455
456fn branch_name(name: &str) -> String {
457    format!("{BRANCH_PREFIX}{name}")
458}
459
460fn identifier(value: &str) -> Result<(), Failure> {
461    if value.is_empty() || value.len() > 512 || value.contains('\0') {
462        return Err(Failure::new(
463            "invalid_identifier",
464            "names must contain 1 to 512 UTF-8 bytes and no NUL",
465        ));
466    }
467    Ok(())
468}
469
470fn collection_key(name: &str) -> Vec<u8> {
471    format!("c\0{name}").into_bytes()
472}
473pub(in crate::serve) fn document_key(collection: &str, id: &str) -> Vec<u8> {
474    format!("d\0{collection}\0{id}").into_bytes()
475}
476
477pub(in crate::serve) fn revision_key(collection: &str, id: &str) -> Vec<u8> {
478    format!("m\0{collection}\0{id}").into_bytes()
479}
480fn receipt_key(branch: &str, generation: u64, id: &str) -> Vec<u8> {
481    format!("r\0{branch}\0{generation}\0{id}").into_bytes()
482}
483
484#[cfg(test)]
485mod long_wait_tests {
486    use std::error::Error;
487    use std::time::Duration;
488
489    use haematite::DatabaseError;
490    use haematite::db::DatabaseConfig;
491    use haematite::store::NodeCacheBudget;
492
493    use super::{Storage, runtime_options};
494
495    #[test]
496    fn zero_is_the_typed_engine_refusal() {
497        assert!(matches!(
498            runtime_options(Duration::ZERO),
499            Err(DatabaseError::InvalidLongWaitInterval { given }) if given == Duration::ZERO
500        ));
501    }
502
503    #[test]
504    fn zero_refuses_create_and_open_before_directory_checks() -> Result<(), Box<dyn Error>> {
505        let directory = tempfile::tempdir()?;
506        let path = directory.path().join("absent");
507        let config = DatabaseConfig {
508            data_dir: path.clone(),
509            shard_count: 1,
510            distributed: None,
511            executor_threads: Some(1),
512            node_cache_budget: Some(NodeCacheBudget::Unlimited),
513            retention: haematite::Retention::reclaim(0),
514        };
515        for refused in [
516            Storage::create(config, NodeCacheBudget::Unlimited, Duration::ZERO),
517            Storage::open(&path, NodeCacheBudget::Unlimited, Duration::ZERO),
518        ] {
519            let failure = refused.err().ok_or("zero interval was accepted")?;
520            assert_eq!(failure.code, "storage_unavailable");
521            assert_eq!(
522                failure.message,
523                DatabaseError::InvalidLongWaitInterval {
524                    given: Duration::ZERO
525                }
526                .to_string()
527            );
528            assert!(!path.try_exists()?);
529        }
530        Ok(())
531    }
532}