Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Build: ControlError, the WorkerHandle Seam, and the Wire

Maps to: Phase 0 (control-core). Kind: Build.

Objective

Finish control-core by adding the three files the rest of the course dispatches through: error.rs (the ControlError taxonomy whose type answers "should I retry?"), worker.rs (the WorkerHandle trait — the seam the concept chapter promised, now in code), and proto.rs (the wire Message enum). Prove with tests that Worker/Protocol errors are retryable and the rest are terminal; that a concrete worker can be stored as Box<dyn WorkerHandle> and dispatched through; and that a Message serializes with a self-describing "type" tag and round-trips.

Scaffold

You already have the crate from the previous build — ids.rs and domain.rs are green. Here you extend it.

Create:

  • crates/control-core/src/error.rsControlError and its two tests.
  • crates/control-core/src/worker.rs — the WorkerHandle trait and its one async test.
  • crates/control-core/src/proto.rs — the Message enum and its two tests.

Edit:

  • crates/control-core/src/lib.rs — add pub mod error; pub mod worker; pub mod proto; and re-export ControlError, WorkerHandle, and Message alongside the ids and domain types.
  • crates/control-core/Cargo.toml — three new dependencies.

New deps and why:

  • thiserror — derives Error and writes each variant's Display message from its #[error("…")], and generates the From<std::io::Error> that #[from] needs. ([dependencies].)
  • async-trait — rewrites the trait's async fn into a method returning a boxed future, which is what a vtable can hold; without it an async fn in a trait cannot be used behind dyn. ([dependencies].)
  • tokio (with the macros and rt features) — supplies #[tokio::test], since the worker test must .await a dispatch. ([dev-dependencies].)

serde, serde_json, and pretty_assertions are already in the manifest from the domain build; proto.rs reuses them.

Expected result: cargo test -p control-core11 tests pass (the 6 from the domain build plus the 5 you add here):

  • from error.rs: worker_and_protocol_are_retryable, invalid_and_not_found_are_terminal
  • from worker.rs: a_worker_handle_can_be_boxed_as_dyn
  • from proto.rs: register_is_tagged_and_roundtrips, assign_carries_a_full_job
NOTE A twelfth test — a Message round-trip over a real socket — joins the crate in Part VIII, when you build the framed codec that actually ships these frames. It is not part of this arc; do not expect it here. The Message enum is defined now so every later arc can name it, but it is not transmitted until the cluster arc.

The spec (givens)

error.rs — the error taxonomy. One enum, deriving Debug and thiserror::Error. Six variants, each carrying a String payload except the last, and each with an #[error("…")] message:

