Skip to main content

choir_sequencer/
lib.rs

1//! Single-writer per-repo sequencer (DECISIONS.md D2): the Phase-0 spike.
2//!
3//! One OS thread owns the op log; all workspaces submit ops through cloned
4//! handles and receive the assigned (seq, hash) synchronously. This is the
5//! in-process second implementation required by the actor-runtime seam (D3);
6//! the Rivet-backed implementation must pass the same conformance tests.
7//!
8//! # Examples
9//!
10//! ```
11//! use choir_oplog::MemLog;
12//! use choir_sequencer::Sequencer;
13//!
14//! let sequencer = Sequencer::spawn(Box::new(MemLog::new()));
15//! let handle = sequencer.handle();
16//! let accepted = handle.submit("agent-1", b"op".to_vec());
17//! assert_eq!(accepted.seq, 0);
18//! let log = sequencer.shutdown();
19//! assert_eq!(log.len(), 1);
20//! ```
21//!
22//! # Where this sits
23//!
24//! `docs/architecture.md` is the map of the whole workspace.
25//! This crate is the single writer itself (D2): one thread per repository decides the total order.
26//!
27//! It builds on [`choir_oplog`].
28
29pub mod fairness;
30pub mod journal;
31pub mod lag;
32
33use choir_oplog::{ContentHash, OpEntry, OpLog, Witness, FORMAT_VERSION};
34use lag::LagMeter;
35use std::sync::atomic::{AtomicBool, Ordering};
36use std::sync::mpsc;
37use std::sync::Arc;
38use std::thread::JoinHandle;
39use std::time::{Duration, Instant};
40
41/// An operation offered to the sequencer on an attribution channel.
42pub struct Submission {
43    /// Signature-covered collaboration channel submitting the op.
44    pub channel: String,
45    /// Opaque operation body, stored as [`OpEntry::payload`].
46    pub payload: Vec<u8>,
47    /// Author signature over `(channel, payload)`; carried into
48    /// [`OpEntry::author_sig`]. `None` for unsigned (pre-L8) clients.
49    pub author_sig: Option<Witness>,
50}
51
52/// Admission policy run inside the writer thread, before an op is
53/// ordered. This is where L8 signature verification and L1 view CAS
54/// plug into L2 without the sequencer depending on either layer.
55pub trait SubmitPolicy: Send {
56    /// Accepts or rejects a submission. Runs on the writer thread, so a
57    /// stateful policy (e.g. one holding a cached materialized view) sees
58    /// submissions in their final total order.
59    ///
60    /// # Errors
61    ///
62    /// A human-readable rejection reason, returned to the submitter.
63    fn check(&mut self, sub: &Submission) -> Result<(), String>;
64
65    /// Observes an entry that was just appended (for cached-state
66    /// policies to fold). Default: ignore.
67    ///
68    /// `hash` is the entry's content hash, which the sequencer has just
69    /// computed to answer the submitter. It is passed rather than left
70    /// to the policy to recompute: a policy that indexes entries by hash
71    /// would otherwise re-serialize every entry on the write path, which
72    /// the allocation budget already caught once.
73    fn accepted(&mut self, _entry: &OpEntry, _hash: &ContentHash) {}
74
75    /// What the last [`SubmitPolicy::check`] identified about its
76    /// submission: `(actor_id, op_type)`, for the journal.
77    ///
78    /// Called by the sequencer immediately after `check`, on the same
79    /// thread, so a policy that already derived these while checking
80    /// hands them over rather than deriving them twice. That matters:
81    /// `actor_id` comes from verifying a signature, and re-verifying
82    /// every op to describe it would double the cost of the one step
83    /// that is genuinely expensive.
84    ///
85    /// The default answers nothing, which is honest for a policy that
86    /// never looked. A journal then records the decision without an
87    /// author, and an entry with a null `actor_id` says exactly that.
88    fn subject(&self) -> (Option<String>, Option<String>) {
89        (None, None)
90    }
91}
92
93/// The default policy: everything is admitted (localhost/dev shape).
94pub struct AdmitAll;
95
96impl SubmitPolicy for AdmitAll {
97    fn check(&mut self, _sub: &Submission) -> Result<(), String> {
98        Ok(())
99    }
100}
101
102/// The sequencer's acknowledgement of a durably ordered op.
103#[derive(Debug)]
104pub struct Accepted {
105    /// Position assigned in the total order.
106    pub seq: u64,
107    /// Content hash of the appended entry (the new log head).
108    pub hash: ContentHash,
109    /// Time from dequeue to append; the merge-decision latency the Phase-0
110    /// gate measures (<100 ms target, CI excluded).
111    pub decision_latency: Duration,
112}
113
114/// Most ops admitted before the writer stops draining and syncs.
115///
116/// Bounds the latency a submitter can inherit from the ops queued ahead
117/// of it: without a cap, a sustained burst would keep the drain loop fed
118/// and the batch would never close. 256 is a starting point chosen to sit
119/// far under the 100 ms decision-latency gate at the measured per-op cost,
120/// not a tuned value.
121const MAX_BATCH: usize = 256;
122
123/// Microseconds, saturating.
124///
125/// The journal carries an integer rather than a float so a `jq` filter
126/// can compare and sum it without surprises. `u64` microseconds covers
127/// half a million years; a decision that outlived that has other
128/// problems.
129fn as_micros(d: Duration) -> u64 {
130    u64::try_from(d.as_micros()).unwrap_or(u64::MAX)
131}
132
133/// One admitted op waiting on the batch's durability barrier: where to
134/// answer, what to answer with, and when it was dequeued (so the ack can
135/// be measured through the barrier, not just up to the append).
136type PendingAck = (mpsc::Sender<Result<Accepted, String>>, Accepted, Instant);
137
138/// Fills `turns` with the order to serve one drained batch in: one op
139/// from each actor in rotation, until every op has a turn.
140///
141/// The quota is what *bounds* how long one actor can make another wait.
142/// This is the finer half of the same idea, and it only matters within a
143/// single wake-up: when the writer surfaces to find one actor's burst
144/// interleaved with somebody's single op, arrival order would serve the
145/// burst first, and rotation serves the single op second instead of
146/// last. Per-actor order is preserved exactly — each bucket is FIFO —
147/// so an actor's own ops never overtake each other.
148///
149/// The single-actor case, which is most of them and every benchmark,
150/// takes the identity path and allocates nothing. That is deliberate:
151/// `alloc_budget` gates the per-op allocation count, and a scheduler
152/// that charged every op for a fairness decision nobody needed would be
153/// paying to solve a problem it does not have.
154fn round_robin(
155    queued: &[Option<Queued>],
156    buckets: &mut Vec<(Arc<str>, std::collections::VecDeque<usize>)>,
157    turns: &mut Vec<usize>,
158) {
159    turns.clear();
160    // One actor (or none): arrival order already is round-robin.
161    let mut lone = true;
162    let mut first: Option<&Arc<str>> = None;
163    for item in queued.iter().flatten() {
164        match first {
165            None => first = Some(&item.1),
166            Some(seen) if Arc::ptr_eq(seen, &item.1) => {}
167            Some(_) => {
168                lone = false;
169                break;
170            }
171        }
172    }
173    if lone {
174        // Filled slots only. Arrival order is already the answer here,
175        // but handing back an index to a slot that holds nothing would
176        // make the caller's `take()` the only thing standing between
177        // this and a panic.
178        turns.extend(
179            queued
180                .iter()
181                .enumerate()
182                .filter_map(|(index, item)| item.as_ref().map(|_| index)),
183        );
184        return;
185    }
186
187    buckets.clear();
188    for (index, item) in queued.iter().enumerate() {
189        let Some((_, actor, _)) = item else { continue };
190        // Pointer equality, not string equality: the same actor hands
191        // back the same interned `Arc` (see [`fairness::Quotas::admit`]),
192        // so this compares a word rather than a string. A key re-interned
193        // mid-batch would split into two buckets, which costs that actor
194        // a slightly larger share of one batch and nothing else.
195        match buckets.iter_mut().find(|(key, _)| Arc::ptr_eq(key, actor)) {
196            Some((_, indexes)) => indexes.push_back(index),
197            None => {
198                let mut indexes = std::collections::VecDeque::new();
199                indexes.push_back(index);
200                buckets.push((actor.clone(), indexes));
201            }
202        }
203    }
204    while turns.len() < queued.len() {
205        let before = turns.len();
206        for (_, indexes) in buckets.iter_mut() {
207            if let Some(index) = indexes.pop_front() {
208                turns.push(index);
209            }
210        }
211        // Every bucket is empty; the remainder are `None` slots. Without
212        // this the loop would spin forever on a batch containing them.
213        if turns.len() == before {
214            break;
215        }
216    }
217}
218
219// `Submit` is ~208 bytes and `Shutdown` carries none, which is what the
220// lint objects to. Its premise does not hold here: these are transient
221// channel messages, at most `MAX_BATCH` in flight, and `Shutdown` is sent
222// exactly once per sequencer lifetime — so the "wasted" space is one
223// message, once, not a cost paid per stored value.
224//
225// Boxing the payload to even them out would put a heap allocation on the
226// write path, which is the one path this workspace measures allocations
227// on (`choir-node/tests/alloc_budget.rs`). Paying that on every op to
228// save 208 bytes on a message sent at shutdown is the wrong trade.
229//
230// It first fired when D45 added `Witness::credential_key`, taking
231// `Submission` past the 200-byte default threshold.
232#[allow(clippy::large_enum_variant)]
233enum Command {
234    /// The op, the interned actor bucket holding its quota slot (see
235    /// [`fairness`]), and where to answer.
236    Submit(Submission, Arc<str>, mpsc::Sender<Result<Accepted, String>>),
237    Shutdown,
238}
239
240/// One drained command awaiting its turn in the writer's round-robin.
241type Queued = (Submission, Arc<str>, mpsc::Sender<Result<Accepted, String>>);
242
243/// Cloneable client handle; one per workspace/agent.
244#[derive(Clone)]
245pub struct SequencerHandle {
246    tx: mpsc::Sender<Command>,
247    poisoned: Arc<AtomicBool>,
248    quotas: fairness::Quotas,
249}
250
251impl SequencerHandle {
252    /// Whether a durability barrier has failed, after which this writer
253    /// refuses every submission.
254    ///
255    /// Exposed rather than acted on. The sequencer's job is to say that it
256    /// can no longer promise durability; deciding what a *node* does about
257    /// that — keep serving reads, exit so supervision restarts it, page
258    /// someone — is a lifecycle policy, and a library linked by every
259    /// embedder including the test suite is the wrong place to make it.
260    ///
261    /// The daemon polls this and exits, so launchd's `KeepAlive` restarts
262    /// into the same replay path a `kill -9` already exercises. Without an
263    /// observer, fail-closed is invisible to supervision: the process
264    /// stays up refusing everything, and a transient fsync error becomes
265    /// permanent downtime that looks like uptime.
266    #[must_use]
267    pub fn durability_failed(&self) -> bool {
268        self.poisoned.load(Ordering::Relaxed)
269    }
270
271    /// Blocks until the sequencer has durably ordered the op. Unsigned
272    /// convenience wrapper over [`SequencerHandle::try_submit`]; only
273    /// valid under a policy that admits unsigned ops.
274    ///
275    /// # Panics
276    ///
277    /// Panics if the sequencer thread has shut down or the policy
278    /// rejects the op.
279    pub fn submit(&self, channel: &str, payload: Vec<u8>) -> Accepted {
280        self.try_submit(channel, payload, None)
281            .expect("policy admits this op")
282    }
283
284    /// Blocks until the sequencer has ordered the op or rejected it.
285    ///
286    /// # Errors
287    ///
288    /// The policy's rejection reason, or a [`fairness`] rejection naming
289    /// the quota when this actor already has too many ops awaiting a
290    /// decision. The quota is checked here, on the calling thread, before
291    /// anything is sent: an over-quota submitter is *answered*, never
292    /// parked, so a client always has something to react to.
293    ///
294    /// # Panics
295    ///
296    /// Panics if the sequencer thread has already shut down.
297    pub fn try_submit(
298        &self,
299        channel: &str,
300        payload: Vec<u8>,
301        author_sig: Option<Witness>,
302    ) -> Result<Accepted, String> {
303        let sub = Submission {
304            channel: channel.to_string(),
305            payload,
306            author_sig,
307        };
308        let actor = self.quotas.admit(fairness::Quotas::actor_of(&sub))?;
309        let (reply_tx, reply_rx) = mpsc::channel();
310        self.tx
311            .send(Command::Submit(sub, actor, reply_tx))
312            .expect("sequencer thread alive");
313        reply_rx.recv().expect("sequencer replies before dropping")
314    }
315
316    /// The per-actor admission quotas this handle submits through.
317    #[must_use]
318    pub fn quotas(&self) -> fairness::Quotas {
319        self.quotas.clone()
320    }
321
322    /// Offers every submission before waiting for any reply, so the whole
323    /// group is already queued when the writer next drains — and therefore
324    /// shares one durability barrier instead of one each.
325    ///
326    /// This is the difference between an N-op request costing N fsyncs and
327    /// costing `ceil(N / MAX_BATCH)`. Calling [`SequencerHandle::try_submit`]
328    /// in a loop cannot achieve it: each call blocks until its own reply,
329    /// so the queue is empty every time the writer looks and every op
330    /// becomes its own batch.
331    ///
332    /// Results are returned in submission order, one per input. Each is
333    /// independent: a rejection does not abort the rest, matching the
334    /// per-op semantics `/api/submit-batch` already promised.
335    ///
336    /// # Panics
337    ///
338    /// Panics if the sequencer thread has already shut down.
339    pub fn try_submit_many(&self, subs: Vec<Submission>) -> Vec<Result<Accepted, String>> {
340        // A reply channel per op rather than one shared channel: replies
341        // are not ordered with respect to each other, because a rejection
342        // is answered immediately while an admitted op waits for the
343        // barrier. Separate channels keep the result order matching the
344        // input order by construction rather than by assumption.
345        // The collect is the whole mechanism, not an accident: it forces
346        // every submission to be SENT before the first reply is awaited.
347        // Consumed lazily this would send one, block on its reply, send
348        // the next -- exactly the per-op blocking that made the batch
349        // endpoint pay one fsync per op.
350        #[allow(clippy::needless_collect)]
351        let waiting: Vec<Result<mpsc::Receiver<Result<Accepted, String>>, String>> = subs
352            .into_iter()
353            .map(|sub| {
354                // An over-quota member is refused in place rather than
355                // failing the group: the endpoint's documented semantics
356                // are per-op, and one member's ceiling is not the
357                // group's problem.
358                let actor = self.quotas.admit(fairness::Quotas::actor_of(&sub))?;
359                let (reply_tx, reply_rx) = mpsc::channel();
360                self.tx
361                    .send(Command::Submit(sub, actor, reply_tx))
362                    .expect("sequencer thread alive");
363                Ok(reply_rx)
364            })
365            .collect();
366        waiting
367            .into_iter()
368            .map(|slot| match slot {
369                Ok(rx) => rx.recv().expect("sequencer replies before dropping"),
370                Err(refused) => Err(refused),
371            })
372            .collect()
373    }
374}
375
376/// Owns the single writer thread; the only component allowed to append to
377/// the repo's [`OpLog`].
378pub struct Sequencer {
379    tx: mpsc::Sender<Command>,
380    /// Set by the writer when a durability barrier fails; read by the
381    /// daemon through [`SequencerHandle::durability_failed`].
382    poisoned: Arc<AtomicBool>,
383    /// Written by the writer for every accepted op; read and drained by
384    /// the daemon through [`Sequencer::lag`].
385    lag: Arc<LagMeter>,
386    /// Per-actor admission ceilings, shared with every handle and with
387    /// the writer thread that releases their slots.
388    quotas: fairness::Quotas,
389    thread: Option<JoinHandle<Box<dyn OpLog>>>,
390}
391
392impl Sequencer {
393    /// Starts the writer thread over `log` with the [`AdmitAll`] policy.
394    pub fn spawn(log: Box<dyn OpLog>) -> Self {
395        Self::spawn_with_policy(log, Box::new(AdmitAll))
396    }
397
398    /// Starts the writer thread over `log`; every submission passes
399    /// through `policy` before it is ordered.
400    ///
401    /// Records nothing. Use [`Sequencer::spawn_with_journal`] to observe
402    /// decisions.
403    pub fn spawn_with_policy(log: Box<dyn OpLog>, policy: Box<dyn SubmitPolicy>) -> Self {
404        Self::spawn_with_journal(log, policy, Box::new(journal::NullJournal))
405    }
406
407    /// [`Sequencer::spawn_with_policy`], recording every decision to
408    /// `journal`.
409    ///
410    /// The journal is derived data: it is written after the decision is
411    /// made, never consulted, and its failure cannot refuse an op. See
412    /// [`journal`] for why the I/O belongs on another thread.
413    pub fn spawn_with_journal(
414        mut log: Box<dyn OpLog>,
415        mut policy: Box<dyn SubmitPolicy>,
416        journal: Box<dyn journal::Journal>,
417    ) -> Self {
418        let (tx, rx) = mpsc::channel::<Command>();
419        let poisoned = Arc::new(AtomicBool::new(false));
420        let writer_flag = poisoned.clone();
421        let lag = Arc::new(LagMeter::new());
422        let writer_lag = lag.clone();
423        let quotas = fairness::Quotas::default();
424        let writer_quotas = quotas.clone();
425        let thread = std::thread::spawn(move || {
426            let quotas = writer_quotas;
427            // Ordered, then made durable, then acknowledged. Everything
428            // admitted in one pass through the loop shares a single
429            // `sync`, so the fsync cost is paid once per batch rather
430            // than once per op.
431            let mut acks: Vec<PendingAck> = Vec::new();
432            let mut stopping = false;
433            // Set by a failed durability barrier and never cleared. See
434            // the refusal in the admit path below for why this is
435            // one-way: the alternative is ordering ops that may not
436            // survive, which is the bug this whole change exists to close.
437            //
438            // The sequencer publishes this state through `writer_flag`;
439            // the daemon observes it and exits for supervision. The
440            // library itself only refuses subsequent work, which keeps
441            // the lifecycle choice with the embedder.
442            let mut durability_failed = false;
443            // Reused across wake-ups. A fresh buffer per batch would put
444            // an allocation on the writer's hot path for no reason.
445            let mut queued: Vec<Option<Queued>> = Vec::new();
446            let mut turns: Vec<usize> = Vec::new();
447            let mut buckets: Vec<(Arc<str>, std::collections::VecDeque<usize>)> = Vec::new();
448            while let Ok(first) = rx.recv() {
449                let mut cmd = Some(first);
450                queued.clear();
451                // Admit the woken command, then drain whatever else is
452                // already queued behind it. Nothing is waited for: an idle
453                // sequencer still batches exactly one op, so a lone
454                // submitter pays no added latency.
455                while let Some(current) = cmd.take() {
456                    match current {
457                        Command::Shutdown => {
458                            stopping = true;
459                            break;
460                        }
461                        Command::Submit(sub, actor, reply) => {
462                            queued.push(Some((sub, actor, reply)));
463                        }
464                    }
465                    if queued.len() >= MAX_BATCH {
466                        break;
467                    }
468                    cmd = rx.try_recv().ok();
469                }
470                // Depth is counted per wake-up rather than sampled on a
471                // timer: this is the writer's own view of how much was
472                // already waiting behind it, which is the number a
473                // backlog question is actually asking about.
474                let drained = queued.len();
475                round_robin(&queued, &mut buckets, &mut turns);
476                for &index in &turns {
477                    let Some((sub, actor, reply)) = queued[index].take() else {
478                        continue;
479                    };
480                    let started = Instant::now();
481                    // Fail closed. Once a barrier has failed this writer
482                    // can no longer promise anything, so it refuses
483                    // *before* appending rather than ordering ops it
484                    // cannot persist.
485                    if durability_failed {
486                        let _ = reply.send(Err(
487                            "log is not durable: writer stopped accepting".to_string()
488                        ));
489                        quotas.release(&actor);
490                        continue;
491                    }
492                    // Cloned only when something will read it:
493                    // `String::new()` does not allocate, the clone does,
494                    // and this is the writer's per-op path.
495                    let journalling = journal.enabled();
496                    let workspace = if journalling {
497                        sub.channel.clone()
498                    } else {
499                        String::new()
500                    };
501                    match policy.check(&sub) {
502                        // A rejection touches neither the log nor
503                        // durability, so it is answered at once rather
504                        // than made to wait for the batch.
505                        Err(reason) => {
506                            if journalling {
507                                let (actor_id, op_type) = policy.subject();
508                                journal.record(journal::Event::Decision {
509                                    actor_id,
510                                    workspace,
511                                    op_type,
512                                    accepted: false,
513                                    reject_reason: Some(reason.clone()),
514                                    seq: None,
515                                    parent: None,
516                                    decision_latency_us: as_micros(started.elapsed()),
517                                });
518                            }
519                            let _ = reply.send(Err(reason));
520                        }
521                        Ok(()) => {
522                            let seq = log.len();
523                            let entry = OpEntry {
524                                format_version: FORMAT_VERSION,
525                                parent: log.head(),
526                                seq,
527                                channel: sub.channel,
528                                payload: sub.payload,
529                                witnesses: Vec::new(),
530                                author_sig: sub.author_sig,
531                            };
532                            // `ContentHash::to_hex` formats each digest byte and
533                            // therefore allocates repeatedly. Preserve the
534                            // journal's zero-cost-disabled contract by building
535                            // this display value only when a journal will use it.
536                            let parent = journalling
537                                .then(|| entry.parent.as_ref().map(ContentHash::to_hex))
538                                .flatten();
539                            match log.append(entry) {
540                                Ok(hash) => {
541                                    // Storage first, projections second.
542                                    // `check` is deliberately read-only;
543                                    // no authoritative cached state may
544                                    // observe an entry the backend refused.
545                                    let appended = log
546                                        .last()
547                                        .expect("a successful append exposes its newest entry");
548                                    if journalling {
549                                        let (actor_id, op_type) = policy.subject();
550                                        journal.record(journal::Event::Decision {
551                                            actor_id,
552                                            workspace,
553                                            op_type,
554                                            accepted: true,
555                                            reject_reason: None,
556                                            seq: Some(seq),
557                                            parent,
558                                            decision_latency_us: as_micros(started.elapsed()),
559                                        });
560                                    }
561                                    policy.accepted(appended, &hash);
562                                    acks.push((
563                                        reply,
564                                        Accepted {
565                                            seq,
566                                            hash,
567                                            decision_latency: started.elapsed(),
568                                        },
569                                        started,
570                                    ));
571                                }
572                                Err(error) => {
573                                    durability_failed = true;
574                                    writer_flag.store(true, Ordering::Relaxed);
575                                    let reason = format!("log append failed: {error:?}");
576                                    if journalling {
577                                        let (actor_id, op_type) = policy.subject();
578                                        journal.record(journal::Event::Decision {
579                                            actor_id,
580                                            workspace,
581                                            op_type,
582                                            accepted: false,
583                                            reject_reason: Some(reason.clone()),
584                                            seq: None,
585                                            parent,
586                                            decision_latency_us: as_micros(started.elapsed()),
587                                        });
588                                    }
589                                    let _ = reply.send(Err(reason));
590                                }
591                            }
592                        }
593                    }
594                    // Decided, so the slot is no longer holding anyone
595                    // up. An accepted op still waits on the shared
596                    // durability barrier, but by then it is behind
597                    // nobody: the quota bounds work queued *ahead* of
598                    // another actor, and this op no longer is any.
599                    quotas.release(&actor);
600                }
601                // Only when something was actually queued behind the
602                // woken command. A lone submitter wakes the writer for
603                // itself constantly, and recording depth 1 for each
604                // would bury every interesting line under them.
605                if drained > 1 && journal.enabled() {
606                    journal.record(journal::Event::QueueDepth { depth: drained });
607                }
608
609                // The durability barrier. Submitters are told `Accepted`
610                // only after this returns, so an acknowledged op has
611                // reached the platter -- not merely the page cache. The
612                // hook submits a ref op before git applies the ref, so
613                // acknowledging early is what would let git hold a ref
614                // whose authorising op does not exist.
615                let durable = if acks.is_empty() {
616                    // Nothing was appended, so there is nothing to make
617                    // durable. Skipping the barrier keeps a batch of pure
618                    // rejections off the disk entirely.
619                    Ok(())
620                } else {
621                    log.sync()
622                };
623                if durable.is_err() {
624                    durability_failed = true;
625                    // Publish before replying, so an observer that wakes
626                    // on a client's error already sees the cause.
627                    writer_flag.store(true, Ordering::Relaxed);
628                }
629                // Measured here, once the batch is durable, so every
630                // accepted op is recorded at the one point every accepted
631                // op passes through. A submit path added later cannot
632                // forget to instrument itself.
633                let batch = acks.len();
634                for (reply, accepted, started) in acks.drain(..) {
635                    if durable.is_ok() {
636                        writer_lag.record(
637                            accepted.seq,
638                            accepted.decision_latency,
639                            started.elapsed(),
640                            batch,
641                        );
642                    }
643                    let answer = match &durable {
644                        Ok(()) => Ok(accepted),
645                        // Ordered but not durable is not an acceptance.
646                        // Say so, rather than acknowledge and hope.
647                        //
648                        // These ops are in the log and folded into the
649                        // view, and cannot be taken back out: the log is
650                        // append-only. So this reply leaves the daemon's
651                        // view holding ops their submitters were told
652                        // failed -- git will not have applied the ref its
653                        // pusher was refused. That divergence is bounded
654                        // to this one batch precisely because the writer
655                        // now stops accepting; unbounded is what it would
656                        // be if it carried on.
657                        Err(e) => Err(format!("ordered but not durable: {e:?}")),
658                    };
659                    // Send failure just means the client gave up waiting.
660                    let _ = reply.send(answer);
661                }
662                if stopping {
663                    break;
664                }
665            }
666            // A clean shutdown must not strand the buffer.
667            log.sync().ok();
668            log
669        });
670        Self {
671            tx,
672            poisoned,
673            lag,
674            quotas,
675            thread: Some(thread),
676        }
677    }
678
679    /// The writer's latency record, for a daemon that wants to know
680    /// whether the decision-latency gate is being met by the traffic it is
681    /// actually serving rather than by the test suite.
682    #[must_use]
683    pub fn lag(&self) -> Arc<LagMeter> {
684        self.lag.clone()
685    }
686
687    /// Creates a new client handle for a workspace.
688    pub fn handle(&self) -> SequencerHandle {
689        SequencerHandle {
690            tx: self.tx.clone(),
691            poisoned: self.poisoned.clone(),
692            quotas: self.quotas.clone(),
693        }
694    }
695
696    /// Stops the writer thread and returns the log for inspection.
697    ///
698    /// # Panics
699    ///
700    /// Panics if the writer thread itself panicked.
701    pub fn shutdown(mut self) -> Box<dyn OpLog> {
702        self.tx.send(Command::Shutdown).ok();
703        self.thread
704            .take()
705            .expect("shutdown called once")
706            .join()
707            .expect("sequencer thread exits cleanly")
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::{round_robin, Queued, Submission};
714    use std::collections::VecDeque;
715    use std::sync::mpsc;
716    use std::sync::Arc;
717
718    /// A batch as the writer sees it: actor keys in arrival order. The
719    /// reply channels are never used, only carried.
720    fn batch(actors: &[&str]) -> (Vec<Option<Queued>>, Vec<Arc<str>>) {
721        // One interned key per distinct name, exactly as `Quotas::admit`
722        // hands out -- the scheduler compares them by pointer, so a test
723        // that allocated a fresh `Arc` per op would be testing nothing.
724        let mut interned: Vec<Arc<str>> = Vec::new();
725        let queued = actors
726            .iter()
727            .map(|name| {
728                let key = match interned.iter().find(|k| k.as_ref() == *name) {
729                    Some(k) => k.clone(),
730                    None => {
731                        let k: Arc<str> = Arc::from(*name);
732                        interned.push(k.clone());
733                        k
734                    }
735                };
736                let (reply, _rx) = mpsc::channel();
737                Some((
738                    Submission {
739                        channel: (*name).to_string(),
740                        payload: Vec::new(),
741                        author_sig: None,
742                    },
743                    key,
744                    reply,
745                ))
746            })
747            .collect();
748        (queued, interned)
749    }
750
751    /// The order `round_robin` chose, as actor names.
752    fn served(actors: &[&str]) -> Vec<String> {
753        let (queued, _interned) = batch(actors);
754        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
755        let mut turns = Vec::new();
756        round_robin(&queued, &mut buckets, &mut turns);
757        turns
758            .iter()
759            .map(|&i| queued[i].as_ref().expect("slot filled").1.to_string())
760            .collect()
761    }
762
763    #[test]
764    fn one_actor_is_served_in_arrival_order() {
765        let order = served(&["a", "a", "a"]);
766        assert_eq!(order, ["a", "a", "a"]);
767    }
768
769    #[test]
770    fn a_burst_does_not_bury_a_single_op_behind_it() {
771        // The case the scheduler exists for: five ops from one actor
772        // already queued when one op from someone else arrives last.
773        let order = served(&["flood", "flood", "flood", "flood", "flood", "quiet"]);
774        assert_eq!(
775            order[1], "quiet",
776            "arrival order would serve the single op last; rotation \
777             serves it second: {order:?}"
778        );
779        assert_eq!(order.len(), 6, "and nothing is dropped: {order:?}");
780    }
781
782    #[test]
783    fn an_actors_own_ops_never_overtake_each_other() {
784        // Per-actor FIFO is the one thing rotation must not disturb: ops
785        // from one actor build on each other, and a CAS written against
786        // the previous one fails if they are reordered.
787        let (queued, _interned) = batch(&["a", "b", "a", "b", "a"]);
788        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
789        let mut turns = Vec::new();
790        round_robin(&queued, &mut buckets, &mut turns);
791        let positions: Vec<usize> = turns
792            .iter()
793            .copied()
794            .filter(|&i| queued[i].as_ref().expect("slot filled").1.as_ref() == "a")
795            .collect();
796        assert_eq!(
797            positions,
798            [0, 2, 4],
799            "a's ops must be served in the order a sent them: {turns:?}"
800        );
801    }
802
803    #[test]
804    fn every_op_is_served_exactly_once() {
805        let (queued, _interned) = batch(&["a", "b", "c", "a", "a", "c"]);
806        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
807        let mut turns = Vec::new();
808        round_robin(&queued, &mut buckets, &mut turns);
809        let mut seen = turns.clone();
810        seen.sort_unstable();
811        seen.dedup();
812        assert_eq!(seen.len(), queued.len(), "no duplicates and no drops");
813        assert_eq!(turns.len(), queued.len());
814    }
815
816    #[test]
817    fn an_empty_batch_terminates() {
818        // The rotation loop runs until every op has a turn. A batch that
819        // can never fill `turns` -- empty, or holding taken slots -- is
820        // the shape that would spin forever without its guard.
821        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
822        let mut turns = Vec::new();
823        round_robin(&[], &mut buckets, &mut turns);
824        assert!(turns.is_empty());
825
826        let (mut queued, _interned) = batch(&["a", "b"]);
827        queued[0] = None;
828        queued[1] = None;
829        round_robin(&queued, &mut buckets, &mut turns);
830        assert!(turns.is_empty(), "no op left to serve: {turns:?}");
831    }
832}