choir_queue/lib.rs
1//! L2 speculative merge queue (DECISIONS.md D5).
2//!
3//! Dependent speculative pipeline: changes are tested in parallel against the
4//! speculative future state produced by everything queued ahead of them,
5//! "exactly as if they had been tested one at a time." Window sizing follows
6//! a TCP-flow-control-inspired algorithm: start at 20,
7//! +1 per successful merge, halved per failure.
8//!
9//! Conflict policy (DECISIONS.md): a change whose merge conflicts is evicted as
10//! a first-class conflict for its author to resolve; it is never silently
11//! resolved and never blocks the changes behind it, which are retested
12//! against a speculative state without it.
13//!
14//! CI runs behind [`executor::CiExecutor`] (the CI executor seam, D18).
15//! A verdict is four cases rather than a bool, because the queue's
16//! response to a test failure and to a provider fault must differ: only
17//! [`executor::Verdict::Failed`] ejects a change, and ejection takes
18//! everything that transitively depends on it. Tests use
19//! [`executor::Synthetic`]; [`local::LocalRunner`] runs real
20//! subprocesses; production wires the spindle/Firecracker executor.
21//!
22//! # Where this sits
23//!
24//! `docs/architecture.md` is the map of the whole workspace.
25//! This crate is L2, the speculative merge queue (D5).
26//!
27//! It builds on [`choir_hash`], [`choir_merge`], [`choir_oplog`], [`choir_sequencer`], [`choir_store`] and [`choir_view`].
28
29pub mod blast;
30pub mod conform;
31pub mod corpus;
32pub mod differential;
33pub mod differential_ledger;
34pub mod envelope;
35pub mod executor;
36pub mod git;
37pub mod identity;
38pub mod landing;
39pub mod local;
40pub mod memory;
41pub mod remote;
42pub mod speculate;
43pub mod worktree;
44
45use choir_merge::Pipeline;
46use choir_oplog::MemLog;
47use choir_sequencer::Sequencer;
48use speculate::{Speculator, Step};
49
50/// Default speculation window: both the initial size and the ceiling
51/// growth may not pass (a documented default).
52///
53/// It is a ceiling and not only a starting point because the cost model
54/// quoted everywhere else depends on it being one. A round's fleet cost
55/// is about `1 + p*k/2` and its tail about `1 + p*k`, and both are
56/// unbounded statements unless `k` is. Additive increase still runs --
57/// it is what walks the window back up to this ceiling after a
58/// multiplicative decrease -- it just cannot walk past it. TCP calls the
59/// same thing a maximum congestion window; a controller with only the
60/// increase half is not flow control, it is a ramp.
61///
62/// [`MergeQueue::set_max_window`] raises or lowers it per queue.
63pub const DEFAULT_WINDOW: usize = 20;
64
65/// A change submitted to the queue: a full-file edit carrying its own base.
66///
67/// The change's `base` is the content it was authored against (its parent),
68/// which is what the 3-way merge must use; merging against the queue's
69/// advanced tip as base would misread the proposal as reverting work merged
70/// ahead of it. The single-file model is the Phase-0 abstraction; the real
71/// tree diff arrives with the L1 integration.
72#[derive(Debug, Clone)]
73pub struct Change {
74 /// Stable change identifier.
75 pub id: u64,
76 /// Workspace (agent) that produced the change.
77 pub workspace: String,
78 /// The content this change was authored against (its parent state).
79 pub base: String,
80 /// The file content this change proposes.
81 pub proposed: String,
82 /// Ids of queued changes this one declares it depends on.
83 /// Declared-only — the queue never infers dependencies
84 /// from overlap; inference is a separate decision. Empty (the
85 /// default for all existing traffic) keeps the legacy
86 /// halve-and-retest behavior on failure; see [`MergeQueue::drain`].
87 pub depends: Vec<u64>,
88}
89
90/// Why a change left the queue without merging.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum Rejection {
93 /// Merge produced a first-class conflict; author must resolve and resubmit.
94 Conflict,
95 /// CI failed for this change on its speculative state.
96 CiFailure,
97 /// A strategy resolved, but its output edits the speculative state beyond
98 /// what the change proposed: silently reverted
99 /// or injected lines. Treated like a conflict — evicted first-class,
100 /// never landed, never blocking the train — but reported separately
101 /// because the author's change may be fine and the *strategy* at fault.
102 SafetyViolation {
103 /// The strategy whose resolution violated the invariant.
104 strategy: &'static str,
105 /// The unattributable lines, as evidence for escalation.
106 violation: choir_merge::safety::Violation,
107 },
108 /// Ejected because it (transitively) declared a dependency on a
109 /// change whose combined build failed. Not a verdict
110 /// on this change itself: resubmit once the dependency is fixed.
111 DependencyEjection {
112 /// The CI-failing change this one depends on.
113 on: u64,
114 },
115 /// A change with this [`identity::change_identity`] already landed
116 /// through this queue: the resubmission — typically
117 /// the same edit rebased after the train rewrote the tip — is
118 /// refused without re-merging, so it cannot land twice.
119 AlreadyLanded,
120}
121
122/// Outcome of draining a queue.
123#[derive(Debug)]
124pub struct QueueReport {
125 /// Changes merged, in merge order.
126 pub merged: Vec<u64>,
127 /// Changes rejected, with reasons.
128 pub rejected: Vec<(u64, Rejection)>,
129 /// Final repository state after all merges.
130 pub final_state: String,
131 /// Window size after each head decision (for observing TCP dynamics).
132 pub window_trace: Vec<usize>,
133 /// Total CI executions, including retests behind failures.
134 pub ci_runs: usize,
135 /// Total strategy-pipeline invocations. A change resolved from
136 /// [`memory::ResolutionMemory`] does not invoke the pipeline, which
137 /// is what a test counts to prove a replay happened.
138 pub merge_invocations: usize,
139 /// Changes whose conflict was resolved from memory, in train order.
140 /// Every one of them still ran CI: replay produces a candidate,
141 /// never a landing.
142 pub replayed: Vec<u64>,
143 /// Why the drain stopped early without blaming a change (D18).
144 ///
145 /// Set when the executor produced no verdict, or an inconclusive
146 /// one: a provider that could not be reached, a batch whose
147 /// verdict count did not match its jobs, a job that errored or
148 /// timed out. Everything not landed is back in the queue, in
149 /// order, and nothing was rejected on account of it -- which is the
150 /// distinction the old `bool` seam could not make.
151 pub provider_error: Option<String>,
152 /// Check reports the sequencer refused, verbatim (D49).
153 ///
154 /// Empty unless [`MergeQueue::set_check_reporter`] was called. A
155 /// refused report is not a landing decision -- the merge already
156 /// happened or did not on the verdict itself -- but silence about
157 /// it would leave the log missing checks with nothing saying so,
158 /// which is the shape of a bug nobody finds.
159 pub unreported_checks: Vec<String>,
160}
161
162/// Submits one check report, returning the sequencer's refusal if any.
163///
164/// Unsigned, like every other op this queue submits: the queue is an
165/// in-process component of whoever runs it, not a network client with
166/// an identity of its own. A daemon whose policy demands a signature
167/// will refuse these, and that refusal is reported rather than
168/// swallowed -- see [`QueueReport::unreported_checks`].
169fn record_check(
170 handle: &choir_sequencer::SequencerHandle,
171 reporter: &CheckReporter,
172 job: &executor::Job,
173 status: choir_view::CheckStatus,
174 evidence: &str,
175) -> Result<(), String> {
176 let op = choir_view::ViewOp::new(choir_view::OpKind::RecordCheck {
177 subject: job.subject.clone(),
178 name: reporter.name.clone(),
179 status,
180 evidence: evidence.to_string(),
181 reporter: reporter.channel.clone(),
182 target_ref: reporter.target_ref.clone(),
183 });
184 let (payload, sig) = match &reporter.seal {
185 Some(seal) => seal(&reporter.channel, &op),
186 None => (serde_json::to_vec(&op).expect("a ViewOp serializes"), None),
187 };
188 handle
189 .try_submit(&reporter.channel, payload, sig)
190 .map(|_| ())
191}
192
193/// Single-shard speculative merge queue over one file.
194pub struct MergeQueue {
195 base: String,
196 /// How a change is put on top of a state (D5). The text
197 /// implementation is the default; a caller holding a repository
198 /// installs the git one.
199 speculator: Box<dyn Speculator>,
200 window: usize,
201 /// The ceiling `window` may not grow past. Enforced in one place,
202 /// [`MergeQueue::resize`], so that a future growth path cannot
203 /// bypass it by being written somewhere else.
204 max_window: usize,
205 queue: std::collections::VecDeque<Change>,
206 memory: memory::ResolutionMemory,
207 landed: std::collections::BTreeSet<String>,
208 /// Where window changes are reported. Derived data: the queue never
209 /// reads it back, and a journal that dropped every record would
210 /// change no landing decision.
211 journal: Box<dyn choir_sequencer::journal::Journal>,
212 /// What to run for each candidate state (D18).
213 template: JobTemplate,
214 /// How a landing is written into the log (D68). The in-memory
215 /// workspace landing is the default; a caller whose log is the
216 /// source of truth installs one that moves the thing the change
217 /// was proposed to.
218 landing: Box<dyn landing::Landing>,
219 /// Who reports check results, when anybody does. `None` is the
220 /// default and means the queue keeps its verdicts to itself.
221 reporter: Option<CheckReporter>,
222}
223
224/// How the queue writes what CI found into the log (D49).
225///
226/// Off by default, and opt-in for the same reason
227/// [`MergeQueue::set_journal`] is: the queue is a library, the identity
228/// under which a check is reported belongs to whoever is running it,
229/// and inventing a channel name here would put an unattributable
230/// reporter in a signed, ordered record.
231#[derive(Clone)]
232pub struct CheckReporter {
233 /// The channel the check is reported under.
234 pub channel: String,
235 /// The check's name, e.g. `"ci/build"`.
236 pub name: String,
237 /// The ref these subjects are proposed to land on, in the view's
238 /// `<repo>:<refname>` form.
239 ///
240 /// `None` leaves the check node-wide. A commit id names no
241 /// repository, so an unbound check is readable only by node-wide
242 /// readers -- correct, and useless to the repository it is about.
243 pub target_ref: Option<String>,
244 /// How a report is turned into what this log will accept (D68).
245 ///
246 /// `None` submits the op's own bytes unsigned, which is right for a
247 /// queue whose sequencer admits them. A daemon's policy does not:
248 /// it demands a signature, and with `--require-scope` a scope
249 /// naming its log and a recent head. Both live in the payload, so
250 /// this seals the whole submission rather than only signing it --
251 /// a signer alone would sign bytes the policy then rejected for
252 /// their scope, and the refusal would read as a key problem.
253 pub seal: Option<Seal>,
254}
255
256/// Turns a report op into the payload and signature a log will accept.
257///
258/// Shared rather than owned because the queue clones its reporter per
259/// verdict, and `Arc` is what lets one node key back every clone
260/// without the key itself being copied.
261pub type Seal = std::sync::Arc<
262 dyn Fn(&str, &choir_view::ViewOp) -> (Vec<u8>, Option<choir_oplog::Witness>) + Send + Sync,
263>;
264
265impl std::fmt::Debug for CheckReporter {
266 /// Hand-written because [`Seal`] is a function and has no useful
267 /// debug form. It is reported as present or absent, which is the
268 /// part a reader chasing a refused report needs.
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.debug_struct("CheckReporter")
271 .field("channel", &self.channel)
272 .field("name", &self.name)
273 .field("target_ref", &self.target_ref)
274 .field("sealed", &self.seal.is_some())
275 .finish()
276 }
277}
278
279/// How the queue turns a speculative state into a [`executor::Job`].
280///
281/// The queue knows which tree to test; it does not know what "test"
282/// means for a repository, and inventing a default command would make
283/// the wrong one silent. The default template has no command, which
284/// every real executor answers with [`executor::Verdict::Errored`] --
285/// loud, and never mistaken for a change that failed.
286///
287/// A template deliberately cannot name a working directory (D18).
288/// Every member of a batch is tested against a *different* speculative
289/// state, so one directory shared by the batch would test the last
290/// state repeatedly -- well-formed, index-aligned, and wrong. The jobs
291/// this builds therefore leave [`executor::Job::directory`] unset,
292/// which asks the executor to materialize [`executor::Job::subject`]
293/// instead, the way [`crate::worktree::WorktreeRunner`] does for a git
294/// state. It is an invariant of the train rather than a knob, because
295/// there is no setting of it that would be right.
296#[derive(Debug, Clone, Default)]
297pub struct JobTemplate {
298 /// The command, as argv.
299 pub command: Vec<String>,
300 /// Exactly what the child sees.
301 pub environment: std::collections::BTreeMap<String, String>,
302 /// Wall-clock ceiling per job. `None` uses
303 /// [`executor::DEFAULT_DEADLINE`].
304 pub deadline: Option<std::time::Duration>,
305 /// Whether these jobs may write a shared build cache.
306 pub may_write_cache: bool,
307}
308
309impl JobTemplate {
310 /// The job testing `change` applied on the state named by `subject`.
311 ///
312 /// The subject is supplied rather than computed because only the
313 /// [`Speculator`] knows how its states are named; see
314 /// [`Speculator::subject`].
315 #[must_use]
316 pub fn job_for(&self, change: &Change, subject: choir_hash::ContentHash) -> executor::Job {
317 let mut job = executor::Job::new(subject, self.command.clone());
318 job.label = change.id.to_string();
319 job.environment = self.environment.clone();
320 job.may_write_cache = self.may_write_cache;
321 if let Some(d) = self.deadline {
322 job.deadline = d;
323 }
324 job
325 }
326}
327
328impl MergeQueue {
329 /// Moves the speculation window and records why.
330 ///
331 /// A window that shrank is the queue's loudest signal, and a size
332 /// alone cannot say whether it shrank because a build failed or
333 /// grew because a train landed. The cause travels with the number
334 /// so the answer does not have to be inferred from timing.
335 fn resize(&mut self, to: usize, cause: &str) {
336 let from = self.window;
337 // The clamp lives here rather than at the call site because the
338 // call site is a loop body that runs once per landed change: a
339 // ceiling checked anywhere else is a ceiling one caller can
340 // forget. A resize to the size it already is records nothing,
341 // so a queue sitting at the ceiling stays quiet in the journal.
342 self.window = to.min(self.max_window);
343 let to = self.window;
344 if from != to {
345 self.journal
346 .record(choir_sequencer::journal::Event::WindowResize {
347 from,
348 to,
349 cause: cause.to_string(),
350 });
351 }
352 }
353
354 /// Sets the ceiling the speculation window may grow to, and brings
355 /// the current window under it now rather than at the next resize.
356 ///
357 /// The cost of a round is linear in this number and so is the
358 /// number of CI jobs in flight at once, which is why it is a
359 /// deliberate call and not a field a caller can drift upward.
360 ///
361 /// # Panics
362 ///
363 /// If `n` is zero, which would be a queue that speculates on
364 /// nothing and drains forever.
365 pub fn set_max_window(&mut self, n: usize) {
366 assert!(
367 n > 0,
368 "a window of zero would take no change from the queue"
369 );
370 self.max_window = n;
371 if self.window > n {
372 self.resize(n, "max window lowered");
373 }
374 }
375
376 /// The ceiling [`MergeQueue::window`] may grow to.
377 #[must_use]
378 pub fn max_window(&self) -> usize {
379 self.max_window
380 }
381
382 /// Sends window changes to `journal` instead of discarding them.
383 pub fn set_journal(&mut self, journal: Box<dyn choir_sequencer::journal::Journal>) {
384 self.journal = journal;
385 }
386
387 /// Records every verdict CI returns as a [`choir_view::OpKind::RecordCheck`] op.
388 ///
389 /// Every verdict, not only the faults. The map from
390 /// [`executor::Verdict`] to [`choir_view::CheckStatus`] is total, so recording
391 /// some and dropping the rest would put an arbitrary hole in the
392 /// log: a reader finding no check could not tell "it passed" from
393 /// "nobody was reporting". The subject is the speculative tree the
394 /// job actually ran against, which means one change tested at two
395 /// train positions reports against two subjects -- correct, because
396 /// they are two different questions, and the second is the one that
397 /// landed.
398 pub fn set_check_reporter(&mut self, reporter: CheckReporter) {
399 self.reporter = Some(reporter);
400 }
401
402 /// Creates a queue over `base` content with the default window.
403 pub fn new(base: &str) -> Self {
404 Self::with_pipeline(base, Pipeline::default_v1())
405 }
406
407 /// Creates a queue with a caller-supplied strategy pipeline — the D4/D19
408 /// widening seam (structured or LLM slots appended by the caller). Every
409 /// resolution is still safety-checked by
410 /// [`speculate::TextSpeculator`], which is what makes the
411 /// non-deterministic slots admissible at all.
412 pub fn with_pipeline(base: &str, pipeline: Pipeline) -> Self {
413 Self::with_speculator(base, Box::new(speculate::TextSpeculator::new(pipeline)))
414 }
415
416 /// Creates a queue over `base` whose merges, change identities and
417 /// job subjects come from `speculator` (D5).
418 ///
419 /// This is the constructor a caller holding a repository wants:
420 /// `base` is then a commit id rather than a file body, and every
421 /// state the queue moves around is one too. The queue's policy is
422 /// unchanged, which is the point — it was never about text.
423 pub fn with_speculator(base: &str, speculator: Box<dyn Speculator>) -> Self {
424 Self {
425 base: base.to_string(),
426 speculator,
427 window: DEFAULT_WINDOW,
428 max_window: DEFAULT_WINDOW,
429 reporter: None,
430 queue: std::collections::VecDeque::new(),
431 memory: memory::ResolutionMemory::new(),
432 landed: std::collections::BTreeSet::new(),
433 journal: Box::new(choir_sequencer::journal::NullJournal),
434 template: JobTemplate::default(),
435 landing: Box::new(landing::WorkspaceLanding),
436 }
437 }
438
439 /// Installs the job template (D18): what to run for each candidate
440 /// state. Without one, every job is refused by the executor for
441 /// having no command.
442 pub fn set_job_template(&mut self, template: JobTemplate) {
443 self.template = template;
444 }
445
446 /// Installs how a landing is recorded (D68).
447 ///
448 /// The default records a workspace head over whatever log the
449 /// sequencer holds, which is right for a caller mirroring a
450 /// canonical upstream. A caller whose own log decides -- a node --
451 /// installs one that moves the ref the change was proposed to, and
452 /// signs it, because the daemon's policy refuses an unsigned op.
453 pub fn set_landing(&mut self, landing: Box<dyn landing::Landing>) {
454 self.landing = landing;
455 }
456
457 /// Lands `prefix` in order, stopping at the first refusal.
458 ///
459 /// Returns the refusal, leaving everything not landed in `prefix`
460 /// with the refused change at its front. The caller requeues them:
461 /// a landing the log would not take means the base the rest of the
462 /// train was speculating on is not the base the log has, so the
463 /// round is void rather than the change being at fault.
464 fn land_prefix(
465 &mut self,
466 handle: &choir_sequencer::SequencerHandle,
467 prefix: &mut Vec<(Change, String)>,
468 merged: &mut Vec<u64>,
469 cause: &str,
470 ) -> Option<String> {
471 while !prefix.is_empty() {
472 let (change, state) = prefix.remove(0);
473 let subject = self.speculator.subject(&state);
474 if let Err(why) = self.landing.record(handle, &change, subject) {
475 prefix.insert(0, (change, state));
476 return Some(format!("landing refused: {why}"));
477 }
478 self.base = state;
479 self.landed.insert(self.speculator.identity(&change));
480 merged.push(change.id);
481 self.resize(self.window + 1, cause);
482 }
483 None
484 }
485
486 /// Installs a resolution memory (item B): a conflict whose triple it
487 /// remembers is replayed as a candidate instead of re-conflicting.
488 /// The default is an empty memory, which changes nothing.
489 pub fn set_memory(&mut self, memory: memory::ResolutionMemory) {
490 self.memory = memory;
491 }
492
493 /// Seeds a landed change identity (item 4), for a queue picking up
494 /// where an earlier instance left off. `drain` records identities of
495 /// everything it lands through the same set.
496 pub fn mark_landed(&mut self, identity: String) {
497 self.landed.insert(identity);
498 }
499
500 /// Enqueues a change.
501 pub fn submit(&mut self, change: Change) {
502 self.queue.push_back(change);
503 }
504
505 /// Number of changes waiting.
506 pub fn len(&self) -> usize {
507 self.queue.len()
508 }
509
510 /// Whether the queue is empty.
511 pub fn is_empty(&self) -> bool {
512 self.queue.is_empty()
513 }
514
515 /// The current speculation window.
516 ///
517 /// Exposed because [`QueueReport::window_trace`] cannot answer for
518 /// it in every case: a drain that stops on a provider fault breaks
519 /// out before writing the trace, so a test reading only the report
520 /// asserts over an empty vector and passes whatever the window did.
521 /// One did, until a mutation that halved the window on a fault was
522 /// not caught.
523 #[must_use]
524 pub fn window(&self) -> usize {
525 self.window
526 }
527
528 /// Drains through a sequencer over an in-memory log.
529 ///
530 /// For a caller whose repository is not the platform's: the
531 /// sequencer is not optional -- every landing is recorded through
532 /// it, which is what keeps the single-writer order true of a train
533 /// as well as of a push -- but a forge bridge mirrors an upstream
534 /// that is canonical (D21), so the ordering of one round is not a
535 /// claim anybody reads back. Keeping it in memory says that,
536 /// instead of writing a log which would look like a second source
537 /// of truth.
538 pub fn drain_in_memory(&mut self, ci: &mut dyn executor::CiExecutor) -> QueueReport {
539 let sequencer = Sequencer::spawn(Box::new(MemLog::new()));
540 let report = self.drain(ci, &sequencer);
541 sequencer.shutdown();
542 report
543 }
544
545 /// Drains the queue: speculatively merges up to `window` changes, runs CI
546 /// on each against its speculative state, merges the passing prefix, and
547 /// halves the window + retests behind on any failure.
548 ///
549 /// Every merged change is recorded through the single-writer `sequencer`
550 /// (payload = the merged state), preserving the platform's total order.
551 pub fn drain(
552 &mut self,
553 ci: &mut dyn executor::CiExecutor,
554 sequencer: &Sequencer,
555 ) -> QueueReport {
556 let mut merged = Vec::new();
557 let mut rejected = Vec::new();
558 let mut window_trace = Vec::new();
559 let mut ci_runs = 0usize;
560 let mut merge_invocations = 0usize;
561 let mut replayed = Vec::new();
562 let mut provider_error: Option<String> = None;
563 let mut unreported_checks: Vec<String> = Vec::new();
564 let handle = sequencer.handle();
565
566 while !self.queue.is_empty() {
567 // Build the speculative train: up to `window` changes, each merged
568 // onto the state produced by the changes ahead of it.
569 let take = self.window.min(self.queue.len());
570 let mut train: Vec<(Change, String)> = Vec::with_capacity(take);
571 let mut speculative = self.base.clone();
572 let mut train_rejects: Vec<(u64, Rejection)> = Vec::new();
573 for _ in 0..take {
574 let change = self.queue.pop_front().unwrap();
575 // Stable change identity (item 4): a resubmission of an
576 // already-landed change — the same position-independent
577 // edit, however rebased — is refused before any merge
578 // work, so the train neither re-merges nor duplicates it.
579 if self.landed.contains(&self.speculator.identity(&change)) {
580 train_rejects.push((change.id, Rejection::AlreadyLanded));
581 continue;
582 }
583 // Resolution memory (item B): a remembered triple is
584 // replayed without re-invoking the strategy pipeline.
585 // The replay is a *candidate* — it joins the train and
586 // runs the same CI verdict as everything else, and it
587 // can only exist because an author already committed
588 // this exact resolution as a value (item A's link).
589 if let Some(remembered) =
590 self.memory
591 .recall(&change.base, &speculative, &change.proposed)
592 {
593 let next = remembered.to_string();
594 speculative = next.clone();
595 replayed.push(change.id);
596 train.push((change, next));
597 continue;
598 }
599 merge_invocations += 1;
600 match self
601 .speculator
602 .step(&change.base, &speculative, &change.proposed)
603 {
604 Step::Advanced(next) => {
605 speculative = next.clone();
606 train.push((change, next));
607 }
608 Step::Conflict => {
609 // First-class conflict: evict, do not block the train.
610 train_rejects.push((change.id, Rejection::Conflict));
611 }
612 Step::Unsafe {
613 strategy,
614 violation,
615 } => {
616 train_rejects.push((
617 change.id,
618 Rejection::SafetyViolation {
619 strategy,
620 violation,
621 },
622 ));
623 }
624 Step::Unavailable(why) => {
625 // Our fault, not this change's. It goes back at
626 // the head of the queue ahead of the train
627 // members below, so the order the caller
628 // submitted survives the stall.
629 provider_error =
630 Some(format!("speculator `{}`: {why}", self.speculator.name()));
631 self.queue.push_front(change);
632 break;
633 }
634 }
635 }
636 rejected.extend(train_rejects);
637 if provider_error.is_some() {
638 // Nothing here was tested, so nothing here is evidence.
639 // The window is not halved for the same reason it is
640 // not halved on a provider fault: halving answers
641 // changes that failed, and none of these did.
642 for (change, _) in train.into_iter().rev() {
643 self.queue.push_front(change);
644 }
645 break;
646 }
647
648 // "Assume-pass": CI for every train member runs against its own
649 // speculative state, so every member costs a run even when an
650 // earlier member fails (its result is then discarded). The whole
651 // train goes to the executor in one call, which is what lets a
652 // provider be concurrent -- the one-job-at-a-time signature this
653 // replaced made the parallelism this cost model assumes
654 // impossible to implement, at any window size (D18).
655 let jobs: Vec<executor::Job> = train
656 .iter()
657 .map(|(change, state)| {
658 self.template
659 .job_for(change, self.speculator.subject(state))
660 })
661 .collect();
662 ci_runs += jobs.len();
663 let verdicts = match ci.run(&jobs) {
664 Ok(v) if v.len() == jobs.len() => v,
665 // Index alignment is the whole contract of the batch call.
666 // A provider that returns a different count has attributed
667 // somebody's result to somebody else, and no verdict in the
668 // batch can be trusted.
669 Ok(v) => {
670 provider_error = Some(format!(
671 "executor returned {} verdicts for {} jobs",
672 v.len(),
673 jobs.len()
674 ));
675 Vec::new()
676 }
677 Err(e) => {
678 provider_error = Some(e.to_string());
679 Vec::new()
680 }
681 };
682 // What CI said, written down before the queue acts on it:
683 // the record is the executor's answer, not the queue's
684 // response to it. An outage judged nobody, so every job in
685 // the batch gets the same `Errored` for the same reason --
686 // a reader asking what happened to their change gets an
687 // answer instead of an absence.
688 if let Some(rep) = self.reporter.clone() {
689 let reports: Vec<(choir_view::CheckStatus, String)> = match &provider_error {
690 Some(why) => std::iter::repeat_n(
691 (choir_view::CheckStatus::Errored, why.clone()),
692 jobs.len(),
693 )
694 .collect(),
695 None => verdicts
696 .iter()
697 .map(|v| (v.as_check_status(), v.to_string()))
698 .collect(),
699 };
700 for (job, (status, evidence)) in jobs.iter().zip(reports) {
701 if let Err(why) = record_check(&handle, &rep, job, status, &evidence) {
702 unreported_checks.push(why);
703 }
704 }
705 }
706
707 if provider_error.is_some() {
708 // No verdicts at all, so nothing here is evidence about any
709 // change. Requeue the whole train in order and stop. The
710 // window is deliberately not halved: halving is the response
711 // to changes that fail, and none of these did.
712 for (change, _) in train.into_iter().rev() {
713 self.queue.push_front(change);
714 }
715 break;
716 }
717
718 // The first member that did not pass. Only a `Failed` may evict:
719 // ejection is permanent for the change *and* everything that
720 // transitively depends on it, so it is reserved for a verdict
721 // that is actually about the change's content. An inconclusive
722 // one stalls the train instead.
723 let stop_at = verdicts
724 .iter()
725 .position(|v| !matches!(v, executor::Verdict::Passed));
726 if let Some(i) = stop_at {
727 if !verdicts[i].evicts() {
728 let mut prefix: Vec<(Change, String)> = train.drain(..i).collect();
729 provider_error = Some(
730 self.land_prefix(&handle, &mut prefix, &mut merged, "green prefix landed")
731 .unwrap_or_else(|| verdicts[i].to_string()),
732 );
733 for (change, _) in train.into_iter().rev() {
734 self.queue.push_front(change);
735 }
736 for (change, _) in prefix.into_iter().rev() {
737 self.queue.push_front(change);
738 }
739 break;
740 }
741 }
742 let failure_at = stop_at;
743
744 match failure_at {
745 None => {
746 // Whole train is green: merge it all.
747 let mut prefix = train;
748 if let Some(why) =
749 self.land_prefix(&handle, &mut prefix, &mut merged, "train landed clean")
750 {
751 provider_error = Some(why);
752 for (change, _) in prefix.into_iter().rev() {
753 self.queue.push_front(change);
754 }
755 break;
756 }
757 }
758 Some(i) => {
759 // Merge the green prefix, reject the failure, requeue the
760 // rest for retesting against a state without the failure.
761 let mut prefix: Vec<(Change, String)> = train.drain(..i).collect();
762 if let Some(why) =
763 self.land_prefix(&handle, &mut prefix, &mut merged, "green prefix landed")
764 {
765 // The round is void, so nobody is blamed for it --
766 // including the change CI failed on, which was
767 // tested against a state the log has not accepted.
768 provider_error = Some(why);
769 for (change, _) in train.into_iter().rev() {
770 self.queue.push_front(change);
771 }
772 for (change, _) in prefix.into_iter().rev() {
773 self.queue.push_front(change);
774 }
775 break;
776 }
777 let (failed, _) = train.remove(0);
778 let failed_id = failed.id;
779 rejected.push((failed_id, Rejection::CiFailure));
780
781 // Dependency-aware ejection: the failure ejects
782 // exactly the failing change plus everything that
783 // (transitively) declared a dependency on it, and the
784 // window is not halved, because that blast radius is
785 // named by the declarations rather than guessed by
786 // shrinking the train. Survivors are requeued in
787 // order and land in this same drain.
788 //
789 // The mode is chosen by whether anything actually
790 // depends on *this* failure, which is why the set is
791 // computed before the branch rather than after it.
792 // Declarations are a lower bound on a blast radius
793 // and never the whole of it, since an undeclared
794 // semantic break is still possible; so a failure
795 // nobody declared a dependency on leaves the queue
796 // with no information about who else is bad, which is
797 // the case halving exists for. The rule this replaces
798 // asked whether declarations existed *anywhere* in
799 // the train or the queue, so one change declaring one
800 // dependency disabled halving for the whole drain.
801 // `tests/it/cost.rs` is what measures the difference,
802 // and under the old rule it read 4.81x against 1.64x
803 // at a 10% failure rate scattered across the train,
804 // with sixteen fewer changes landed at 25%.
805 let mut ejected = std::collections::BTreeSet::from([failed_id]);
806 loop {
807 let dependent = |c: &Change| {
808 !ejected.contains(&c.id)
809 && c.depends.iter().any(|d| ejected.contains(d))
810 };
811 let next: Vec<u64> = train
812 .iter()
813 .map(|(c, _)| c)
814 .chain(self.queue.iter())
815 .filter(|c| dependent(c))
816 .map(|c| c.id)
817 .collect();
818 if next.is_empty() {
819 break;
820 }
821 ejected.extend(next);
822 }
823 if ejected.len() > 1 {
824 for (change, _) in train.into_iter().rev() {
825 if ejected.contains(&change.id) {
826 rejected.push((
827 change.id,
828 Rejection::DependencyEjection { on: failed_id },
829 ));
830 } else {
831 self.queue.push_front(change);
832 }
833 }
834 let waiting = std::mem::take(&mut self.queue);
835 for change in waiting {
836 if ejected.contains(&change.id) {
837 rejected.push((
838 change.id,
839 Rejection::DependencyEjection { on: failed_id },
840 ));
841 } else {
842 self.queue.push_back(change);
843 }
844 }
845 } else {
846 self.resize((self.window / 2).max(1), "combined build failed");
847 for (change, _) in train.into_iter().rev() {
848 self.queue.push_front(change);
849 }
850 }
851 }
852 }
853 window_trace.push(self.window);
854 }
855
856 QueueReport {
857 merged,
858 rejected,
859 final_state: self.base.clone(),
860 window_trace,
861 ci_runs,
862 merge_invocations,
863 replayed,
864 provider_error,
865 unreported_checks,
866 }
867 }
868}
869
870/// Convenience: drain `changes` through a fresh queue + sequencer and return
871/// the report plus the sequencer's op count (which must equal merged count).
872pub fn run_batch(
873 base: &str,
874 changes: Vec<Change>,
875 ci: &mut dyn executor::CiExecutor,
876) -> (QueueReport, u64) {
877 let mut queue = MergeQueue::new(base);
878 for c in changes {
879 queue.submit(c);
880 }
881 let sequencer = Sequencer::spawn(Box::new(MemLog::new()));
882 let report = queue.drain(ci, &sequencer);
883 let log = sequencer.shutdown();
884 (report, log.len())
885}