Variant#[error] messageRetryable?
NotFound(String)"not found: {0}"no — terminal
Invalid(String)"invalid request: {0}"no — terminal
Worker(String)"worker error: {0}"yes
Protocol(String)"protocol error: {0}"yes
Store(String)"store error: {0}"no — terminal
Io(#[from] std::io::Error)"io error: {0}"no — terminal
  • Exactly the Io variant carries #[from]; the domain variants are constructed by hand where the problem is detected, so they get no #[from].
  • Inherent method is_retryable(&self) -> bool. The rule: it returns true for Worker and Protocol and false for everything else — write it as one total matches! over the two retryable variants so a future terminal variant falls through to false automatically.

[→ Answer key](../appendix-answer-key.md#core-error)

worker.rs — the seam. One trait, annotated #[async_trait]:

  • pub trait WorkerHandle: Send + Sync — the Send + Sync bound is required because a dyn WorkerHandle is moved between and shared across tokio tasks on different threads.
  • fn id(&self) -> &str; — a stable identifier for logging and accounting. Note it is not async and returns a borrowed &str.
  • async fn dispatch(&self, job: Job) -> Result<JobOutcome, ControlError>; — run one job to completion. Takes Job and JobOutcome from domain, and the error from error. The method takes a concrete Job, not a generic — that is what keeps the trait object-safe (dyn-compatible).

[→ Answer key](../appendix-answer-key.md#core-worker)

proto.rs — the wire protocol. One enum:

  • pub enum Message, deriving Debug, Clone, PartialEq, Eq, Serialize, Deserialize.
  • Attribute: #[serde(tag = "type", rename_all = "snake_case")] — an internally tagged enum. Each variant serializes as a JSON object carrying a "type" discriminator ("register", "assign", "result", "heartbeat") alongside its fields, so a receiver can route a frame without a side channel.
  • Struct-style variants:
    • Register { worker_id: String, capacity: u32 } — a worker announcing itself and how many jobs it can hold at once.
    • Assign { job: Job } — the coordinator handing a job to a worker.
    • Result { outcome: JobOutcome } — a worker returning a finished job.
    • Heartbeat { worker_id: String } — a liveness ping.

[→ Answer key](../appendix-answer-key.md#core-proto)

Concepts exercised

  • Encoding the retry decision in the error type via a single is_retryable method, so no call site re-derives the policy.
  • #[from] on exactly the I/O variant; explicit construction for the domain variants.
  • An #[async_trait] trait with Send + Sync used as Box<dyn WorkerHandle> — the concrete seam behind the whole distributed design.
  • Object-safety: why dispatch takes a concrete Job rather than a generic.
  • serde's internally tagged enum (tag = "type") for a self-describing wire frame, and rename_all for snake_case discriminators.

The build loop (you drive)

Write each test first, predict its failure, run to see the red you predicted, then implement the minimum to green.

  1. worker_and_protocol_are_retryable — asserts ControlError::Worker("died".into()).is_retryable() and the same for Protocol. Predict: with the enum not yet written, does this fail to compile or fail an assertion? Run, confirm it is a compile failure, then define the enum and is_retryable.

  2. invalid_and_not_found_are_terminal — asserts !ControlError::Invalid(…).is_retryable() and !ControlError::NotFound(…).is_retryable(). Once your matches! names only Worker | Protocol, both of these should pass without touching the method.

Predict first Before you add #[from] to the Io variant: if you declare it as plain Io(std::io::Error) and later write a function returning Result<_, ControlError> that calls a std::fs function with ?, is the failure a type mismatch or a missing-trait error — and which error code? Name it, then recall from the concept chapter which one attribute fixes it.
  1. a_worker_handle_can_be_boxed_as_dyn — a #[tokio::test]. Define a tiny EchoWorker inside the test module whose id() returns "echo" and whose dispatch returns a JobOutcome with the job's id and no records. Then store it as let worker: Box<dyn WorkerHandle> = Box::new(EchoWorker);, assert worker.id() == "echo", and assert the outcome of worker.dispatch(job).await carries the same job_id you sent. Predict: what does the compiler say if you forget #[async_trait] on the impl block?
TRAP Two failure modes cluster here. First, drop #[async_trait] on the impl and the async method signatures no longer match the (macro-rewritten) trait — a mismatch, not a mysterious lifetime error. Second, if you ever give a trait method its own generic type parameter, forming Box<dyn WorkerHandle> fails with error[E0038] ("not dyn compatible"). Both are the compiler enforcing the discipline that keeps the seam usable as dyn — the exact property Part VIII's RemoteWorker relies on.
  1. register_is_tagged_and_roundtrips — build a Message::Register { worker_id: "w1".into(), capacity: 4 }, serde_json::to_string it, assert the JSON contains("\"type\":\"register\""), then from_str back and assert equal. This pins the discriminator: it is the tag = "type" + rename_all doing their job. Predict what the "type" value would be if you dropped rename_all and the variant were RegisterWorker instead.

  2. assign_carries_a_full_job — build a Message::Assign { job } around a full Job (nested EvalJob), encode, assert contains("\"type\":\"assign\""), decode, assert equal. This proves a whole Job — ids, status, spec — travels inside one tagged frame and comes back byte-identical.

NOTE The Result variant is named Result on purpose — it is Message::Result, always written with the Message:: path, so it never collides with std::result::Result. serde renames it to "result" on the wire like any other variant.

Done when

cargo test -p control-core shows 11 green (6 from the domain build, 5 from this one); you have watched worker_and_protocol_are_retryable fail to compile before the enum exists; you can say why dispatch takes a concrete Job rather than a generic, and what error[E0038] would mean if it did not; and a Message::Register serializes with "type":"register". Commit. The Core arc is complete — every seam the rest of the course leans on now exists and is tested.