crates/kernel/ents-sync/src/transfer.rs
transfer.rshistorycomment on this file
| 1 | //! Fetch and push over `refs/meta/*` — the routine plumbing that moves the |
| 2 | //! forge itself, not merely code (`sync.forge-transfer`). |
| 3 | //! |
| 4 | //! Both directions copy the *complete* object closure of every meta-ref — |
| 5 | //! each ref's whole commit chain and all its trees and blobs, commit |
| 6 | //! objects verbatim so their signatures travel too — so a clone plus |
| 7 | //! `refs/meta/*` carries the entire audit history and the signatures needed |
| 8 | //! to verify it, with nothing left server-side (`sync.forge-transfer`). |
| 9 | //! |
| 10 | //! A remote and a local repository are each just a ([`RefStoreRead`]/ |
| 11 | //! [`RefStore`], `Find`/`Write`) pair; transfer is expressed directly over |
| 12 | //! those seams, with no bespoke transport type. [`fetch`] advances every |
| 13 | //! local meta-ref that the remote fast-forwards and reports the ones that |
| 14 | //! diverged, feeding them to the merge machinery ([`crate::resolve`]). |
| 15 | //! [`push`] runs pre-flight against the remote's own policy before moving a |
| 16 | //! ref, so a rejected canonical push surfaces the inbox alternative instead |
| 17 | //! (`sync.pre-flight`, `sync.inbox-routing`). |
| 18 | //! |
| 19 | //! Both directions advance the destination ref through |
| 20 | //! [`RefStore::transaction`] directly rather than through `receive()`, |
| 21 | //! which `receive.unit` scopes to *origination*. [`fetch`] is |
| 22 | //! *replication*: every ref it lands was already admitted by the source's |
| 23 | //! own `receive`, so re-verification here is an opt-in audit, not an |
| 24 | //! obligation, and effect obligations for arrived refs are recovered by |
| 25 | //! the boot-time reconciliation scan (`receive.reconstructible`). The |
| 26 | //! merges the machinery authors itself are origination and go through the |
| 27 | //! gate ([`crate::resolve`]). [`push`]'s destination side alone is |
| 28 | //! stand-in plumbing: pushing *is* origination at the destination, whose |
| 29 | //! own `receive()` — the hosted hook of the phase-6 single-node root — |
| 30 | //! does not exist yet; until it does, pre-flight is the judgment a push |
| 31 | //! destination gets. |
| 32 | |
| 33 | use ents_gate::Update; |
| 34 | use ents_model::MemberId; |
| 35 | use gix::refs::FullName; |
| 36 | use gix_hash::ObjectId; |
| 37 | use gix_object::{Find, Write}; |
| 38 | use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome}; |
| 39 | |
| 40 | use crate::error::Result; |
| 41 | use crate::objects::{copy_closure, descends_from}; |
| 42 | use crate::preflight::{PreFlight, preflight}; |
| 43 | |
| 44 | /// The meta-ref prefix that scopes every forge transfer. |
| 45 | const META_PREFIX: &str = "refs/meta/"; |
| 46 | |
| 47 | /// A remote meta-ref that has moved out from under the local tip: neither |
| 48 | /// side descends from the other, so a fast-forward is impossible and the |
| 49 | /// answer is a merge ([`crate::resolve::merge_heads`], `sync.divergence-merge`). |
| 50 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 51 | pub struct Diverged { |
| 52 | /// The ref that diverged. |
| 53 | pub name: FullName, |
| 54 | /// The local tip. |
| 55 | pub local: ObjectId, |
| 56 | /// The remote tip. |
| 57 | pub remote: ObjectId, |
| 58 | } |
| 59 | |
| 60 | /// What [`fetch`] did to the local `refs/meta/*` set. |
| 61 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
| 62 | pub struct FetchReport { |
| 63 | /// Refs advanced to the remote tip (created, or fast-forwarded). |
| 64 | pub updated: Vec<FullName>, |
| 65 | /// Refs already at the remote tip; nothing to do. |
| 66 | pub unchanged: Vec<FullName>, |
| 67 | /// Refs whose local and remote tips diverged — resolve by merging. |
| 68 | pub diverged: Vec<Diverged>, |
| 69 | /// Refs whose CAS was rejected: another local writer moved the ref |
| 70 | /// between this fetch's read and its transaction, so nothing was |
| 71 | /// written. Re-running fetch re-classifies them against the new tip. |
| 72 | pub stale: Vec<FullName>, |
| 73 | } |
| 74 | |
| 75 | /// Fetch every `refs/meta/*` ref from `remote` into `local`, moving the |
| 76 | /// whole forge (`sync.forge-transfer`). |
| 77 | /// |
| 78 | /// For each remote meta-ref the full object closure is copied into |
| 79 | /// `local_objects` first — so the ref never points at an object the local |
| 80 | /// store lacks — then the local ref is advanced if the remote is a |
| 81 | /// fast-forward, left alone if already current, and reported as |
| 82 | /// [`Diverged`] otherwise. Object copy is unconditional even for a |
| 83 | /// divergence, since the subsequent merge needs both heads present locally. |
| 84 | /// |
| 85 | /// # Errors |
| 86 | /// |
| 87 | /// Propagates ref-store and object failures. A rejected CAS — another |
| 88 | /// local writer moved a ref between this fetch's read and its transaction |
| 89 | /// — is not an error: the ref is reported in [`FetchReport::stale`] and |
| 90 | /// nothing is written for it. |
| 91 | /// |
| 92 | /// # Examples |
| 93 | /// |
| 94 | /// ``` |
| 95 | /// use ents_model::Provenance; |
| 96 | /// use ents_sync::transfer::fetch; |
| 97 | /// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member}; |
| 98 | /// use gix_ref_store::RefStoreRead; |
| 99 | /// |
| 100 | /// // The "remote" is just another ref-store / object-store pair. |
| 101 | /// let remote_refs = MemRefStore::default(); |
| 102 | /// let remote_objects = ObjectStore::default(); |
| 103 | /// let key = Keypair::from_seed(1); |
| 104 | /// enroll_member(&remote_refs, &remote_objects, "jdc", &key, Provenance::AdminRegistered, 100); |
| 105 | /// |
| 106 | /// let local_refs = MemRefStore::default(); |
| 107 | /// let local_objects = ObjectStore::default(); |
| 108 | /// let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).expect("fetches"); |
| 109 | /// assert_eq!(report.updated.len(), 1); |
| 110 | /// |
| 111 | /// let name: gix::refs::FullName = "refs/meta/member/jdc".try_into().expect("valid"); |
| 112 | /// assert!(local_refs.get(name.as_ref()).expect("readable").is_some()); |
| 113 | /// ``` |
| 114 | // @relation(sync.forge-transfer, scope=function) |
| 115 | pub fn fetch( |
| 116 | remote_refs: &dyn RefStoreRead, |
| 117 | remote_objects: &impl Find, |
| 118 | local_refs: &dyn RefStore, |
| 119 | local_objects: &(impl Find + Write), |
| 120 | ) -> Result<FetchReport> { |
| 121 | let mut report = FetchReport::default(); |
| 122 | for entry in remote_refs.iter_prefix(META_PREFIX)? { |
| 123 | let (name, remote_tip) = entry?; |
| 124 | copy_closure(remote_objects, local_objects, remote_tip)?; |
| 125 | |
| 126 | let local_tip = local_refs.get(name.as_ref())?; |
| 127 | match local_tip { |
| 128 | Some(local) if local == remote_tip => report.unchanged.push(name), |
| 129 | Some(local) if descends_from(local_objects, remote_tip, local)? => { |
| 130 | let expected = Expected::MustExistAndMatch(local); |
| 131 | match advance(local_refs, &name, expected, remote_tip)? { |
| 132 | TxOutcome::Applied => report.updated.push(name), |
| 133 | TxOutcome::Rejected { .. } => report.stale.push(name), |
| 134 | } |
| 135 | } |
| 136 | Some(local) => report.diverged.push(Diverged { |
| 137 | name, |
| 138 | local, |
| 139 | remote: remote_tip, |
| 140 | }), |
| 141 | None => match advance(local_refs, &name, Expected::MustNotExist, remote_tip)? { |
| 142 | TxOutcome::Applied => report.updated.push(name), |
| 143 | TxOutcome::Rejected { .. } => report.stale.push(name), |
| 144 | }, |
| 145 | } |
| 146 | } |
| 147 | Ok(report) |
| 148 | } |
| 149 | |
| 150 | /// The outcome of pushing one local meta-ref to a remote ([`push`]). |
| 151 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 152 | pub enum Pushed { |
| 153 | /// Pre-flight passed and the ref was transferred and advanced on the |
| 154 | /// remote. |
| 155 | Advanced(FullName), |
| 156 | /// Pre-flight predicted a rejection the inbox can absorb; the ref was |
| 157 | /// *not* pushed, and this is the inbox route offered instead |
| 158 | /// (`sync.inbox-routing`). |
| 159 | Inbox(FullName), |
| 160 | /// Pre-flight predicted a rejection the inbox cannot absorb (a |
| 161 | /// divergence — merge first — or a refname mismatch). The ref was not |
| 162 | /// pushed; the prediction is carried for the caller to render. |
| 163 | Refused(Box<PreFlight>), |
| 164 | /// The pre-flight prediction went stale between judgment and CAS: |
| 165 | /// another writer advanced the remote ref, the transaction was |
| 166 | /// rejected, and nothing was written. This is exactly the staleness a |
| 167 | /// prediction admits (`sync.pre-flight`) — fetch, merge if divergent, |
| 168 | /// and push again. |
| 169 | Stale(FullName), |
| 170 | } |
| 171 | |
| 172 | /// Push one local meta-ref `name` to `remote`, pre-flighting against the |
| 173 | /// remote's own policy first (`sync.pre-flight`). |
| 174 | /// |
| 175 | /// The local tip's object closure is copied to the remote *before* the |
| 176 | /// verdict is computed — the gate must be able to read the proposed |
| 177 | /// objects, exactly as the hosted CAS judges after ingest — so a refused |
| 178 | /// push deliberately leaves those objects in the remote object store even |
| 179 | /// though no ref comes to point at them. That residue matters for |
| 180 | /// redaction: recorded redaction targets refuse re-ingest at the `receive` |
| 181 | /// boundary (`receive.redaction-ingest`), and purging unreferenced objects |
| 182 | /// is the store's garbage collection, not this function's. Only the *ref* |
| 183 | /// is gated: a predicted rejection routes to the inbox |
| 184 | /// (`sync.inbox-routing`) or is reported, and the remote's refs are |
| 185 | /// untouched. Pre-flight runs the identical gate the remote will run at |
| 186 | /// CAS time (`gate.call-sites`), so the result is a prediction that can |
| 187 | /// only be stale, never wrong about the rules — and when it *does* go |
| 188 | /// stale (a racing writer advances the remote between judgment and CAS) |
| 189 | /// the rejected transaction is reported as [`Pushed::Stale`], never as |
| 190 | /// success. Local writes are never blocked by any of this — that is |
| 191 | /// [`mod@crate::preflight`]'s and the local store's concern |
| 192 | /// (`sync.local-advisory`); push is the one place a verdict gates an |
| 193 | /// actual (remote) write. |
| 194 | /// |
| 195 | /// # Errors |
| 196 | /// |
| 197 | /// Propagates pre-flight, ref-store, and object failures. |
| 198 | // @relation(sync.pre-flight, sync.inbox-routing, sync.forge-transfer, scope=function) |
| 199 | pub fn push( |
| 200 | remote_refs: &dyn RefStore, |
| 201 | remote_objects: &(impl Find + Write), |
| 202 | local_objects: &impl Find, |
| 203 | name: &FullName, |
| 204 | local_tip: ObjectId, |
| 205 | author: &MemberId, |
| 206 | ) -> Result<Pushed> { |
| 207 | let update = Update { |
| 208 | name: name.clone(), |
| 209 | new: Some(local_tip), |
| 210 | }; |
| 211 | // Pre-flight needs the proposed objects visible in the store it reads, |
| 212 | // exactly as the hosted CAS would after ingest; copy first, then judge. |
| 213 | copy_closure(local_objects, remote_objects, local_tip)?; |
| 214 | let pf = preflight(remote_refs, remote_objects, &update, author)?; |
| 215 | if !pf.is_pass() { |
| 216 | return Ok(match pf.inbox { |
| 217 | Some(inbox) => Pushed::Inbox(inbox), |
| 218 | None => Pushed::Refused(Box::new(pf)), |
| 219 | }); |
| 220 | } |
| 221 | |
| 222 | let expected = remote_refs |
| 223 | .get(name.as_ref())? |
| 224 | .map_or(Expected::MustNotExist, Expected::MustExistAndMatch); |
| 225 | match advance(remote_refs, name, expected, local_tip)? { |
| 226 | TxOutcome::Applied => Ok(Pushed::Advanced(name.clone())), |
| 227 | TxOutcome::Rejected { name } => Ok(Pushed::Stale(name)), |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | /// Apply one ref advance as a single-edit CAS transaction. |
| 232 | fn advance( |
| 233 | refs: &dyn RefStore, |
| 234 | name: &FullName, |
| 235 | expected: Expected, |
| 236 | new: ObjectId, |
| 237 | ) -> Result<TxOutcome> { |
| 238 | Ok(refs.transaction(&[RefEdit { |
| 239 | name: name.clone(), |
| 240 | expected, |
| 241 | new: Some(new), |
| 242 | }])?) |
| 243 | } |