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        // A line gives up every version it kept, version 0 to its head, and
224        // its receipt says how many (STOR-050 R2): read before anything is
225        // written, so the stored receipt is the answer every time.
226        let head = super::Captured {
227            branch: target.to_owned(),
228            version: version(record),
229            shards: record.shards.clone(),
230        };
231        let versions_released = if super::lineage::is_line(&self.reader, &head)? {
232            Some(record.seq.checked_add(1).ok_or_else(|| {
233                Failure::new("sequence_exhausted", "a line's versions cannot be counted")
234            })?)
235        } else {
236            None
237        };
238        // The head and the versions the record keeps leave with it: each has
239        // its row first, in one service commit (STOR-003 MD5a, MD5c).
240        self.rows_before_discard(target, record)?;
241        #[cfg(test)]
242        super::history::after_rows::reached()?;
243        let intent = Intent {
244            branch: target.to_owned(),
245            request_id: request.request_id.clone(),
246            version: request.expected,
247        };
248        let name = intent.name()?;
249        let result = create_branch_with_kind(
250            &name,
251            record
252                .shards
253                .iter()
254                .map(|shard| (shard.shard_id, shard.head)),
255            BranchKind::Namespace,
256            &mut self.refs,
257            &self.registry,
258            current_timestamp(),
259        );
260        if self.refs.get(&name).is_some() {
261            self.pending_discards.insert(record.name.clone());
262            self.visible.remove(&record.name)?;
263        }
264        result.map_err(|error| {
265            Failure::new(
266                if self.refs.get(&name).is_some() {
267                    "durability_unknown"
268                } else {
269                    "write_refused"
270                },
271                error.to_string(),
272            )
273        })?;
274        let receipt = Receipt {
275            retention: None,
276            request_id: request.request_id.clone(),
277            fingerprint: fingerprint.to_owned(),
278            branch: target.to_owned(),
279            version: request.expected,
280            versions_released,
281        };
282        self.commit_and_finish_discard(&name, &intent, &receipt)?;
283        // Durable now: the line's rows, and its origin's when that is a
284        // discarded line this branch held, go to reclaim (STOR-050 R2).
285        self.release_after_discard(target, record.created)?;
286        Ok(receipt)
287    }
288
289    fn commit_and_finish_discard(
290        &mut self,
291        name: &str,
292        intent: &Intent,
293        receipt: &Receipt,
294    ) -> Result<(), Failure> {
295        if let Err(refused) = self.commit_discard_row(receipt) {
296            if refused.code == "write_refused" {
297                self.finish_intent(name, intent, None).map_err(|abort| {
298                    Failure::new(
299                        "discard_abort_failed",
300                        format!("{refused}; failed to abort discard intent: {abort}"),
301                    )
302                })?;
303            }
304            return Err(refused);
305        }
306        self.finish_intent(name, intent, Some(receipt))
307    }
308
309    fn commit_discard_row(&mut self, receipt: &Receipt) -> Result<(), Failure> {
310        #[cfg(test)]
311        let fault = ROW_COMMIT_FAULT.with(std::cell::Cell::take);
312        #[cfg(test)]
313        if matches!(fault, Some(CommitFault::RefuseBeforeCommit)) {
314            return Err(Failure::new(
315                "write_refused",
316                "injected refusal before discard row commit",
317            ));
318        }
319        let key = key(&receipt.branch, &receipt.request_id);
320        let bytes = serde_json::to_vec(receipt).map_err(Failure::storage)?;
321        let now = current_timestamp();
322        // The generation's marker rides the receipt's own commit: a discard
323        // whose receipt is durable removes its branch, and only such a one
324        // leaves rows with no record for release to find.
325        let dead_key = dead_key(&receipt.branch, receipt.version.generation);
326        let dead = serde_json::to_vec(&Dead {
327            branch: receipt.branch.clone(),
328            last: receipt.version,
329            discarded_at: now,
330        })
331        .map_err(Failure::storage)?;
332        let at = version(service_record(&self.reader, &self.refs)?);
333        let service =
334            open_branch(SERVICE, &self.refs, &self.registry).map_err(Failure::branch_commit)?;
335        service
336            .put(self.writer.shard_for(&key), key, bytes)
337            .map_err(Failure::branch)?;
338        service
339            .put(self.writer.shard_for(&dead_key), dead_key, dead)
340            .map_err(Failure::branch)?;
341        self.writer
342            .commit(
343                &service,
344                &self.registry,
345                CommitRequest {
346                    durability: CommitDurability::Durable {
347                        refs: &mut self.refs,
348                    },
349                    extra_parents: &[],
350                    timestamp: now,
351                    note: None,
352                },
353            )
354            .map_err(|error| {
355                let installed = self.refs.get(SERVICE).is_some_and(|now| version(now) != at);
356                Failure::new(
357                    if installed {
358                        "durability_unknown"
359                    } else {
360                        "write_refused"
361                    },
362                    error.to_string(),
363                )
364            })?;
365        #[cfg(test)]
366        if matches!(fault, Some(CommitFault::LoseCommittedAnswer)) {
367            return Err(Failure::new(
368                "durability_unknown",
369                "injected lost answer after discard row commit",
370            ));
371        }
372        self.publish_record(SERVICE)
373    }
374
375    pub(super) fn discard_receipt(
376        &mut self,
377        query: &ReceiptQuery,
378    ) -> Result<Option<Receipt>, Failure> {
379        let receipt = read_row(
380            &self.reader,
381            &self.refs,
382            &key(&query.branch, &query.request_id),
383        )?;
384        if let Some(receipt) = &receipt {
385            if receipt.branch != query.branch || receipt.request_id != query.request_id {
386                return Err(Failure::new(
387                    "discard_record_corrupt",
388                    "receipt differs from requested identity",
389                ));
390            }
391            let at = version(service_record(&self.reader, &self.refs)?);
392            self.refs
393                .confirm_version(SERVICE, at.generation, at.sequence)
394                .map_err(|error| Failure::new("durability_unknown", error.to_string()))?;
395            self.publish_record(SERVICE)?;
396        }
397        // The receipt supplies the original generation after unlink. Before
398        // row commit, admission still holds the target at its original head.
399        let target = branch_name(&query.branch);
400        if self.pending_discards.contains(&target) {
401            let expected = receipt
402                .as_ref()
403                .map(|receipt| receipt.version)
404                .or_else(|| self.refs.get(&target).map(version))
405                .ok_or_else(|| {
406                    Failure::new(
407                        "discard_pending",
408                        "pending removal has no receipt or target",
409                    )
410                })?;
411            let intent = Intent {
412                branch: query.branch.clone(),
413                request_id: query.request_id.clone(),
414                version: expected,
415            };
416            let name = intent.name()?;
417            if let Some(record) = self.refs.get(&name) {
418                let intent = Intent::decode(record, self.reader.shard_count())?;
419                self.finish_intent(&name, &intent, receipt.as_ref())?;
420            }
421        }
422        Ok(receipt)
423    }
424
425    pub(super) fn reconcile_discards(&mut self) -> Result<(), Failure> {
426        // Intent records are removed after both the row and branch unlink are
427        // fenced. Clean open has no intents and performs no receipt point read.
428        let mut targets = std::collections::BTreeSet::new();
429        let mut intents = Vec::new();
430        for record in self.refs.list_prefix(INTENT_PREFIX) {
431            let intent = Intent::decode(record, self.reader.shard_count())?;
432            if !targets.insert(intent.branch.clone()) {
433                return Err(Failure::new(
434                    "discard_intent_corrupt",
435                    "multiple intents name the same branch",
436                ));
437            }
438            intents.push((record.name.clone(), intent));
439        }
440        for (name, intent) in intents {
441            let receipt = read_row(
442                &self.reader,
443                &self.refs,
444                &key(&intent.branch, &intent.request_id),
445            )?;
446            self.finish_intent(&name, &intent, receipt.as_ref())?;
447        }
448        Ok(())
449    }
450
451    fn finish_intent(
452        &mut self,
453        name: &str,
454        intent: &Intent,
455        receipt: Option<&Receipt>,
456    ) -> Result<(), Failure> {
457        let target = branch_name(&intent.branch);
458        self.pending_discards.insert(target.clone());
459        let mut removed = false;
460        if let Some(receipt) = receipt {
461            if receipt.branch != intent.branch
462                || receipt.request_id != intent.request_id
463                || receipt.version != intent.version
464            {
465                return Err(Failure::new(
466                    "discard_intent_corrupt",
467                    "intent differs from its receipt row",
468                ));
469            }
470            if let Some(current) = self.refs.get(&target)
471                && current.created == intent.version.generation
472            {
473                if version(current) != intent.version {
474                    return Err(Failure::new(
475                        "discard_conflict",
476                        "branch advanced after its removal was recorded",
477                    ));
478                }
479                self.visible.remove(&target)?;
480                remove_branch(&target, &mut self.refs)
481                    .map_err(|error| Failure::new("durability_unknown", error.to_string()))?;
482                removed = true;
483            }
484        }
485        // An absent row proves the intent never reached a committed removal.
486        // A newer generation is preserved even if an old intent resurfaces.
487        remove_branch(name, &mut self.refs)
488            .map_err(|error| Failure::new("durability_unknown", error.to_string()))?;
489        self.visible.remove(name)?;
490        if self.refs.get(&target).is_some() {
491            self.publish_record(&target)?;
492        } else {
493            self.visible.remove(&target)?;
494        }
495        self.pending_discards.remove(&target);
496        // The pass is asked for only now: the intent removed just above holds
497        // the target's heads, so a pass marked before it would keep every node
498        // the discard gave up. As after a release (release.rs carry_out), the
499        // removal is durable by now and nothing is waited on. What came of the
500        // ask is said in `store.stats`, never in the receipt: a receipt is the
501        // stored row, the same every time it is answered.
502        if removed {
503            self.ask_pass(intent);
504        }
505        Ok(())
506    }
507
508    /// Ask the engine for a reclaim pass and return at once: removing a ref
509    /// writes no node, so no pass falls due by itself (STOR-003 RB2). A
510    /// refusal is kept by name for `store.stats`; the discard stands.
511    fn ask_pass(&self, intent: &Intent) {
512        let asked = self.writer.request_reclaim();
513        #[cfg(test)]
514        let asked = PASS_REFUSED.with(std::cell::Cell::take).map_or(asked, Err);
515        self.discard_passes.record(LastPass {
516            branch: intent.branch.clone(),
517            request_id: intent.request_id.clone(),
518            asked: asked.is_ok(),
519            refused: asked.err().map(|error| error.to_string()),
520        });
521    }
522}
523
524/// What came of the last discard's ask for a reclaim pass.
525#[derive(Debug, Clone, Serialize)]
526struct LastPass {
527    branch: String,
528    request_id: String,
529    asked: bool,
530    refused: Option<String>,
531}
532
533/// The last discard's ask for a reclaim pass, shared between the storage that
534/// asks and the service that answers `store.stats`.
535#[derive(Debug, Clone, Default)]
536pub struct DiscardPasses(std::sync::Arc<std::sync::Mutex<Option<LastPass>>>);
537
538impl DiscardPasses {
539    fn record(&self, pass: LastPass) {
540        *self
541            .0
542            .lock()
543            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pass);
544    }
545
546    /// The last ask as `store.stats` says it: null before any discard asked,
547    /// else its branch, request id, whether it was asked, and the refusal by
548    /// name when it was refused.
549    #[must_use]
550    pub fn status(&self) -> Value {
551        self.0
552            .lock()
553            .unwrap_or_else(std::sync::PoisonError::into_inner)
554            .as_ref()
555            .map_or(Value::Null, |pass| json!(pass))
556    }
557}
558
559#[cfg(test)]
560#[derive(Clone, Copy)]
561enum CommitFault {
562    RefuseBeforeCommit,
563    LoseCommittedAnswer,
564}
565
566#[cfg(test)]
567thread_local! {
568    static ROW_COMMIT_FAULT: std::cell::Cell<Option<CommitFault>> =
569        const { std::cell::Cell::new(None) };
570    /// The refusal the next ask for a pass is answered with, in place of the
571    /// engine's answer.
572    static PASS_REFUSED: std::cell::Cell<Option<haematite::db::DatabaseError>> =
573        const { std::cell::Cell::new(None) };
574}
575
576#[cfg(test)]
577#[path = "discards_tests.rs"]
578mod tests;