Skip to main content

haematite_cli/serve/storage/
discards.rs

1//! Completed receipts are point-read rows; only interrupted removals keep intents.
2
3use haematite::branch::{CommitDurability, CommitRequest, RefRecord, remove as remove_branch};
4use haematite::db::ReadOnlyDatabase;
5use haematite::tree::Hash;
6use serde::{Deserialize, Serialize};
7use serde_json::{Value, json};
8
9use super::{
10    BranchKind, BranchRefStore, Failure, Receipt, SERVICE, Storage, branch_name,
11    create_branch_with_kind, current_timestamp,
12    dead::{Dead, dead_key},
13    identifier, open_branch, version,
14};
15use crate::serve::protocol::{Mutation, ReceiptQuery, Version};
16
17const FILE_PREFIX: &str = "@haem/discard/";
18const INTENT_PREFIX: &str = "@haem/discard-pending/";
19
20#[derive(Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22struct Intent {
23    branch: String,
24    request_id: String,
25    version: Version,
26}
27
28impl Intent {
29    fn name(&self) -> Result<String, Failure> {
30        Ok(format!(
31            "{INTENT_PREFIX}{}",
32            serde_json::to_string(self).map_err(Failure::storage)?
33        ))
34    }
35
36    fn decode(record: &RefRecord, shard_count: usize) -> Result<Self, Failure> {
37        let corrupt = |message| {
38            Failure::new(
39                "discard_intent_corrupt",
40                format!("{}: {message}", record.name),
41            )
42        };
43        let suffix = record
44            .name
45            .strip_prefix(INTENT_PREFIX)
46            .ok_or_else(|| corrupt("intent namespace differs".to_owned()))?;
47        let intent: Self =
48            serde_json::from_str(suffix).map_err(|error| corrupt(error.to_string()))?;
49        identifier(&intent.branch).map_err(|error| corrupt(error.to_string()))?;
50        identifier(&intent.request_id).map_err(|error| corrupt(error.to_string()))?;
51        if intent.branch == "main"
52            || record.kind != BranchKind::Namespace
53            || record.seq != 0
54            || intent.name()? != record.name
55            || record.shards.len() != shard_count
56            || record
57                .shards
58                .iter()
59                .enumerate()
60                .any(|(id, shard)| id != shard.shard_id)
61        {
62            return Err(corrupt("intent identity or kind differs".to_owned()));
63        }
64        Ok(intent)
65    }
66}
67
68impl Storage {
69    /// The discards asked for and not finished, as (branch, version) each,
70    /// read from their intent records (STOR-003 H1b2): what a release's floor
71    /// relies on besides heads, forks and merges. An intent that does not
72    /// decode refuses the listing by its name; none is passed over.
73    ///
74    /// # Errors
75    /// `discard_intent_corrupt`, naming the record.
76    pub fn pending_discards(&self) -> Result<Vec<(String, Version)>, Failure> {
77        let shard_count = self.reader.shard_count();
78        self.refs
79            .list_prefix(INTENT_PREFIX)
80            .map(|record| {
81                Intent::decode(record, shard_count).map(|intent| (intent.branch, intent.version))
82            })
83            .collect()
84    }
85}
86
87pub(super) fn key(branch: &str, request: &str) -> Vec<u8> {
88    let mut key = Vec::with_capacity(66);
89    key.extend_from_slice(b"d\0");
90    key.extend_from_slice(Hash::of(branch.as_bytes()).as_bytes());
91    key.extend_from_slice(Hash::of(request.as_bytes()).as_bytes());
92    key
93}
94
95fn service_record<'a>(
96    reader: &ReadOnlyDatabase,
97    refs: &'a BranchRefStore,
98) -> Result<&'a RefRecord, Failure> {
99    let record = refs
100        .get(SERVICE)
101        .ok_or_else(|| Failure::new("reference_missing", "missing service namespace"))?;
102    if record.kind != BranchKind::Namespace
103        || record.shards.len() != reader.shard_count()
104        || record
105            .shards
106            .iter()
107            .enumerate()
108            .any(|(id, shard)| id != shard.shard_id)
109    {
110        return Err(Failure::new(
111            "discard_service_invalid",
112            "service must name every physical shard",
113        ));
114    }
115    Ok(record)
116}
117
118fn read_row(
119    reader: &ReadOnlyDatabase,
120    refs: &BranchRefStore,
121    key: &[u8],
122) -> Result<Option<Receipt>, Failure> {
123    let service = service_record(reader, refs)?;
124    let shard_id = reader.shard_for(key);
125    let root = service
126        .shards
127        .get(shard_id)
128        .ok_or_else(|| Failure::new("incomplete_roots", "service row shard is missing"))?
129        .head;
130    reader
131        .get_at(shard_id, root, key)
132        .map_err(Failure::observer)?
133        .map(|bytes| {
134            let corrupt = |message| Failure::new("discard_record_corrupt", message);
135            let receipt: Receipt =
136                serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?;
137            identifier(&receipt.branch).map_err(|error| corrupt(error.to_string()))?;
138            identifier(&receipt.request_id).map_err(|error| corrupt(error.to_string()))?;
139            if receipt.branch == "main"
140                || self::key(&receipt.branch, &receipt.request_id) != key
141                || receipt.fingerprint.len() != 64
142                || !receipt
143                    .fingerprint
144                    .bytes()
145                    .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
146            {
147                return Err(corrupt("receipt identity differs from its row".to_owned()));
148            }
149            Ok(receipt)
150        })
151        .transpose()
152}
153
154pub(super) fn require_file_rows(
155    reader: &ReadOnlyDatabase,
156    refs: &BranchRefStore,
157) -> Result<(), Failure> {
158    for record in refs.list_prefix(FILE_PREFIX) {
159        let suffix = record
160            .name
161            .strip_prefix(FILE_PREFIX)
162            .ok_or_else(|| Failure::new("discard_ref_corrupt", &record.name))?;
163        let (branch, request) = suffix
164            .split_once('/')
165            .ok_or_else(|| Failure::new("discard_ref_corrupt", &record.name))?;
166        let mut key = b"d\0".to_vec();
167        key.extend_from_slice(&decode_hash(branch).map_err(|error| {
168            Failure::new("discard_ref_corrupt", format!("{}: {error}", record.name))
169        })?);
170        key.extend_from_slice(&decode_hash(request).map_err(|error| {
171            Failure::new("discard_ref_corrupt", format!("{}: {error}", record.name))
172        })?);
173        if read_row(reader, refs, &key)?.is_none() {
174            return Err(Failure::new(
175                "discard_rows_incomplete",
176                format!("discard file {} has no matching row", record.name),
177            ));
178        }
179    }
180    Ok(())
181}
182
183fn decode_hash(hex: &str) -> Result<[u8; 32], Failure> {
184    let invalid = || {
185        Failure::new(
186            "discard_ref_corrupt",
187            "discard reference requires canonical hashes",
188        )
189    };
190    if hex.len() != 64
191        || !hex
192            .bytes()
193            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
194    {
195        return Err(invalid());
196    }
197    let mut hash = [0; 32];
198    for (byte, pair) in hash.iter_mut().zip(hex.as_bytes().as_chunks::<2>().0) {
199        let digits = std::str::from_utf8(pair).map_err(|_| invalid())?;
200        *byte = u8::from_str_radix(digits, 16).map_err(|_| invalid())?;
201    }
202    Ok(hash)
203}
204
205impl Storage {
206    pub(super) fn discard_request(
207        &mut self,
208        request: &Mutation,
209        fingerprint: &str,
210        target: &str,
211        record: &RefRecord,
212    ) -> Result<Receipt, Failure> {
213        identifier(target)?;
214        if target == "main" {
215            return Err(Failure::new("main_protected", "main cannot be discarded"));
216        }
217        if target != request.branch {
218            return Err(Failure::new(
219                "discard_target_mismatch",
220                "discard must name the mutation's branch",
221            ));
222        }
223        // The head and the versions the record keeps leave with it: each has
224        // its row first, in one service commit (STOR-003 MD5a, MD5c).
225        self.rows_before_discard(target, record)?;
226        #[cfg(test)]
227        super::history::after_rows::reached()?;
228        let intent = Intent {
229            branch: target.to_owned(),
230            request_id: request.request_id.clone(),
231            version: request.expected,
232        };
233        let name = intent.name()?;
234        let result = create_branch_with_kind(
235            &name,
236            record
237                .shards
238                .iter()
239                .map(|shard| (shard.shard_id, shard.head)),
240            BranchKind::Namespace,
241            &mut self.refs,
242            &self.registry,
243            current_timestamp(),
244        );
245        if self.refs.get(&name).is_some() {
246            self.pending_discards.insert(record.name.clone());
247            self.visible.remove(&record.name)?;
248        }
249        result.map_err(|error| {
250            Failure::new(
251                if self.refs.get(&name).is_some() {
252                    "durability_unknown"
253                } else {
254                    "write_refused"
255                },
256                error.to_string(),
257            )
258        })?;
259        let receipt = Receipt {
260            retention: None,
261            request_id: request.request_id.clone(),
262            fingerprint: fingerprint.to_owned(),
263            branch: target.to_owned(),
264            version: request.expected,
265        };
266        self.commit_and_finish_discard(&name, &intent, &receipt)?;
267        Ok(receipt)
268    }
269
270    fn commit_and_finish_discard(
271        &mut self,
272        name: &str,
273        intent: &Intent,
274        receipt: &Receipt,
275    ) -> Result<(), Failure> {
276        if let Err(refused) = self.commit_discard_row(receipt) {
277            if refused.code == "write_refused" {
278                self.finish_intent(name, intent, None).map_err(|abort| {
279                    Failure::new(
280                        "discard_abort_failed",
281                        format!("{refused}; failed to abort discard intent: {abort}"),
282                    )
283                })?;
284            }
285            return Err(refused);
286        }
287        self.finish_intent(name, intent, Some(receipt))
288    }
289
290    fn commit_discard_row(&mut self, receipt: &Receipt) -> Result<(), Failure> {
291        #[cfg(test)]
292        let fault = ROW_COMMIT_FAULT.with(std::cell::Cell::take);
293        #[cfg(test)]
294        if matches!(fault, Some(CommitFault::RefuseBeforeCommit)) {
295            return Err(Failure::new(
296                "write_refused",
297                "injected refusal before discard row commit",
298            ));
299        }
300        let key = key(&receipt.branch, &receipt.request_id);
301        let bytes = serde_json::to_vec(receipt).map_err(Failure::storage)?;
302        let now = current_timestamp();
303        // The generation's marker rides the receipt's own commit: a discard
304        // whose receipt is durable removes its branch, and only such a one
305        // leaves rows with no record for release to find.
306        let dead_key = dead_key(&receipt.branch, receipt.version.generation);
307        let dead = serde_json::to_vec(&Dead {
308            branch: receipt.branch.clone(),
309            last: receipt.version,
310            discarded_at: now,
311        })
312        .map_err(Failure::storage)?;
313        let at = version(service_record(&self.reader, &self.refs)?);
314        let service =
315            open_branch(SERVICE, &self.refs, &self.registry).map_err(Failure::branch_commit)?;
316        service
317            .put(self.writer.shard_for(&key), key, bytes)
318            .map_err(Failure::branch)?;
319        service
320            .put(self.writer.shard_for(&dead_key), dead_key, dead)
321            .map_err(Failure::branch)?;
322        self.writer
323            .commit(
324                &service,
325                &self.registry,
326                CommitRequest {
327                    durability: CommitDurability::Durable {
328                        refs: &mut self.refs,
329                    },
330                    extra_parents: &[],
331                    timestamp: now,
332                },
333            )
334            .map_err(|error| {
335                let installed = self.refs.get(SERVICE).is_some_and(|now| version(now) != at);
336                Failure::new(
337                    if installed {
338                        "durability_unknown"
339                    } else {
340                        "write_refused"
341                    },
342                    error.to_string(),
343                )
344            })?;
345        #[cfg(test)]
346        if matches!(fault, Some(CommitFault::LoseCommittedAnswer)) {
347            return Err(Failure::new(
348                "durability_unknown",
349                "injected lost answer after discard row commit",
350            ));
351        }
352        self.publish_record(SERVICE)
353    }
354
355    pub(super) fn discard_receipt(
356        &mut self,
357        query: &ReceiptQuery,
358    ) -> Result<Option<Receipt>, Failure> {
359        let receipt = read_row(
360            &self.reader,
361            &self.refs,
362            &key(&query.branch, &query.request_id),
363        )?;
364        if let Some(receipt) = &receipt {
365            if receipt.branch != query.branch || receipt.request_id != query.request_id {
366                return Err(Failure::new(
367                    "discard_record_corrupt",
368                    "receipt differs from requested identity",
369                ));
370            }
371            let at = version(service_record(&self.reader, &self.refs)?);
372            self.refs
373                .confirm_version(SERVICE, at.generation, at.sequence)
374                .map_err(|error| Failure::new("durability_unknown", error.to_string()))?;
375            self.publish_record(SERVICE)?;
376        }
377        // The receipt supplies the original generation after unlink. Before
378        // row commit, admission still holds the target at its original head.
379        let target = branch_name(&query.branch);
380        if self.pending_discards.contains(&target) {
381            let expected = receipt
382                .as_ref()
383                .map(|receipt| receipt.version)
384                .or_else(|| self.refs.get(&target).map(version))
385                .ok_or_else(|| {
386                    Failure::new(
387                        "discard_pending",
388                        "pending removal has no receipt or target",
389                    )
390                })?;
391            let intent = Intent {
392                branch: query.branch.clone(),
393                request_id: query.request_id.clone(),
394                version: expected,
395            };
396            let name = intent.name()?;
397            if let Some(record) = self.refs.get(&name) {
398                let intent = Intent::decode(record, self.reader.shard_count())?;
399                self.finish_intent(&name, &intent, receipt.as_ref())?;
400            }
401        }
402        Ok(receipt)
403    }
404
405    pub(super) fn reconcile_discards(&mut self) -> Result<(), Failure> {
406        // Intent records are removed after both the row and branch unlink are
407        // fenced. Clean open has no intents and performs no receipt point read.
408        let mut targets = std::collections::BTreeSet::new();
409        let mut intents = Vec::new();
410        for record in self.refs.list_prefix(INTENT_PREFIX) {
411            let intent = Intent::decode(record, self.reader.shard_count())?;
412            if !targets.insert(intent.branch.clone()) {
413                return Err(Failure::new(
414                    "discard_intent_corrupt",
415                    "multiple intents name the same branch",
416                ));
417            }
418            intents.push((record.name.clone(), intent));
419        }
420        for (name, intent) in intents {
421            let receipt = read_row(
422                &self.reader,
423                &self.refs,
424                &key(&intent.branch, &intent.request_id),
425            )?;
426            self.finish_intent(&name, &intent, receipt.as_ref())?;
427        }
428        Ok(())
429    }
430
431    fn finish_intent(
432        &mut self,
433        name: &str,
434        intent: &Intent,
435        receipt: Option<&Receipt>,
436    ) -> Result<(), Failure> {
437        let target = branch_name(&intent.branch);
438        self.pending_discards.insert(target.clone());
439        let mut removed = false;
440        if let Some(receipt) = receipt {
441            if receipt.branch != intent.branch
442                || receipt.request_id != intent.request_id
443                || receipt.version != intent.version
444            {
445                return Err(Failure::new(
446                    "discard_intent_corrupt",
447                    "intent differs from its receipt row",
448                ));
449            }
450            if let Some(current) = self.refs.get(&target)
451                && current.created == intent.version.generation
452            {
453                if version(current) != intent.version {
454                    return Err(Failure::new(
455                        "discard_conflict",
456                        "branch advanced after its removal was recorded",
457                    ));
458                }
459                self.visible.remove(&target)?;
460                remove_branch(&target, &mut self.refs)
461                    .map_err(|error| Failure::new("durability_unknown", error.to_string()))?;
462                removed = true;
463            }
464        }
465        // An absent row proves the intent never reached a committed removal.
466        // A newer generation is preserved even if an old intent resurfaces.
467        remove_branch(name, &mut self.refs)
468            .map_err(|error| Failure::new("durability_unknown", error.to_string()))?;
469        self.visible.remove(name)?;
470        if self.refs.get(&target).is_some() {
471            self.publish_record(&target)?;
472        } else {
473            self.visible.remove(&target)?;
474        }
475        self.pending_discards.remove(&target);
476        // The pass is asked for only now: the intent removed just above holds
477        // the target's heads, so a pass marked before it would keep every node
478        // the discard gave up. As after a release (release.rs carry_out), the
479        // removal is durable by now and nothing is waited on. What came of the
480        // ask is said in `store.stats`, never in the receipt: a receipt is the
481        // stored row, the same every time it is answered.
482        if removed {
483            self.ask_pass(intent);
484        }
485        Ok(())
486    }
487
488    /// Ask the engine for a reclaim pass and return at once: removing a ref
489    /// writes no node, so no pass falls due by itself (STOR-003 RB2). A
490    /// refusal is kept by name for `store.stats`; the discard stands.
491    fn ask_pass(&self, intent: &Intent) {
492        let asked = self.writer.request_reclaim();
493        #[cfg(test)]
494        let asked = PASS_REFUSED.with(std::cell::Cell::take).map_or(asked, Err);
495        self.discard_passes.record(LastPass {
496            branch: intent.branch.clone(),
497            request_id: intent.request_id.clone(),
498            asked: asked.is_ok(),
499            refused: asked.err().map(|error| error.to_string()),
500        });
501    }
502}
503
504/// What came of the last discard's ask for a reclaim pass.
505#[derive(Debug, Clone, Serialize)]
506struct LastPass {
507    branch: String,
508    request_id: String,
509    asked: bool,
510    refused: Option<String>,
511}
512
513/// The last discard's ask for a reclaim pass, shared between the storage that
514/// asks and the service that answers `store.stats`.
515#[derive(Debug, Clone, Default)]
516pub struct DiscardPasses(std::sync::Arc<std::sync::Mutex<Option<LastPass>>>);
517
518impl DiscardPasses {
519    fn record(&self, pass: LastPass) {
520        *self
521            .0
522            .lock()
523            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pass);
524    }
525
526    /// The last ask as `store.stats` says it: null before any discard asked,
527    /// else its branch, request id, whether it was asked, and the refusal by
528    /// name when it was refused.
529    #[must_use]
530    pub fn status(&self) -> Value {
531        self.0
532            .lock()
533            .unwrap_or_else(std::sync::PoisonError::into_inner)
534            .as_ref()
535            .map_or(Value::Null, |pass| json!(pass))
536    }
537}
538
539#[cfg(test)]
540#[derive(Clone, Copy)]
541enum CommitFault {
542    RefuseBeforeCommit,
543    LoseCommittedAnswer,
544}
545
546#[cfg(test)]
547thread_local! {
548    static ROW_COMMIT_FAULT: std::cell::Cell<Option<CommitFault>> =
549        const { std::cell::Cell::new(None) };
550    /// The refusal the next ask for a pass is answered with, in place of the
551    /// engine's answer.
552    static PASS_REFUSED: std::cell::Cell<Option<haematite::db::DatabaseError>> =
553        const { std::cell::Cell::new(None) };
554}
555
556#[cfg(test)]
557#[path = "discards_tests.rs"]
558mod tests;