crates/kernel/ents-receive/src/reconcile.rs
reconcile.rshistorycomment on this file
| 1 | //! The boot-time reconciliation scan (`receive.reconstructible`): the |
| 2 | //! reference proof that the obligation queue needs no state `receive` |
| 3 | //! itself did not already have available in the repository. |
| 4 | |
| 5 | use ents_model::Effect; |
| 6 | use ents_query::{Evaluator, Query}; |
| 7 | use gix::refs::FullName; |
| 8 | use gix_hash::ObjectId; |
| 9 | use gix_object::{CommitRef, Find, Kind}; |
| 10 | use gix_ref_store::RefStoreRead; |
| 11 | |
| 12 | use crate::error::{Error, Result}; |
| 13 | use crate::sink::EventSink; |
| 14 | |
| 15 | const EFFECTS_PREFIX: &str = "refs/meta/effects/"; |
| 16 | |
| 17 | /// Every effect definition currently readable under `refs/meta/effects/*`, |
| 18 | /// as `(name, parsed trigger)`. |
| 19 | /// |
| 20 | /// An effect whose tree cannot be read, or whose `trigger` fails to parse, |
| 21 | /// is skipped rather than failing the scan: `receive.validation` |
| 22 | /// (`ents-model`, `ents-query`) is what keeps a *newly written* effect |
| 23 | /// well-formed, but a scan reused across every future push must stay |
| 24 | /// resilient to a pre-existing malformed one rather than let it take down |
| 25 | /// every push after it (the same spirit as `receive.never-blocks`, applied |
| 26 | /// to a scan that runs on `receive`'s own hot path). |
| 27 | // @relation(receive.event-sink, scope=function) |
| 28 | fn known_effects(refs: &dyn RefStoreRead, objects: &impl Find) -> Result<Vec<(String, Query)>> { |
| 29 | let mut effects = Vec::new(); |
| 30 | for entry in refs.iter_prefix(EFFECTS_PREFIX)? { |
| 31 | let (name, tip) = entry?; |
| 32 | let Some(short) = short_effect_name(&name) else { |
| 33 | continue; |
| 34 | }; |
| 35 | let Some(tree) = commit_tree(objects, tip)? else { |
| 36 | continue; |
| 37 | }; |
| 38 | let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, objects) else { |
| 39 | continue; |
| 40 | }; |
| 41 | let Ok(trigger) = effect.trigger.parse::<Query>() else { |
| 42 | continue; |
| 43 | }; |
| 44 | effects.push((short, trigger)); |
| 45 | } |
| 46 | Ok(effects) |
| 47 | } |
| 48 | |
| 49 | /// The effect name segment of a `refs/meta/effects/<name>` refname, or |
| 50 | /// `None` for anything deeper or shallower (mirrors the results-namespace |
| 51 | /// scan in `ents-query`'s evaluator). |
| 52 | fn short_effect_name(name: &FullName) -> Option<String> { |
| 53 | let path = name.as_bstr().to_string(); |
| 54 | let short = path.strip_prefix(EFFECTS_PREFIX)?; |
| 55 | (!short.is_empty() && !short.contains('/')).then(|| short.to_owned()) |
| 56 | } |
| 57 | |
| 58 | /// The tree of the commit at `oid`, or `None` if `oid` is missing or not a |
| 59 | /// commit — treated as "this ref is unreadable", never a hard failure of |
| 60 | /// the whole scan. Shared with [`crate::receive`]'s redaction-target scan. |
| 61 | pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<Option<ObjectId>> { |
| 62 | let mut buf = Vec::new(); |
| 63 | let Some(data) = objects |
| 64 | .try_find(&oid, &mut buf) |
| 65 | .map_err(|source| Error::Decode { |
| 66 | oid, |
| 67 | detail: source.to_string(), |
| 68 | })? |
| 69 | else { |
| 70 | return Ok(None); |
| 71 | }; |
| 72 | if data.kind != Kind::Commit { |
| 73 | return Ok(None); |
| 74 | } |
| 75 | let Ok(commit) = CommitRef::from_bytes(data.data, oid.kind()) else { |
| 76 | return Ok(None); |
| 77 | }; |
| 78 | Ok(Some(commit.tree())) |
| 79 | } |
| 80 | |
| 81 | /// The full, reconciliation-grade obligation scan |
| 82 | /// (`receive.reconstructible`): for every effect currently defined, compute |
| 83 | /// its outstanding work set (`trigger − results(self, any)`, |
| 84 | /// `query.workset`) against current ref state and enqueue every commit |
| 85 | /// still owed a result. |
| 86 | /// |
| 87 | /// A composition root calls this once at startup, before serving further |
| 88 | /// pushes, so an `EventSink` that lost its queued events on crash (the null |
| 89 | /// sink always; the in-memory reference sink after a restart) recovers |
| 90 | /// exactly the same obligations incremental `receive` calls would have |
| 91 | /// enqueued — the queue is reconstructible from repository state alone, |
| 92 | /// with the dedup key (`receive.dedup`) unchanged by reconciliation. |
| 93 | /// |
| 94 | /// # Errors |
| 95 | /// |
| 96 | /// Fails only on a ref-store or object-store read failure, or a sink |
| 97 | /// failure; a malformed individual effect definition is skipped, not an |
| 98 | /// error (this module's private effect-scan helper treats an unreadable |
| 99 | /// tree or an unparsable trigger as "no match", never a hard failure). |
| 100 | /// |
| 101 | /// # Examples |
| 102 | /// |
| 103 | /// ``` |
| 104 | /// use ents_model::Effect; |
| 105 | /// use ents_receive::{MemoryEventSink, reconcile}; |
| 106 | /// use ents_testutil::{MemRefStore, ObjectStore, advance_ref, write_meta_entity}; |
| 107 | /// |
| 108 | /// let refs = MemRefStore::default(); |
| 109 | /// let objects = ObjectStore::default(); |
| 110 | /// let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 100); |
| 111 | /// |
| 112 | /// let effect = Effect { |
| 113 | /// name: "unit".to_owned(), |
| 114 | /// trigger: "rev(refs/heads/main)".to_owned(), |
| 115 | /// toolchains: vec![], |
| 116 | /// run: "true".to_owned(), |
| 117 | /// }; |
| 118 | /// let name: gix::refs::FullName = "refs/meta/effects/unit".try_into().expect("valid"); |
| 119 | /// write_meta_entity(&refs, &objects, name, &effect, None, 200); |
| 120 | /// |
| 121 | /// let sink = MemoryEventSink::default(); |
| 122 | /// reconcile(&refs, &objects, &sink).expect("reconciles"); |
| 123 | /// assert_eq!(sink.pending(), vec![("unit".to_owned(), commits[0])]); |
| 124 | /// ``` |
| 125 | // @relation(receive.reconstructible, query.workset, scope=function) |
| 126 | pub fn reconcile( |
| 127 | refs: &dyn RefStoreRead, |
| 128 | objects: &impl Find, |
| 129 | events: &dyn EventSink, |
| 130 | ) -> Result<()> { |
| 131 | let evaluator = Evaluator::new(refs, objects); |
| 132 | for (name, trigger) in known_effects(refs, objects)? { |
| 133 | for oid in evaluator.outstanding(&name, &trigger)? { |
| 134 | enqueue(events, &name, oid)?; |
| 135 | } |
| 136 | } |
| 137 | Ok(()) |
| 138 | } |
| 139 | |
| 140 | /// Enqueue matches for `transition` against every known effect |
| 141 | /// (`receive.event-sink`): the incremental counterpart to [`reconcile`], |
| 142 | /// called by [`crate::receive`] once per successfully applied transition. |
| 143 | // @relation(receive.event-sink, query.workset, scope=function) |
| 144 | pub(crate) fn enqueue_matches( |
| 145 | refs: &dyn RefStoreRead, |
| 146 | objects: &impl Find, |
| 147 | events: &dyn EventSink, |
| 148 | transition: &ents_query::Transition, |
| 149 | ) -> Result<()> { |
| 150 | let evaluator = Evaluator::new(refs, objects); |
| 151 | for (name, trigger) in known_effects(refs, objects)? { |
| 152 | for oid in evaluator.work_set(&name, &trigger, transition)? { |
| 153 | enqueue(events, &name, oid)?; |
| 154 | } |
| 155 | } |
| 156 | Ok(()) |
| 157 | } |
| 158 | |
| 159 | fn enqueue(events: &dyn EventSink, effect: &str, oid: ObjectId) -> Result<()> { |
| 160 | events.enqueue(effect, oid) |
| 161 | } |