crates/cli/git-ents/tests/reconcile.rs
reconcile.rshistorycomment on this file
| 1 | //! The phase-6 exit criterion, run literally: "the boot-time |
| 2 | //! reconciliation scan regenerates obligations correctly after a `kill -9` |
| 3 | //! of the in-memory queue." |
| 4 | //! |
| 5 | //! [`HostedRoot::open`] runs [`ents_receive::reconcile`] at open time |
| 6 | //! (`receive.reconstructible`) to populate its in-memory `EventSink`; this |
| 7 | //! test defines an effect, advances a branch into its trigger set, opens a |
| 8 | //! `HostedRoot` once, then *drops it and opens a fresh one* against the |
| 9 | //! same on-disk repository — standing in for a `kill -9` of whatever |
| 10 | //! process held the in-memory queue, since nothing here persists across |
| 11 | //! that boundary except repository state itself. |
| 12 | #![allow( |
| 13 | clippy::expect_used, |
| 14 | clippy::indexing_slicing, |
| 15 | clippy::string_slice, |
| 16 | reason = "integration test" |
| 17 | )] |
| 18 | |
| 19 | mod common; |
| 20 | |
| 21 | use ents_model::{Effect, ResultRecord, Status}; |
| 22 | use git_ents::root::HostedRoot; |
| 23 | use gix_object::{Commit, Kind, Write as _}; |
| 24 | use gix_ref_store::{Expected, RefEdit, RefStore}; |
| 25 | |
| 26 | /// Write an empty-tree commit and move `refname` to it directly through |
| 27 | /// the ref store — a branch ref needs no signature at all |
| 28 | /// (`gate.principled-split`: code refs keep transport-level authorization, |
| 29 | /// never the tip invariant), so this bypasses `receive` entirely, the way |
| 30 | /// a plain `git commit` would. |
| 31 | fn advance_branch(root: &HostedRoot, refname: &str, seconds: i64) -> gix_hash::ObjectId { |
| 32 | let empty_tree = root |
| 33 | .objects |
| 34 | .write(&gix_object::Tree::empty()) |
| 35 | .expect("tree"); |
| 36 | let actor = gix::actor::Signature { |
| 37 | name: "test".into(), |
| 38 | email: "test@ents.test".into(), |
| 39 | time: gix::date::Time { seconds, offset: 0 }, |
| 40 | }; |
| 41 | let commit = Commit { |
| 42 | tree: empty_tree, |
| 43 | parents: Default::default(), |
| 44 | author: actor.clone(), |
| 45 | committer: actor, |
| 46 | encoding: None, |
| 47 | message: "advance".into(), |
| 48 | extra_headers: Vec::new(), |
| 49 | }; |
| 50 | let mut raw = Vec::new(); |
| 51 | gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize"); |
| 52 | let oid = root.objects.write_buf(Kind::Commit, &raw).expect("write"); |
| 53 | |
| 54 | let name: gix::refs::FullName = refname.try_into().expect("valid refname"); |
| 55 | root.refs |
| 56 | .transaction(&[RefEdit { |
| 57 | name, |
| 58 | expected: Expected::Any, |
| 59 | new: Some(oid), |
| 60 | }]) |
| 61 | .expect("moves the ref"); |
| 62 | oid |
| 63 | } |
| 64 | |
| 65 | fn define_effect(root: &HostedRoot, name: &str, trigger: &str) { |
| 66 | let tree = facet_git_tree::serialize_into( |
| 67 | &Effect { |
| 68 | name: name.to_owned(), |
| 69 | trigger: trigger.to_owned(), |
| 70 | toolchains: vec![], |
| 71 | run: "true".to_owned(), |
| 72 | }, |
| 73 | &root.objects, |
| 74 | ) |
| 75 | .expect("serialize effect"); |
| 76 | let commit = Commit { |
| 77 | tree, |
| 78 | parents: Default::default(), |
| 79 | author: gix::actor::Signature { |
| 80 | name: "test".into(), |
| 81 | email: "test@ents.test".into(), |
| 82 | time: gix::date::Time { |
| 83 | seconds: 1, |
| 84 | offset: 0, |
| 85 | }, |
| 86 | }, |
| 87 | committer: gix::actor::Signature { |
| 88 | name: "test".into(), |
| 89 | email: "test@ents.test".into(), |
| 90 | time: gix::date::Time { |
| 91 | seconds: 1, |
| 92 | offset: 0, |
| 93 | }, |
| 94 | }, |
| 95 | encoding: None, |
| 96 | message: "define effect".into(), |
| 97 | extra_headers: Vec::new(), |
| 98 | }; |
| 99 | let mut raw = Vec::new(); |
| 100 | gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize"); |
| 101 | let oid = root.objects.write_buf(Kind::Commit, &raw).expect("write"); |
| 102 | |
| 103 | let ref_name = ents_model::namespace::effect_ref(name).expect("valid"); |
| 104 | root.refs |
| 105 | .transaction(&[RefEdit { |
| 106 | name: ref_name, |
| 107 | expected: Expected::Any, |
| 108 | new: Some(oid), |
| 109 | }]) |
| 110 | .expect("moves the ref"); |
| 111 | } |
| 112 | |
| 113 | /// The literal phase-6 exit test. |
| 114 | // @relation(receive.reconstructible, scope=function, role=Verifies) |
| 115 | #[test] |
| 116 | fn boot_time_reconciliation_survives_a_simulated_crash() { |
| 117 | let fixture = common::Fixture::new_bare(10); |
| 118 | |
| 119 | // Set up repository state entirely before any `HostedRoot` exists — |
| 120 | // "queue" state here is purely derived, never authored directly. |
| 121 | { |
| 122 | let root = HostedRoot::open(fixture.path()).expect("opens"); |
| 123 | define_effect(&root, "unit", "rev(refs/heads/main)"); |
| 124 | advance_branch(&root, "refs/heads/main", 100); |
| 125 | // This first root's own boot scan already saw the commit (it |
| 126 | // opened after the effect existed but the commit came after)... |
| 127 | } |
| 128 | // ...so open fresh once more with everything in place, exactly as a |
| 129 | // process starting for the first time against this repository would. |
| 130 | let expected_oid = { |
| 131 | let root = HostedRoot::open(fixture.path()).expect("second open reconciles fresh"); |
| 132 | let pending = root.events.pending(); |
| 133 | assert_eq!(pending.len(), 1, "exactly one outstanding obligation"); |
| 134 | assert_eq!(pending[0].0, "unit"); |
| 135 | pending[0].1 |
| 136 | }; |
| 137 | |
| 138 | // Simulate `kill -9` of the in-memory queue: drop this handle (its |
| 139 | // `MemoryEventSink` goes with it — nothing persists it) and open an |
| 140 | // entirely fresh `HostedRoot` against the same on-disk repository. |
| 141 | let root = HostedRoot::open(fixture.path()).expect("reconciles again, from scratch"); |
| 142 | let pending = root.events.pending(); |
| 143 | assert_eq!( |
| 144 | pending, |
| 145 | vec![("unit".to_owned(), expected_oid)], |
| 146 | "the boot-time scan regenerates the exact same obligation from repository state alone" |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | /// Once a result exists for a commit, reconciliation must not re-list it — |
| 151 | /// otherwise a restarted worker would re-run every effect it had ever |
| 152 | /// completed. |
| 153 | // @relation(receive.reconstructible, query.workset, scope=function, role=Verifies) |
| 154 | #[test] |
| 155 | fn reconciliation_excludes_already_resulted_commits() { |
| 156 | let fixture = common::Fixture::new_bare(11); |
| 157 | let root = HostedRoot::open(fixture.path()).expect("opens"); |
| 158 | define_effect(&root, "unit", "rev(refs/heads/main)"); |
| 159 | let oid = advance_branch(&root, "refs/heads/main", 100); |
| 160 | |
| 161 | // Record a result directly (bypassing `write_result`'s signing |
| 162 | // requirement) — a full `ResultRecord`, not a bare `Status`, since |
| 163 | // `Evaluator::outstanding` strictly decodes the results tree as one |
| 164 | // (`model.result-identity`). |
| 165 | let short = &oid.to_string()[..12]; |
| 166 | let status_tree = facet_git_tree::serialize_into( |
| 167 | &ResultRecord::new("unit", oid, Status::Pass), |
| 168 | &root.objects, |
| 169 | ) |
| 170 | .expect("serialize"); |
| 171 | let commit = gix_object::Commit { |
| 172 | tree: status_tree, |
| 173 | parents: Default::default(), |
| 174 | author: gix::actor::Signature { |
| 175 | name: "worker".into(), |
| 176 | email: "worker@ents.test".into(), |
| 177 | time: gix::date::Time { |
| 178 | seconds: 200, |
| 179 | offset: 0, |
| 180 | }, |
| 181 | }, |
| 182 | committer: gix::actor::Signature { |
| 183 | name: "worker".into(), |
| 184 | email: "worker@ents.test".into(), |
| 185 | time: gix::date::Time { |
| 186 | seconds: 200, |
| 187 | offset: 0, |
| 188 | }, |
| 189 | }, |
| 190 | encoding: None, |
| 191 | message: "result".into(), |
| 192 | extra_headers: Vec::new(), |
| 193 | }; |
| 194 | let mut raw = Vec::new(); |
| 195 | gix_object::WriteTo::write_to(&commit, &mut raw).expect("serialize"); |
| 196 | let result_oid = root.objects.write_buf(Kind::Commit, &raw).expect("write"); |
| 197 | let result_ref = ents_model::namespace::result_ref("unit", short).expect("valid"); |
| 198 | root.refs |
| 199 | .transaction(&[RefEdit { |
| 200 | name: result_ref, |
| 201 | expected: Expected::Any, |
| 202 | new: Some(result_oid), |
| 203 | }]) |
| 204 | .expect("records the result"); |
| 205 | |
| 206 | let root = HostedRoot::open(fixture.path()).expect("reconciles fresh"); |
| 207 | assert!( |
| 208 | root.events.pending().is_empty(), |
| 209 | "a commit with a recorded result must never be re-enqueued" |
| 210 | ); |
| 211 | } |