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>
126 lines
6.0 KiB
Markdown
126 lines
6.0 KiB
Markdown
# 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.
|