Skip to content

← The Expert Edge: Idiom, Review and Capstones step 5 of 14

Hard End-to-End

Capstone: an ownership-only job scheduler

Make a small job scheduler compile, without changing what it does.

pub fn schedule(script: Vec<String>) -> Vec<String>

There is no algorithm here. The command grammar is trivial, the data structures are a VecDeque and an Option, and the expected output is written down for you. The only difficulty in this problem is satisfying the borrow checker, which is exactly the point: knowing the three rules and being able to write a program under them are different skills, and this is the one that decides whether you can ship Rust.

The starter has four deliberate errors, one per command, and they are four different diagnostics. Fix them all.

The scheduler

Job derives nothing. Not Clone, not Copy, not Debug. It holds a String id, a String payload, and an Rc<RefCell<Vec<String>>> handle to the shared log, and it implements Drop to write drop <id> when it dies. That Drop impl is the instrument: every move, every early free and every forgotten value shows up in the log, in order.

command meaning
submit <id> <payload> build a Job, move it to the back of pending. Logs submit <id>.
start <id> move that job out of pending into the single running slot. If something was already running, it moves back to the front of pending first, logging preempt <oldid>. Logs start <id>, or start <id> missing.
finish consume the running job by value, logging done <id>:<PAYLOAD-UPPERCASED> and then, as it dies, drop <id>. Logs finish idle if nothing is running.
cancel <id> remove it from pending and drop it immediately: cancel <id> then drop <id>. Or cancel <id> missing.
snapshot log pending a,b,cwithout disturbing the queue. A read-only borrow. pending - when empty.

Anything else logs bad <line>. At the end the log gets a shutdown line, then running is dropped, then pending front to back — so the final drop order is part of the expected output.

Every error path is a no-op that still logs. Cancelling a job that does not exist is not a panic and not silence; it is one line saying so. That is a design rule worth stealing: a command processor with a defined output for every input is testable, and one that panics on bad input is not.

The four errors, and why each one is the shape it is

start — E0382, borrow of moved value. running = Some(job); gives the job away, and the next line reads job.id. Rust’s move checker is flow-sensitive, so this is not “you cannot use a variable twice” — it is “the value is not there any more”. Two fixes: read what you need before the move, or read it back out of its new home. One of those is obviously better.

finish — E0509, cannot move out of a type which implements Drop. This is the interesting one, and the one that stops people.

::: question Why does implementing Drop make let Job { id, payload, .. } = job; illegal, when it would be fine on a struct without Drop? Because Drop::drop takes &mut self — it is handed the whole value, every field intact. If you were allowed to move payload out and then let the rest of the job be destroyed, drop would run on a Job with a hole in it, reading a field that has already been given away.

For a struct without Drop, that is fine: the compiler just drops each remaining field individually and skips the moved one. Adding a Drop impl makes the value indivisible — you can move the whole thing or nothing.

The two ways out, both idiomatic:

  1. Consume the whole value in one place. Give Job a method taking self, do the work inside it, and let the job die at the end of that method. This is the better answer here, and it has a bonus: done is logged before drop automatically, because the destructor runs when the method returns.
  2. Leave a hole behind. std::mem::take(&mut self.payload) swaps in an empty String and hands you the old one, so the job stays whole. Use this when you genuinely need the field before the value dies.

What you must not do is add #[derive(Clone)] and clone your way past it. clippy::redundant_clone is denied in this file precisely so that shortcut fails. :::

cancel — E0502, cannot borrow as mutable because it is also borrowed as immutable. pending.iter().find(..) hands back a reference into the queue, and removing an element while that reference is alive is exactly the bug the rule exists to prevent: the element could move in memory, or be the one you are pointing at. Fix it by not holding the reference — find the index, which is a usize and borrows nothing.

snapshot — E0507, cannot move out of a shared reference. .iter() gives &Job, and j.id tries to take the String out of borrowed memory. .clone() compiles and is the wrong instinct: you are building a comma-joined line and never keep the strings, so j.id.as_str() gives you everything you need for free. That is the difference between “make the error go away” and “understand what the error was telling you”.

Reading the log as evidence

When you have it compiling, look at what the expected outputs prove.

In the case that ends with three jobs still pending, the log ends shutdown, drop a, drop b, drop c — front to back, after the shutdown marker. Nothing was freed early and nothing leaked. In the preempt case, preempt a appears with no drop a after it, because the preempted job moved back into the queue rather than being destroyed. In the finish case, done x:HI is immediately followed by drop x, one line apart, because consuming a value by reference-free means its destructor runs right there.

That is what an ownership discipline buys you: the lifecycle of every value is visible in the source, and it is the same every run. No GC pause, no finaliser queue, no “eventually”. The Drop log is just a way of printing something the compiler already knew.

Loading visualization…