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