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}