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    /// Whether an entry another writer already sequenced can be folded
93    /// here, asked by [`SequencerHandle::replicate`] before it appends
94    /// one. Default: yes.
95    ///
96    /// **This is not admission, and must not become it.** The writer that
97    /// sequenced the entry decided whether it was admissible; a replica
98    /// that re-decided would be a second writer with its own opinion of
99    /// the order, which is the thing a replica exists not to be. So no
100    /// signature, grant or quota is consulted here, and
101    /// [`SubmitPolicy::check`] is never called on a replicated entry.
102    ///
103    /// What is asked is narrower and has to be asked before the append:
104    /// can this replica's projection take the entry at all. A replica
105    /// built from an older release may not know an op kind the writer
106    /// admitted, and appending what [`SubmitPolicy::accepted`] then cannot
107    /// fold would leave a log that no longer replays. Refusing here halts
108    /// replication at that entry, which is the only honest outcome: a
109    /// replica that skipped what it did not understand would be a fork.
110    ///
111    /// # Errors
112    ///
113    /// Why the entry cannot be folded, returned to the replicator.
114    fn replicable(&mut self, _entry: &OpEntry) -> Result<(), String> {
115        Ok(())
116    }
117}
118
119/// The default policy: everything is admitted (localhost/dev shape).
120pub struct AdmitAll;
121
122impl SubmitPolicy for AdmitAll {
123    fn check(&mut self, _sub: &Submission) -> Result<(), String> {
124        Ok(())
125    }
126}
127
128/// The sequencer's acknowledgement of a durably ordered op.
129#[derive(Debug)]
130pub struct Accepted {
131    /// Position assigned in the total order.
132    pub seq: u64,
133    /// Content hash of the appended entry (the new log head).
134    pub hash: ContentHash,
135    /// Time from dequeue to append; the merge-decision latency the Phase-0
136    /// gate measures (<100 ms target, CI excluded).
137    pub decision_latency: Duration,
138}
139
140/// An entry another writer already sequenced, and the hash the page that
141/// carried it claimed for it.
142///
143/// The claim travels beside the entry rather than being recomputed and
144/// trusted: [`SequencerHandle::replicate`] refuses an entry whose content
145/// does not hash to what its source said, which is SYNC.md's second check
146/// made again at the one place that appends.
147#[derive(Debug, Clone)]
148pub struct Sequenced {
149    /// The entry, exactly as its writer sequenced it: `seq`, `parent`,
150    /// payload and signature untouched.
151    pub entry: OpEntry,
152    /// The content hash its source claimed for it.
153    pub hash: ContentHash,
154}
155
156/// What a [`SequencerHandle::replicate`] call appended.
157#[derive(Debug)]
158pub struct Replicated {
159    /// Entries appended and folded, all of the batch.
160    pub appended: u64,
161    /// Content hash of the last one appended, the log's new head; `None`
162    /// for an empty batch.
163    pub head: Option<ContentHash>,
164}
165
166/// Why a [`SequencerHandle::replicate`] call stopped.
167///
168/// Everything before [`ReplicateError::seq`] in the batch was appended,
169/// folded and made durable; nothing at or after it was. A replica keeps
170/// what it verified and stops there, rather than skipping on.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct ReplicateError {
173    /// The sequence number of the entry that was refused.
174    pub seq: u64,
175    /// Why: a position or parent that does not continue this log, a hash
176    /// that is not the entry's, a projection that cannot fold it, or a
177    /// storage failure.
178    pub reason: String,
179    /// How many entries of the batch were appended before it.
180    pub appended: u64,
181}
182
183impl std::fmt::Display for ReplicateError {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        write!(f, "seq {}: {}", self.seq, self.reason)
186    }
187}
188
189/// Most ops admitted before the writer stops draining and syncs.
190///
191/// Bounds the latency a submitter can inherit from the ops queued ahead
192/// of it: without a cap, a sustained burst would keep the drain loop fed
193/// and the batch would never close. 256 is a starting point chosen to sit
194/// far under the 100 ms decision-latency gate at the measured per-op cost,
195/// not a tuned value.
196const MAX_BATCH: usize = 256;
197
198/// Microseconds, saturating.
199///
200/// The journal carries an integer rather than a float so a `jq` filter
201/// can compare and sum it without surprises. `u64` microseconds covers
202/// half a million years; a decision that outlived that has other
203/// problems.
204fn as_micros(d: Duration) -> u64 {
205    u64::try_from(d.as_micros()).unwrap_or(u64::MAX)
206}
207
208/// One admitted op waiting on the batch's durability barrier: where to
209/// answer, what to answer with, and when it was dequeued (so the ack can
210/// be measured through the barrier, not just up to the append).
211type PendingAck = (mpsc::Sender<Result<Accepted, String>>, Accepted, Instant);
212
213/// Fills `turns` with the order to serve one drained batch in: one op
214/// from each actor in rotation, until every op has a turn.
215///
216/// The quota is what *bounds* how long one actor can make another wait.
217/// This is the finer half of the same idea, and it only matters within a
218/// single wake-up: when the writer surfaces to find one actor's burst
219/// interleaved with somebody's single op, arrival order would serve the
220/// burst first, and rotation serves the single op second instead of
221/// last. Per-actor order is preserved exactly — each bucket is FIFO —
222/// so an actor's own ops never overtake each other.
223///
224/// The single-actor case, which is most of them and every benchmark,
225/// takes the identity path and allocates nothing. That is deliberate:
226/// `alloc_budget` gates the per-op allocation count, and a scheduler
227/// that charged every op for a fairness decision nobody needed would be
228/// paying to solve a problem it does not have.
229fn round_robin(
230    queued: &[Option<Queued>],
231    buckets: &mut Vec<(Arc<str>, std::collections::VecDeque<usize>)>,
232    turns: &mut Vec<usize>,
233) {
234    turns.clear();
235    // One actor (or none): arrival order already is round-robin.
236    let mut lone = true;
237    let mut first: Option<&Arc<str>> = None;
238    for item in queued.iter().flatten() {
239        match first {
240            None => first = Some(&item.1),
241            Some(seen) if Arc::ptr_eq(seen, &item.1) => {}
242            Some(_) => {
243                lone = false;
244                break;
245            }
246        }
247    }
248    if lone {
249        // Filled slots only. Arrival order is already the answer here,
250        // but handing back an index to a slot that holds nothing would
251        // make the caller's `take()` the only thing standing between
252        // this and a panic.
253        turns.extend(
254            queued
255                .iter()
256                .enumerate()
257                .filter_map(|(index, item)| item.as_ref().map(|_| index)),
258        );
259        return;
260    }
261
262    buckets.clear();
263    for (index, item) in queued.iter().enumerate() {
264        let Some((_, actor, _)) = item else { continue };
265        // Pointer equality, not string equality: the same actor hands
266        // back the same interned `Arc` (see [`fairness::Quotas::admit`]),
267        // so this compares a word rather than a string. A key re-interned
268        // mid-batch would split into two buckets, which costs that actor
269        // a slightly larger share of one batch and nothing else.
270        match buckets.iter_mut().find(|(key, _)| Arc::ptr_eq(key, actor)) {
271            Some((_, indexes)) => indexes.push_back(index),
272            None => {
273                let mut indexes = std::collections::VecDeque::new();
274                indexes.push_back(index);
275                buckets.push((actor.clone(), indexes));
276            }
277        }
278    }
279    while turns.len() < queued.len() {
280        let before = turns.len();
281        for (_, indexes) in buckets.iter_mut() {
282            if let Some(index) = indexes.pop_front() {
283                turns.push(index);
284            }
285        }
286        // Every bucket is empty; the remainder are `None` slots. Without
287        // this the loop would spin forever on a batch containing them.
288        if turns.len() == before {
289            break;
290        }
291    }
292}
293
294// `Submit` is ~208 bytes and `Shutdown` carries none, which is what the
295// lint objects to. Its premise does not hold here: these are transient
296// channel messages, at most `MAX_BATCH` in flight, and `Shutdown` is sent
297// exactly once per sequencer lifetime — so the "wasted" space is one
298// message, once, not a cost paid per stored value.
299//
300// Boxing the payload to even them out would put a heap allocation on the
301// write path, which is the one path this workspace measures allocations
302// on (`choir-node/tests/alloc_budget.rs`). Paying that on every op to
303// save 208 bytes on a message sent at shutdown is the wrong trade.
304//
305// It first fired when D45 added `Witness::credential_key`, taking
306// `Submission` past the 200-byte default threshold.
307#[allow(clippy::large_enum_variant)]
308enum Command {
309    /// The op, the interned actor bucket holding its quota slot (see
310    /// [`fairness`]), and where to answer.
311    Submit(Submission, Arc<str>, mpsc::Sender<Result<Accepted, String>>),
312    /// Entries another writer already sequenced, to append as they are.
313    Replicate(
314        Vec<Sequenced>,
315        mpsc::Sender<Result<Replicated, ReplicateError>>,
316    ),
317    Shutdown,
318}
319
320/// Appends a replicated batch, in order, stopping at the first entry that
321/// does not continue this log. Runs on the writer thread and nowhere else.
322///
323/// Three checks before each append, none of them admission: the entry's
324/// `seq` is this log's next position, its `parent` is this log's head, and
325/// its content hashes to what its source claimed. Then the policy is asked
326/// whether its projection can fold it, and only then is it appended and
327/// handed to [`SubmitPolicy::accepted`], exactly as an admitted op is.
328fn replicate_batch(
329    log: &mut dyn OpLog,
330    policy: &mut dyn SubmitPolicy,
331    batch: Vec<Sequenced>,
332    durability_failed: &mut bool,
333    writer_flag: &AtomicBool,
334) -> Result<Replicated, ReplicateError> {
335    let mut appended = 0u64;
336    let mut head = None;
337    for Sequenced { entry, hash } in batch {
338        let seq = entry.seq;
339        let refuse = |reason: String| ReplicateError {
340            seq,
341            reason,
342            appended,
343        };
344        if *durability_failed {
345            return Err(refuse(
346                "log is not durable: writer stopped accepting".to_string(),
347            ));
348        }
349        if seq != log.len() {
350            return Err(refuse(format!(
351                "entry is at seq {seq}, and this log's next position is {}",
352                log.len()
353            )));
354        }
355        if entry.parent != log.head() {
356            return Err(refuse(
357                "entry's parent is not this log's head, so it continues another chain".to_string(),
358            ));
359        }
360        let computed = entry.content_hash();
361        if computed != hash {
362            return Err(refuse(format!(
363                "entry hashes to {}, and its source claimed {}",
364                computed.to_hex(),
365                hash.to_hex()
366            )));
367        }
368        policy.replicable(&entry).map_err(refuse)?;
369        match log.append(entry) {
370            Ok(stored) => {
371                // Storage first, projections second, as on the admit
372                // path: the policy folds the entry the backend holds.
373                let last = log
374                    .last()
375                    .expect("a successful append exposes its newest entry");
376                policy.accepted(last, &stored);
377                appended += 1;
378                head = Some(stored);
379            }
380            Err(error) => {
381                *durability_failed = true;
382                writer_flag.store(true, Ordering::Relaxed);
383                return Err(ReplicateError {
384                    seq,
385                    reason: format!("log append failed: {error:?}"),
386                    appended,
387                });
388            }
389        }
390    }
391    Ok(Replicated { appended, head })
392}
393
394/// One drained command awaiting its turn in the writer's round-robin.
395type Queued = (Submission, Arc<str>, mpsc::Sender<Result<Accepted, String>>);
396
397/// Cloneable client handle; one per workspace/agent.
398#[derive(Clone)]
399pub struct SequencerHandle {
400    tx: mpsc::Sender<Command>,
401    poisoned: Arc<AtomicBool>,
402    quotas: fairness::Quotas,
403}
404
405impl SequencerHandle {
406    /// Whether a durability barrier has failed, after which this writer
407    /// refuses every submission.
408    ///
409    /// Exposed rather than acted on. The sequencer's job is to say that it
410    /// can no longer promise durability; deciding what a *node* does about
411    /// that — keep serving reads, exit so supervision restarts it, page
412    /// someone — is a lifecycle policy, and a library linked by every
413    /// embedder including the test suite is the wrong place to make it.
414    ///
415    /// The daemon polls this and exits, so launchd's `KeepAlive` restarts
416    /// into the same replay path a `kill -9` already exercises. Without an
417    /// observer, fail-closed is invisible to supervision: the process
418    /// stays up refusing everything, and a transient fsync error becomes
419    /// permanent downtime that looks like uptime.
420    #[must_use]
421    pub fn durability_failed(&self) -> bool {
422        self.poisoned.load(Ordering::Relaxed)
423    }
424
425    /// Blocks until the sequencer has durably ordered the op. Unsigned
426    /// convenience wrapper over [`SequencerHandle::try_submit`]; only
427    /// valid under a policy that admits unsigned ops.
428    ///
429    /// # Panics
430    ///
431    /// Panics if the sequencer thread has shut down or the policy
432    /// rejects the op.
433    pub fn submit(&self, channel: &str, payload: Vec<u8>) -> Accepted {
434        self.try_submit(channel, payload, None)
435            .expect("policy admits this op")
436    }
437
438    /// Blocks until the sequencer has ordered the op or rejected it.
439    ///
440    /// # Errors
441    ///
442    /// The policy's rejection reason, or a [`fairness`] rejection naming
443    /// the quota when this actor already has too many ops awaiting a
444    /// decision. The quota is checked here, on the calling thread, before
445    /// anything is sent: an over-quota submitter is *answered*, never
446    /// parked, so a client always has something to react to.
447    ///
448    /// # Panics
449    ///
450    /// Panics if the sequencer thread has already shut down.
451    pub fn try_submit(
452        &self,
453        channel: &str,
454        payload: Vec<u8>,
455        author_sig: Option<Witness>,
456    ) -> Result<Accepted, String> {
457        let sub = Submission {
458            channel: channel.to_string(),
459            payload,
460            author_sig,
461        };
462        let actor = self.quotas.admit(fairness::Quotas::actor_of(&sub))?;
463        let (reply_tx, reply_rx) = mpsc::channel();
464        self.tx
465            .send(Command::Submit(sub, actor, reply_tx))
466            .expect("sequencer thread alive");
467        reply_rx.recv().expect("sequencer replies before dropping")
468    }
469
470    /// Appends entries another writer already sequenced, in order, through
471    /// this writer thread, and folds each one.
472    ///
473    /// This is how a replica takes a log without becoming a second writer
474    /// (invariant 5): the entries keep the `seq` and `parent` their own
475    /// writer gave them, and this thread is still the only thing that
476    /// appends. Each must continue this log exactly (next position, this
477    /// head as parent, content hashing to the claimed hash) and the
478    /// policy's [`SubmitPolicy::replicable`] must say it can fold it.
479    /// [`SubmitPolicy::check`] is never run: admission was the source
480    /// writer's decision.
481    ///
482    /// The whole batch shares one durability barrier and is answered after
483    /// it. Replicated entries are not admission decisions, so they are not
484    /// journalled, counted against a quota, or measured by the lag meter.
485    ///
486    /// # Errors
487    ///
488    /// The first entry that failed, with how many before it were appended.
489    /// Nothing after it is attempted.
490    ///
491    /// # Panics
492    ///
493    /// Panics if the sequencer thread has already shut down.
494    pub fn replicate(&self, entries: Vec<Sequenced>) -> Result<Replicated, ReplicateError> {
495        let (reply_tx, reply_rx) = mpsc::channel();
496        self.tx
497            .send(Command::Replicate(entries, reply_tx))
498            .expect("sequencer thread alive");
499        reply_rx.recv().expect("sequencer replies before dropping")
500    }
501
502    /// The per-actor admission quotas this handle submits through.
503    #[must_use]
504    pub fn quotas(&self) -> fairness::Quotas {
505        self.quotas.clone()
506    }
507
508    /// Offers every submission before waiting for any reply, so the whole
509    /// group is already queued when the writer next drains — and therefore
510    /// shares one durability barrier instead of one each.
511    ///
512    /// This is the difference between an N-op request costing N fsyncs and
513    /// costing `ceil(N / MAX_BATCH)`. Calling [`SequencerHandle::try_submit`]
514    /// in a loop cannot achieve it: each call blocks until its own reply,
515    /// so the queue is empty every time the writer looks and every op
516    /// becomes its own batch.
517    ///
518    /// Results are returned in submission order, one per input. Each is
519    /// independent: a rejection does not abort the rest, matching the
520    /// per-op semantics `/api/submit-batch` already promised.
521    ///
522    /// # Panics
523    ///
524    /// Panics if the sequencer thread has already shut down.
525    pub fn try_submit_many(&self, subs: Vec<Submission>) -> Vec<Result<Accepted, String>> {
526        // A reply channel per op rather than one shared channel: replies
527        // are not ordered with respect to each other, because a rejection
528        // is answered immediately while an admitted op waits for the
529        // barrier. Separate channels keep the result order matching the
530        // input order by construction rather than by assumption.
531        // The collect is the whole mechanism, not an accident: it forces
532        // every submission to be SENT before the first reply is awaited.
533        // Consumed lazily this would send one, block on its reply, send
534        // the next -- exactly the per-op blocking that made the batch
535        // endpoint pay one fsync per op.
536        #[allow(clippy::needless_collect)]
537        let waiting: Vec<Result<mpsc::Receiver<Result<Accepted, String>>, String>> = subs
538            .into_iter()
539            .map(|sub| {
540                // An over-quota member is refused in place rather than
541                // failing the group: the endpoint's documented semantics
542                // are per-op, and one member's ceiling is not the
543                // group's problem.
544                let actor = self.quotas.admit(fairness::Quotas::actor_of(&sub))?;
545                let (reply_tx, reply_rx) = mpsc::channel();
546                self.tx
547                    .send(Command::Submit(sub, actor, reply_tx))
548                    .expect("sequencer thread alive");
549                Ok(reply_rx)
550            })
551            .collect();
552        waiting
553            .into_iter()
554            .map(|slot| match slot {
555                Ok(rx) => rx.recv().expect("sequencer replies before dropping"),
556                Err(refused) => Err(refused),
557            })
558            .collect()
559    }
560}
561
562/// Owns the single writer thread; the only component allowed to append to
563/// the repo's [`OpLog`].
564pub struct Sequencer {
565    tx: mpsc::Sender<Command>,
566    /// Set by the writer when a durability barrier fails; read by the
567    /// daemon through [`SequencerHandle::durability_failed`].
568    poisoned: Arc<AtomicBool>,
569    /// Written by the writer for every accepted op; read and drained by
570    /// the daemon through [`Sequencer::lag`].
571    lag: Arc<LagMeter>,
572    /// Per-actor admission ceilings, shared with every handle and with
573    /// the writer thread that releases their slots.
574    quotas: fairness::Quotas,
575    thread: Option<JoinHandle<Box<dyn OpLog>>>,
576}
577
578impl Sequencer {
579    /// Starts the writer thread over `log` with the [`AdmitAll`] policy.
580    pub fn spawn(log: Box<dyn OpLog>) -> Self {
581        Self::spawn_with_policy(log, Box::new(AdmitAll))
582    }
583
584    /// Starts the writer thread over `log`; every submission passes
585    /// through `policy` before it is ordered.
586    ///
587    /// Records nothing. Use [`Sequencer::spawn_with_journal`] to observe
588    /// decisions.
589    pub fn spawn_with_policy(log: Box<dyn OpLog>, policy: Box<dyn SubmitPolicy>) -> Self {
590        Self::spawn_with_journal(log, policy, Box::new(journal::NullJournal))
591    }
592
593    /// [`Sequencer::spawn_with_policy`], recording every decision to
594    /// `journal`.
595    ///
596    /// The journal is derived data: it is written after the decision is
597    /// made, never consulted, and its failure cannot refuse an op. See
598    /// [`journal`] for why the I/O belongs on another thread.
599    pub fn spawn_with_journal(
600        mut log: Box<dyn OpLog>,
601        mut policy: Box<dyn SubmitPolicy>,
602        journal: Box<dyn journal::Journal>,
603    ) -> Self {
604        let (tx, rx) = mpsc::channel::<Command>();
605        let poisoned = Arc::new(AtomicBool::new(false));
606        let writer_flag = poisoned.clone();
607        let lag = Arc::new(LagMeter::new());
608        let writer_lag = lag.clone();
609        let quotas = fairness::Quotas::default();
610        let writer_quotas = quotas.clone();
611        let thread = std::thread::spawn(move || {
612            let quotas = writer_quotas;
613            // Ordered, then made durable, then acknowledged. Everything
614            // admitted in one pass through the loop shares a single
615            // `sync`, so the fsync cost is paid once per batch rather
616            // than once per op.
617            let mut acks: Vec<PendingAck> = Vec::new();
618            let mut stopping = false;
619            // Set by a failed durability barrier and never cleared. See
620            // the refusal in the admit path below for why this is
621            // one-way: the alternative is ordering ops that may not
622            // survive, which is the bug this whole change exists to close.
623            //
624            // The sequencer publishes this state through `writer_flag`;
625            // the daemon observes it and exits for supervision. The
626            // library itself only refuses subsequent work, which keeps
627            // the lifecycle choice with the embedder.
628            let mut durability_failed = false;
629            // Reused across wake-ups. A fresh buffer per batch would put
630            // an allocation on the writer's hot path for no reason.
631            let mut queued: Vec<Option<Queued>> = Vec::new();
632            let mut turns: Vec<usize> = Vec::new();
633            let mut buckets: Vec<(Arc<str>, std::collections::VecDeque<usize>)> = Vec::new();
634            while let Ok(first) = rx.recv() {
635                let mut cmd = Some(first);
636                queued.clear();
637                // A replicated batch ends the drain: the submissions
638                // already queued are decided first, then the batch, so
639                // arrival order between the two is kept.
640                let mut replicating = None;
641                // Admit the woken command, then drain whatever else is
642                // already queued behind it. Nothing is waited for: an idle
643                // sequencer still batches exactly one op, so a lone
644                // submitter pays no added latency.
645                while let Some(current) = cmd.take() {
646                    match current {
647                        Command::Shutdown => {
648                            stopping = true;
649                            break;
650                        }
651                        Command::Submit(sub, actor, reply) => {
652                            queued.push(Some((sub, actor, reply)));
653                        }
654                        Command::Replicate(batch, reply) => {
655                            replicating = Some((batch, reply));
656                            break;
657                        }
658                    }
659                    if queued.len() >= MAX_BATCH {
660                        break;
661                    }
662                    cmd = rx.try_recv().ok();
663                }
664                // Depth is counted per wake-up rather than sampled on a
665                // timer: this is the writer's own view of how much was
666                // already waiting behind it, which is the number a
667                // backlog question is actually asking about.
668                let drained = queued.len();
669                round_robin(&queued, &mut buckets, &mut turns);
670                for &index in &turns {
671                    let Some((sub, actor, reply)) = queued[index].take() else {
672                        continue;
673                    };
674                    let started = Instant::now();
675                    // Fail closed. Once a barrier has failed this writer
676                    // can no longer promise anything, so it refuses
677                    // *before* appending rather than ordering ops it
678                    // cannot persist.
679                    if durability_failed {
680                        let _ = reply.send(Err(
681                            "log is not durable: writer stopped accepting".to_string()
682                        ));
683                        quotas.release(&actor);
684                        continue;
685                    }
686                    // Cloned only when something will read it:
687                    // `String::new()` does not allocate, the clone does,
688                    // and this is the writer's per-op path.
689                    let journalling = journal.enabled();
690                    let workspace = if journalling {
691                        sub.channel.clone()
692                    } else {
693                        String::new()
694                    };
695                    match policy.check(&sub) {
696                        // A rejection touches neither the log nor
697                        // durability, so it is answered at once rather
698                        // than made to wait for the batch.
699                        Err(reason) => {
700                            if journalling {
701                                let (actor_id, op_type) = policy.subject();
702                                journal.record(journal::Event::Decision {
703                                    actor_id,
704                                    workspace,
705                                    op_type,
706                                    accepted: false,
707                                    reject_reason: Some(reason.clone()),
708                                    seq: None,
709                                    parent: None,
710                                    decision_latency_us: as_micros(started.elapsed()),
711                                });
712                            }
713                            let _ = reply.send(Err(reason));
714                        }
715                        Ok(()) => {
716                            let seq = log.len();
717                            let entry = OpEntry {
718                                format_version: FORMAT_VERSION,
719                                parent: log.head(),
720                                seq,
721                                channel: sub.channel,
722                                payload: sub.payload,
723                                witnesses: Vec::new(),
724                                author_sig: sub.author_sig,
725                            };
726                            // `ContentHash::to_hex` formats each digest byte and
727                            // therefore allocates repeatedly. Preserve the
728                            // journal's zero-cost-disabled contract by building
729                            // this display value only when a journal will use it.
730                            let parent = journalling
731                                .then(|| entry.parent.as_ref().map(ContentHash::to_hex))
732                                .flatten();
733                            match log.append(entry) {
734                                Ok(hash) => {
735                                    // Storage first, projections second.
736                                    // `check` is deliberately read-only;
737                                    // no authoritative cached state may
738                                    // observe an entry the backend refused.
739                                    let appended = log
740                                        .last()
741                                        .expect("a successful append exposes its newest entry");
742                                    if journalling {
743                                        let (actor_id, op_type) = policy.subject();
744                                        journal.record(journal::Event::Decision {
745                                            actor_id,
746                                            workspace,
747                                            op_type,
748                                            accepted: true,
749                                            reject_reason: None,
750                                            seq: Some(seq),
751                                            parent,
752                                            decision_latency_us: as_micros(started.elapsed()),
753                                        });
754                                    }
755                                    policy.accepted(appended, &hash);
756                                    acks.push((
757                                        reply,
758                                        Accepted {
759                                            seq,
760                                            hash,
761                                            decision_latency: started.elapsed(),
762                                        },
763                                        started,
764                                    ));
765                                }
766                                Err(error) => {
767                                    durability_failed = true;
768                                    writer_flag.store(true, Ordering::Relaxed);
769                                    let reason = format!("log append failed: {error:?}");
770                                    if journalling {
771                                        let (actor_id, op_type) = policy.subject();
772                                        journal.record(journal::Event::Decision {
773                                            actor_id,
774                                            workspace,
775                                            op_type,
776                                            accepted: false,
777                                            reject_reason: Some(reason.clone()),
778                                            seq: None,
779                                            parent,
780                                            decision_latency_us: as_micros(started.elapsed()),
781                                        });
782                                    }
783                                    let _ = reply.send(Err(reason));
784                                }
785                            }
786                        }
787                    }
788                    // Decided, so the slot is no longer holding anyone
789                    // up. An accepted op still waits on the shared
790                    // durability barrier, but by then it is behind
791                    // nobody: the quota bounds work queued *ahead* of
792                    // another actor, and this op no longer is any.
793                    quotas.release(&actor);
794                }
795                // Only when something was actually queued behind the
796                // woken command. A lone submitter wakes the writer for
797                // itself constantly, and recording depth 1 for each
798                // would bury every interesting line under them.
799                if drained > 1 && journal.enabled() {
800                    journal.record(journal::Event::QueueDepth { depth: drained });
801                }
802                let replicated = replicating.map(|(batch, reply)| {
803                    let outcome = replicate_batch(
804                        log.as_mut(),
805                        policy.as_mut(),
806                        batch,
807                        &mut durability_failed,
808                        &writer_flag,
809                    );
810                    (reply, outcome)
811                });
812                let replicated_any =
813                    replicated
814                        .as_ref()
815                        .is_some_and(|(_, outcome)| match outcome {
816                            Ok(done) => done.appended > 0,
817                            Err(stopped) => stopped.appended > 0,
818                        });
819
820                // The durability barrier. Submitters are told `Accepted`
821                // only after this returns, so an acknowledged op has
822                // reached the platter -- not merely the page cache. The
823                // hook submits a ref op before git applies the ref, so
824                // acknowledging early is what would let git hold a ref
825                // whose authorising op does not exist.
826                let durable = if acks.is_empty() && !replicated_any {
827                    // Nothing was appended, so there is nothing to make
828                    // durable. Skipping the barrier keeps a batch of pure
829                    // rejections off the disk entirely.
830                    Ok(())
831                } else {
832                    log.sync()
833                };
834                if durable.is_err() {
835                    durability_failed = true;
836                    // Publish before replying, so an observer that wakes
837                    // on a client's error already sees the cause.
838                    writer_flag.store(true, Ordering::Relaxed);
839                }
840                // Measured here, once the batch is durable, so every
841                // accepted op is recorded at the one point every accepted
842                // op passes through. A submit path added later cannot
843                // forget to instrument itself.
844                let batch = acks.len();
845                for (reply, accepted, started) in acks.drain(..) {
846                    if durable.is_ok() {
847                        writer_lag.record(
848                            accepted.seq,
849                            accepted.decision_latency,
850                            started.elapsed(),
851                            batch,
852                        );
853                    }
854                    let answer = match &durable {
855                        Ok(()) => Ok(accepted),
856                        // Ordered but not durable is not an acceptance.
857                        // Say so, rather than acknowledge and hope.
858                        //
859                        // These ops are in the log and folded into the
860                        // view, and cannot be taken back out: the log is
861                        // append-only. So this reply leaves the daemon's
862                        // view holding ops their submitters were told
863                        // failed -- git will not have applied the ref its
864                        // pusher was refused. That divergence is bounded
865                        // to this one batch precisely because the writer
866                        // now stops accepting; unbounded is what it would
867                        // be if it carried on.
868                        Err(e) => Err(format!("ordered but not durable: {e:?}")),
869                    };
870                    // Send failure just means the client gave up waiting.
871                    let _ = reply.send(answer);
872                }
873                if let Some((reply, outcome)) = replicated {
874                    // Appended but not durable is not replicated, for the
875                    // reason it is not accepted above.
876                    let answer = match (&durable, outcome) {
877                        (Err(e), Ok(done)) => Err(ReplicateError {
878                            seq: log.len().saturating_sub(done.appended),
879                            reason: format!("appended but not durable: {e:?}"),
880                            appended: 0,
881                        }),
882                        (Err(e), Err(stopped)) => Err(ReplicateError {
883                            seq: log.len().saturating_sub(stopped.appended),
884                            reason: format!("appended but not durable: {e:?}"),
885                            appended: 0,
886                        }),
887                        (Ok(()), outcome) => outcome,
888                    };
889                    let _ = reply.send(answer);
890                }
891                if stopping {
892                    break;
893                }
894            }
895            // A clean shutdown must not strand the buffer.
896            log.sync().ok();
897            log
898        });
899        Self {
900            tx,
901            poisoned,
902            lag,
903            quotas,
904            thread: Some(thread),
905        }
906    }
907
908    /// The writer's latency record, for a daemon that wants to know
909    /// whether the decision-latency gate is being met by the traffic it is
910    /// actually serving rather than by the test suite.
911    #[must_use]
912    pub fn lag(&self) -> Arc<LagMeter> {
913        self.lag.clone()
914    }
915
916    /// Creates a new client handle for a workspace.
917    pub fn handle(&self) -> SequencerHandle {
918        SequencerHandle {
919            tx: self.tx.clone(),
920            poisoned: self.poisoned.clone(),
921            quotas: self.quotas.clone(),
922        }
923    }
924
925    /// Stops the writer thread and returns the log for inspection.
926    ///
927    /// # Panics
928    ///
929    /// Panics if the writer thread itself panicked.
930    pub fn shutdown(mut self) -> Box<dyn OpLog> {
931        self.tx.send(Command::Shutdown).ok();
932        self.thread
933            .take()
934            .expect("shutdown called once")
935            .join()
936            .expect("sequencer thread exits cleanly")
937    }
938}
939
940#[cfg(test)]
941mod tests {
942    use super::{round_robin, Queued, Submission};
943    use std::collections::VecDeque;
944    use std::sync::mpsc;
945    use std::sync::Arc;
946
947    /// A batch as the writer sees it: actor keys in arrival order. The
948    /// reply channels are never used, only carried.
949    fn batch(actors: &[&str]) -> (Vec<Option<Queued>>, Vec<Arc<str>>) {
950        // One interned key per distinct name, exactly as `Quotas::admit`
951        // hands out -- the scheduler compares them by pointer, so a test
952        // that allocated a fresh `Arc` per op would be testing nothing.
953        let mut interned: Vec<Arc<str>> = Vec::new();
954        let queued = actors
955            .iter()
956            .map(|name| {
957                let key = match interned.iter().find(|k| k.as_ref() == *name) {
958                    Some(k) => k.clone(),
959                    None => {
960                        let k: Arc<str> = Arc::from(*name);
961                        interned.push(k.clone());
962                        k
963                    }
964                };
965                let (reply, _rx) = mpsc::channel();
966                Some((
967                    Submission {
968                        channel: (*name).to_string(),
969                        payload: Vec::new(),
970                        author_sig: None,
971                    },
972                    key,
973                    reply,
974                ))
975            })
976            .collect();
977        (queued, interned)
978    }
979
980    /// The order `round_robin` chose, as actor names.
981    fn served(actors: &[&str]) -> Vec<String> {
982        let (queued, _interned) = batch(actors);
983        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
984        let mut turns = Vec::new();
985        round_robin(&queued, &mut buckets, &mut turns);
986        turns
987            .iter()
988            .map(|&i| queued[i].as_ref().expect("slot filled").1.to_string())
989            .collect()
990    }
991
992    #[test]
993    fn one_actor_is_served_in_arrival_order() {
994        let order = served(&["a", "a", "a"]);
995        assert_eq!(order, ["a", "a", "a"]);
996    }
997
998    #[test]
999    fn a_burst_does_not_bury_a_single_op_behind_it() {
1000        // The case the scheduler exists for: five ops from one actor
1001        // already queued when one op from someone else arrives last.
1002        let order = served(&["flood", "flood", "flood", "flood", "flood", "quiet"]);
1003        assert_eq!(
1004            order[1], "quiet",
1005            "arrival order would serve the single op last; rotation \
1006             serves it second: {order:?}"
1007        );
1008        assert_eq!(order.len(), 6, "and nothing is dropped: {order:?}");
1009    }
1010
1011    #[test]
1012    fn an_actors_own_ops_never_overtake_each_other() {
1013        // Per-actor FIFO is the one thing rotation must not disturb: ops
1014        // from one actor build on each other, and a CAS written against
1015        // the previous one fails if they are reordered.
1016        let (queued, _interned) = batch(&["a", "b", "a", "b", "a"]);
1017        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
1018        let mut turns = Vec::new();
1019        round_robin(&queued, &mut buckets, &mut turns);
1020        let positions: Vec<usize> = turns
1021            .iter()
1022            .copied()
1023            .filter(|&i| queued[i].as_ref().expect("slot filled").1.as_ref() == "a")
1024            .collect();
1025        assert_eq!(
1026            positions,
1027            [0, 2, 4],
1028            "a's ops must be served in the order a sent them: {turns:?}"
1029        );
1030    }
1031
1032    #[test]
1033    fn every_op_is_served_exactly_once() {
1034        let (queued, _interned) = batch(&["a", "b", "c", "a", "a", "c"]);
1035        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
1036        let mut turns = Vec::new();
1037        round_robin(&queued, &mut buckets, &mut turns);
1038        let mut seen = turns.clone();
1039        seen.sort_unstable();
1040        seen.dedup();
1041        assert_eq!(seen.len(), queued.len(), "no duplicates and no drops");
1042        assert_eq!(turns.len(), queued.len());
1043    }
1044
1045    #[test]
1046    fn an_empty_batch_terminates() {
1047        // The rotation loop runs until every op has a turn. A batch that
1048        // can never fill `turns` -- empty, or holding taken slots -- is
1049        // the shape that would spin forever without its guard.
1050        let mut buckets: Vec<(Arc<str>, VecDeque<usize>)> = Vec::new();
1051        let mut turns = Vec::new();
1052        round_robin(&[], &mut buckets, &mut turns);
1053        assert!(turns.is_empty());
1054
1055        let (mut queued, _interned) = batch(&["a", "b"]);
1056        queued[0] = None;
1057        queued[1] = None;
1058        round_robin(&queued, &mut buckets, &mut turns);
1059        assert!(turns.is_empty(), "no op left to serve: {turns:?}");
1060    }
1061}