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}