Add M2a plan: thirteen tasks, tests, fake server and recordings
The tasks build the inference path: emsha-backed SHA-256, inferproxy, config, a hand-written HTTP and SSE client, request building, delta assembly, the chat state machine, the thinking cap, the slot gate with retry, the startup self-test and on-device verification. Everything the tasks copy in was checked against a private reference implementation: the gate passes after each task in order, the timing tests pass repeatedly under CPU load, and the reference passes the self-test and all four device checks on straylight. Expected results for the recorded streams were derived by a separate script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
# M2a task 11: the slot gate, and retry
|
||||
|
||||
**Branch:** `m2a` (run `git switch m2a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `Add the per-slot gate and chat_with_retry`
|
||||
|
||||
## Goal
|
||||
|
||||
Two protections against a pile-up. The **gate** lets `loopd` have only one request in flight per
|
||||
server slot; others wait their turn inside `loopd`, in order, up to a limit. **Retry** resends a
|
||||
request whose server went away, a few times, with growing waits.
|
||||
|
||||
## Context
|
||||
|
||||
The inference server is shared and can be restarted under us. After a restart every conversation
|
||||
that was waiting would retry at once and queue invisibly on the server. With the gate they queue
|
||||
in `loopd`, where the queue is bounded and the caller is told (`Queued`, `Busy`). Retrying is safe
|
||||
because nothing is recorded until a completion is final: a retry sends the same bytes again.
|
||||
|
||||
The gate is held for **one request**, from before it is sent until `chat` returns. It is the first
|
||||
code in this milestone that several threads use at once.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/loopd/tests/retry.rs`
|
||||
- Create: `crates/loopd/src/llama/gate.rs`, `crates/loopd/src/llama/retry.rs`
|
||||
- Modify: `crates/loopd/src/llama/mod.rs`, `crates/loopd/src/llama/chat.rs`,
|
||||
`docs/implementer-log.md`
|
||||
|
||||
## Interfaces
|
||||
|
||||
`crates/loopd/src/llama/gate.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Default)]
|
||||
pub struct SlotGate { /* private: a Mutex around per-slot state, and a Condvar */ }
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct GateFull;
|
||||
|
||||
/// The right to have a request in flight on the slot. Dropping it passes the slot on.
|
||||
pub struct Permit<'a> { /* private */ }
|
||||
|
||||
impl SlotGate {
|
||||
pub fn new() -> Self;
|
||||
pub fn acquire(&self, slot: u32, max_queue: usize, on_queued: &mut dyn FnMut(usize)) -> Result<Permit<'_>, GateFull>;
|
||||
}
|
||||
```
|
||||
|
||||
Gate rules:
|
||||
|
||||
1. A free slot with nobody waiting is taken at once; `on_queued` is not called.
|
||||
2. Otherwise, if `max_queue` requests are already waiting for this slot, return `GateFull` at
|
||||
once, without waiting. `max_queue` of 0 means nobody may wait.
|
||||
3. Otherwise join the queue, call `on_queued(ahead)` **once**, where `ahead` is the holder (1 if
|
||||
the slot is taken) plus the waiters already in the queue, and wait.
|
||||
4. Waiters get the slot **in the order they arrived**. A `Condvar` wakes waiters in no particular
|
||||
order, so give each waiter a ticket, keep the tickets in a `VecDeque`, and let a woken waiter
|
||||
take the slot only if the slot is free **and** its ticket is at the front. Use `notify_all`.
|
||||
5. Slots are independent of each other.
|
||||
6. Dropping the `Permit` frees the slot and wakes the waiters. Implement `Drop`.
|
||||
7. `Mutex::lock` and `Condvar::wait` return `Err` if another thread panicked while holding the
|
||||
lock. Do not `unwrap`: recover the guard with `unwrap_or_else(|poisoned| poisoned.into_inner())`.
|
||||
|
||||
`crates/loopd/src/llama/retry.rs`:
|
||||
|
||||
```rust
|
||||
pub fn is_retryable(e: &InferError) -> bool;
|
||||
pub fn backoff_ms(schedule: &[u64], retry: u32, jitter: i32) -> u64;
|
||||
impl Client {
|
||||
pub fn chat_with_retry(&self, req: &ChatRequest, on_event: &mut dyn FnMut(&ChatEvent)) -> Result<Completion, InferError>;
|
||||
}
|
||||
```
|
||||
|
||||
Retry rules:
|
||||
|
||||
1. `is_retryable`, one line per variant, all nine listed so that a new variant is a compile error:
|
||||
|
||||
| Retried: the server went away and may be back | Not retried |
|
||||
|---|---|
|
||||
| `Connect`, `StreamClosedEarly`, `Stalled`, `LoadTimeout`, `Http` with status 503 | `Busy`, `WaitTimeout` (it has already waited), `ThinkingOverrun`, `Protocol`, `Http` with any other status |
|
||||
|
||||
2. `backoff_ms(schedule, retry, jitter)`: `retry` is 1 for the first retry (0 is treated as 1). The
|
||||
base is `schedule[retry - 1]`, or the last entry past the end, or 0 for an empty schedule.
|
||||
`jitter` is clamped to -1000..=1000 and moves the wait by up to a quarter of the base:
|
||||
`base + (base / 4) * jitter / 1000`. Integer arithmetic; huge values must not overflow
|
||||
(`saturating_*`, `try_from`). Examples: `([2000, 8000, 30000], 1, 0)` is 2000; `(…, 1, 1000)` is
|
||||
2500; `(…, 1, -1000)` is 1500; `(…, 2, 500)` is 9000; `(…, 4, 0)` is 30000.
|
||||
3. `chat_with_retry` calls `chat`. On an error that is retryable, and while fewer than
|
||||
`retry_attempts` attempts have been made: compute the wait with a jitter that differs from call
|
||||
to call (the sub-second nanoseconds of the system clock, reduced to -1000..=1000, are random
|
||||
enough; do not add a crate). If the time since the first attempt plus the wait is more than
|
||||
`retry_window_ms`, return the error. Otherwise emit
|
||||
`ChatEvent::Retrying { attempt, after_ms, error }` (`attempt` is the number of the attempt about
|
||||
to start, so 2 for the first retry; `error` is the error's `Display` text), sleep, and try again.
|
||||
When attempts run out, return the last error.
|
||||
|
||||
Changes elsewhere:
|
||||
|
||||
- `Client` gets a second field, `pub(crate) gate: gate::SlotGate`, created in `Client::new`.
|
||||
- `chat` starts by acquiring the gate for `req.slot` with `cfg.limits.queue_len`, passing
|
||||
`|ahead| on_event(&ChatEvent::Queued { ahead })`. `GateFull` becomes `InferError::Busy`. Bind the
|
||||
permit to a name such as `_permit` so that it lives until `chat` returns; `let _ = …` would drop
|
||||
it at once.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m2a`, then
|
||||
`cp docs/plans/M2a/files/crates/loopd/tests/retry.rs crates/loopd/tests/`
|
||||
- [ ] **2. See the test fail.** `cargo test -p loopd --test retry`. Expected: it does not compile.
|
||||
- [ ] **3. Write `gate.rs` and `retry.rs`, and make the two changes.** Run `cargo fmt --all`.
|
||||
- [ ] **4. See the tests pass.** `cargo test -p loopd`. Expected: `retry` 13 passed, and every earlier
|
||||
test file still passes. Run `cargo test -p loopd --test retry` ten times in a row.
|
||||
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p loopd --test retry` reports 13 passed, ten runs in a row; `make gate` prints
|
||||
`gate: ok`.
|
||||
- `gate.rs` has no `unwrap()` and no `expect(`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- `waiters_are_served_in_order_one_at_a_time` fails now and then. That means rule 4 is not met;
|
||||
if you cannot see why after two attempts, stop.
|
||||
Reference in New Issue
Block a user