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.rs—ControlErrorand its two tests.crates/control-core/src/worker.rs— theWorkerHandletrait and its one async test.crates/control-core/src/proto.rs— theMessageenum and its two tests.
Edit:
crates/control-core/src/lib.rs— addpub mod error; pub mod worker; pub mod proto;and re-exportControlError,WorkerHandle, andMessagealongside the ids and domain types.crates/control-core/Cargo.toml— three new dependencies.
New deps and why:
thiserror— derivesErrorand writes each variant'sDisplaymessage from its#[error("…")], and generates theFrom<std::io::Error>that#[from]needs. ([dependencies].)async-trait— rewrites the trait'sasync fninto a method returning a boxed future, which is what a vtable can hold; without it anasync fnin a trait cannot be used behinddyn. ([dependencies].)tokio(with themacrosandrtfeatures) — supplies#[tokio::test], since the worker test must.awaitadispatch. ([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-core → 11 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
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] message | Retryable? |
|---|---|---|
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
Iovariant 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 returnstrueforWorkerandProtocolandfalsefor everything else — write it as one totalmatches!over the two retryable variants so a future terminal variant falls through tofalseautomatically.
[→ Answer key](../appendix-answer-key.md#core-error)
worker.rs — the seam. One trait, annotated #[async_trait]:
pub trait WorkerHandle: Send + Sync— theSend + Syncbound is required because adyn WorkerHandleis 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. TakesJobandJobOutcomefromdomain, and the error fromerror. The method takes a concreteJob, 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, derivingDebug, 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_retryablemethod, 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 withSend + Syncused asBox<dyn WorkerHandle>— the concrete seam behind the whole distributed design. - Object-safety: why
dispatchtakes a concreteJobrather than a generic. - serde's internally tagged enum (
tag = "type") for a self-describing wire frame, andrename_allforsnake_casediscriminators.
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.
-
worker_and_protocol_are_retryable— assertsControlError::Worker("died".into()).is_retryable()and the same forProtocol. 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 andis_retryable. -
invalid_and_not_found_are_terminal— asserts!ControlError::Invalid(…).is_retryable()and!ControlError::NotFound(…).is_retryable(). Once yourmatches!names onlyWorker | Protocol, both of these should pass without touching the method.
#[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.
a_worker_handle_can_be_boxed_as_dyn— a#[tokio::test]. Define a tinyEchoWorkerinside the test module whoseid()returns"echo"and whosedispatchreturns aJobOutcomewith the job's id and no records. Then store it aslet worker: Box<dyn WorkerHandle> = Box::new(EchoWorker);, assertworker.id() == "echo", and assert the outcome ofworker.dispatch(job).awaitcarries the samejob_idyou sent. Predict: what does the compiler say if you forget#[async_trait]on theimplblock?
#[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.
-
register_is_tagged_and_roundtrips— build aMessage::Register { worker_id: "w1".into(), capacity: 4 },serde_json::to_stringit, assert the JSONcontains("\"type\":\"register\""), thenfrom_strback and assert equal. This pins the discriminator: it is thetag = "type"+rename_alldoing their job. Predict what the"type"value would be if you droppedrename_alland the variant wereRegisterWorkerinstead. -
assign_carries_a_full_job— build aMessage::Assign { job }around a fullJob(nestedEvalJob), encode, assertcontains("\"type\":\"assign\""), decode, assert equal. This proves a wholeJob— ids, status, spec — travels inside one tagged frame and comes back byte-identical.
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.