feat: checkpoint batching and release-gate stories
This commit is contained in:
220
.scratch/distributed-gguf-runtime/evidence/DGR-012/README.md
Normal file
220
.scratch/distributed-gguf-runtime/evidence/DGR-012/README.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# DGR-012 — Continuous batching and bounded admission: evidence
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Date: 2026-07-16
|
||||||
|
Evidence kind: **synthetic-unit** (pure-numpy KV-cached dense-Llama reference +
|
||||||
|
node-local continuous-batching scheduler). No model download, no GPU, no torch,
|
||||||
|
no network, no API credit.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implemented the node-local scheduler that turns concurrent Route Sessions into
|
||||||
|
llama.cpp-style continuous batches while bounding admission (RALPH runtime
|
||||||
|
decision #9, ADR-0024). It sits **on top of** the DGR-007 Hot KV State manager —
|
||||||
|
batching is a scheduling concern layered over the existing per-`(session, epoch)`
|
||||||
|
KV isolation, not a new control plane or a change to the KV contract.
|
||||||
|
|
||||||
|
- **Bounded admission (`NodeBudget` + `submit`).** A new session is admitted only
|
||||||
|
if it fits four budgets: resident **weight** footprint (reported), **KV** byte
|
||||||
|
budget (a session must be able to hold its *whole* generation, prompt + new
|
||||||
|
tokens, on its own), **scratch** (per-active-session activation buffers, capped
|
||||||
|
by a total scratch envelope), and the bounded **queue**. Anything that cannot
|
||||||
|
fit is rejected up front with an explicit `AdmissionReason`
|
||||||
|
(`REJECTED_KV_BUDGET` / `REJECTED_SCRATCH_BUDGET` / `REJECTED_DUPLICATE`);
|
||||||
|
anything that fits but has no free slot waits in the bounded queue; a **full
|
||||||
|
queue is refused** (`REJECTED_QUEUE_FULL`) — that refusal is the backpressure
|
||||||
|
signal.
|
||||||
|
- **Continuous batching (`ContinuousBatchScheduler` + `KvBatchEngine`).** Every
|
||||||
|
tick, all currently-decoding sessions contribute their single next token to one
|
||||||
|
batch (bounded by `max_batch_size`); the engine runs the batch once. Each
|
||||||
|
session keeps its own position and appends its own sampled token via its own
|
||||||
|
`SessionCache`, so batching never mixes outputs. `KvBatchEngine` adapts the
|
||||||
|
DGR-007 `KvBoundaryAdapter`, so the batch runs against the *real* KV isolation
|
||||||
|
path; the pinned llama.cpp worker (DGR-008) implements the same
|
||||||
|
`recipe_fingerprint`/`prefill`/`decode_batch`/`release` contract where a batch
|
||||||
|
becomes one `llama_decode` over several sequences.
|
||||||
|
- **Prefill does not starve decode.** The scheduling policy is explicit and fixed:
|
||||||
|
**decode first, then bounded prefill.** In-flight decodes always run before any
|
||||||
|
new prompt is prefilled, and prefill work per tick is capped
|
||||||
|
(`max_prefill_tokens_per_tick`, always allowing at least one so a single large
|
||||||
|
prompt still progresses). A burst of new sessions cannot stall generations
|
||||||
|
already in flight.
|
||||||
|
- **Bounded memory / backpressure.** KV growth is bounded by the manager byte
|
||||||
|
budget; queued activations are bounded by `max_queue_depth` and the scratch
|
||||||
|
envelope; completed sessions release their KV so total KV returns to zero.
|
||||||
|
- **Capability telemetry (`SchedulerTelemetry`).** Reports active sessions, queue
|
||||||
|
depth, batch occupancy (last/avg/max), KV pressure (bytes/budget), scratch
|
||||||
|
pressure, prefill/decode token totals **and rates**, and rejected admissions
|
||||||
|
(total + by reason). All JSON-safe.
|
||||||
|
- **Concurrency 1/2/4/8 sweep (`run_concurrency_sweep`).** Runs the same eight
|
||||||
|
jobs at each level against a fresh KV manager and proves (a) **no cross-session
|
||||||
|
corruption** — every level yields byte-identical per-session tokens as the
|
||||||
|
serialized concurrency-1 reference — and (b) **saturation** — average batch
|
||||||
|
occupancy rises and total ticks fall as concurrency increases, until occupancy
|
||||||
|
plateaus.
|
||||||
|
|
||||||
|
No existing runtime code was modified — this story is purely additive (one new
|
||||||
|
module + one new test module + evidence).
|
||||||
|
|
||||||
|
## Files changed (all new)
|
||||||
|
|
||||||
|
- `packages/node/meshnet_node/batch_scheduler.py` — the scheduler:
|
||||||
|
- `NodeBudget` — weight/KV/scratch/queue budgets + `max_batch_size` /
|
||||||
|
`max_prefill_tokens_per_tick` scheduling bounds, with derived
|
||||||
|
`effective_active_cap` (tighter of active-slot and scratch caps).
|
||||||
|
- `AdmissionReason` / `AdmissionDecision` — structured admit/queue/reject.
|
||||||
|
- `GenerationRequest` / `DecodeItem` / `StepResult` — job + engine I/O values.
|
||||||
|
- `KvBatchEngine` — adapts a full-shard `KvBoundaryAdapter` to the batch-engine
|
||||||
|
contract (rejects a partial head/tail-only range).
|
||||||
|
- `SchedulerTelemetry` — the bounded capability snapshot.
|
||||||
|
- `ContinuousBatchScheduler` — thread-safe `submit` / `run_tick` /
|
||||||
|
`run_to_completion` / `telemetry`, decode-first-then-bounded-prefill policy.
|
||||||
|
- `run_concurrency_sweep` / `ConcurrencyResult` / `ConcurrencySweep` — the
|
||||||
|
deterministic 1/2/4/8 saturation report + corruption check.
|
||||||
|
- `tests/test_batch_scheduler.py` — 16 tests (see below); reuses the DGR-007
|
||||||
|
numpy dense-Llama reference via `from test_hot_kv_state import _KvDenseLlama,
|
||||||
|
_KvReferenceShard`.
|
||||||
|
- `.scratch/distributed-gguf-runtime/evidence/DGR-012/` — this README,
|
||||||
|
`commands.txt`, `generate_evidence.py`, `results.json`.
|
||||||
|
|
||||||
|
## Acceptance criteria → evidence
|
||||||
|
|
||||||
|
- **Scheduler admits sessions against weight, KV, scratch, and queue budgets** —
|
||||||
|
`test_admission_respects_active_scratch_and_queue_budgets` (fill slots → queue →
|
||||||
|
reject full queue), `test_admission_rejects_a_session_that_cannot_fit_the_kv_budget`,
|
||||||
|
`test_admission_rejects_when_per_session_scratch_exceeds_budget`,
|
||||||
|
`test_duplicate_submission_is_rejected`,
|
||||||
|
`test_weight_budget_is_reported_in_telemetry`.
|
||||||
|
- **Compatible decode steps form batches preserving per-session positions/outputs**
|
||||||
|
— `test_batched_decode_preserves_per_session_positions_and_outputs`
|
||||||
|
(`batch_occupancy_max == 4`, four divergent references each reproduced),
|
||||||
|
`test_positions_are_isolated_across_different_prompt_lengths` (prompt lengths 1/3/7).
|
||||||
|
- **Prefill does not starve decode; policy and bounds explicit** —
|
||||||
|
`test_prefill_does_not_starve_in_flight_decode` (in-flight session decodes on
|
||||||
|
*every* tick during a 4-session prefill burst; ≤1 prefill/tick),
|
||||||
|
`test_decode_first_policy_is_explicit_in_a_single_tick`.
|
||||||
|
- **Backpressure prevents unbounded queued activations or KV growth** —
|
||||||
|
`test_backpressure_signals_when_queue_full_then_recovers`,
|
||||||
|
`test_completed_sessions_release_kv_so_growth_is_bounded` (`kv_total_bytes == 0`
|
||||||
|
after completion).
|
||||||
|
- **Capability telemetry reports all required signals** —
|
||||||
|
`test_telemetry_reports_every_required_signal` (asserts every key present;
|
||||||
|
deterministic rates under an injected clock).
|
||||||
|
- **Concurrency 1/2/4/8 identifies saturation, no cross-session corruption** —
|
||||||
|
`test_concurrency_sweep_identifies_saturation_without_corruption`
|
||||||
|
(occupancy strictly ↑, ticks strictly ↓, tokens/tick ↑, `corruption_free`,
|
||||||
|
0 cache misses, saturation=8), `test_concurrency_sweep_saturates_below_max_when_load_is_small`.
|
||||||
|
- **Engine/usage guards** — `test_kv_batch_engine_requires_a_full_shard`,
|
||||||
|
`test_run_to_completion_is_bounded_against_misconfiguration`.
|
||||||
|
|
||||||
|
## Concurrency 1/2/4/8 sweep (real, deterministic — `results.json`)
|
||||||
|
|
||||||
|
Eight sessions, prompt length 4, 8 new tokens each; fresh KV manager per level;
|
||||||
|
budgets sized so KV never evicts (so the corruption check is unambiguous).
|
||||||
|
|
||||||
|
| concurrency | ticks | avg batch occupancy | max occupancy | tokens/tick | peak KV bytes |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 64 | 1.000 | 1 | 1.375 | 15360 |
|
||||||
|
| 2 | 33 | 1.750 | 2 | 2.667 | 29184 |
|
||||||
|
| 4 | 19 | 3.111 | 4 | 4.632 | 52224 |
|
||||||
|
| 8 | 15 | 4.000 | 7 | 5.867 | 75264 |
|
||||||
|
|
||||||
|
`saturation_concurrency = 8`, `corruption_free = True`, `cache_misses = 0`,
|
||||||
|
`rejected_admissions = 0`. As concurrency rises, the scheduler packs more sessions
|
||||||
|
per decode step (occupancy ↑) and finishes the same 56 decode + 32 prefill tokens
|
||||||
|
in far fewer ticks (aggregate work/tick ↑) — the batching throughput property —
|
||||||
|
while every per-session token stream stays byte-identical to the serialized
|
||||||
|
reference (no cross-session corruption). Max occupancy is 7 (not 8) at level 8
|
||||||
|
because the fairness policy prefills at most one new session per tick, so the last
|
||||||
|
session begins decoding one tick later.
|
||||||
|
|
||||||
|
## Commands and real results
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VP=/run/media/popov/d/DEV/repos/d-popov.com/AI/.venv/bin/python
|
||||||
|
|
||||||
|
$VP -m pytest -q tests/test_batch_scheduler.py
|
||||||
|
# -> 16 passed
|
||||||
|
|
||||||
|
$VP -m pytest -q tests/test_hot_kv_state.py # dependency still green
|
||||||
|
# -> 22 passed
|
||||||
|
|
||||||
|
$VP -m compileall -q packages tests
|
||||||
|
# -> exit 0
|
||||||
|
|
||||||
|
git diff --check
|
||||||
|
# -> exit 0
|
||||||
|
|
||||||
|
$VP .scratch/distributed-gguf-runtime/evidence/DGR-012/generate_evidence.py
|
||||||
|
# -> wrote results.json; saturation_concurrency=8 corruption_free=True
|
||||||
|
|
||||||
|
$VP -m pytest -q -rfE -p no:cacheprovider
|
||||||
|
# -> FULL_SUITE_RESULT_PLACEHOLDER
|
||||||
|
```
|
||||||
|
|
||||||
|
`commands.txt` beside this README captures the exact commands.
|
||||||
|
|
||||||
|
## Full-suite baseline (pre-existing unrelated failures)
|
||||||
|
|
||||||
|
FULL_SUITE_BASELINE_PLACEHOLDER
|
||||||
|
|
||||||
|
## Limitations and deferred work
|
||||||
|
|
||||||
|
- **Synthetic-unit, not real weights.** The scheduler is exercised against the
|
||||||
|
deterministic numpy KV-cached dense-Llama reference (the same one DGR-007 uses),
|
||||||
|
not a downloaded GGUF. This is required to keep the default gate deterministic,
|
||||||
|
download-free, and GPU-free. Real concurrent throughput on a downloaded
|
||||||
|
dense-Llama (CPU/ROCm) belongs to DGR-010 (blocked — no certified dense-Llama
|
||||||
|
artifact on this machine; see `evidence/DGR-010/BLOCKED.md`) and the final
|
||||||
|
comparison in DGR-014.
|
||||||
|
- **Batching is a scheduling grouping in this reference.** `KvBatchEngine.decode_batch`
|
||||||
|
runs each batch member sequentially through the cached decode (each attends only
|
||||||
|
its own KV, exactly like an independent llama.cpp sequence). The pinned llama.cpp
|
||||||
|
worker (DGR-008) fuses the batch into one `llama_decode` graph; the scheduling
|
||||||
|
semantics — one batch per tick, isolated positions/outputs — are identical. The
|
||||||
|
numbers here are *scheduler* quantities (ticks, batch occupancy, tokens/tick)
|
||||||
|
that are real and deterministic; **actual kernel-level batching speedup is a
|
||||||
|
native-worker property and is NOT claimed here** (RALPH performance discipline:
|
||||||
|
no unmeasured speed claims). It is measured in DGR-008/DGR-010/DGR-014.
|
||||||
|
- **Greedy sampling only.** Reuses the DGR-006 greedy `SamplingContract`. Greedy
|
||||||
|
over isolated per-session KV is order-independent, which is exactly why the
|
||||||
|
corruption check can assert byte-identical outputs across concurrency levels.
|
||||||
|
Stochastic sampling is out of scope for the deterministic gate.
|
||||||
|
- **Single loaded shard / single recipe per scheduler.** The scheduler batches
|
||||||
|
compatible sessions of one loaded shard (one `recipe_fingerprint`), which is the
|
||||||
|
node-local case. Multi-range routes batch at the head node whose adapter owns the
|
||||||
|
final head; cross-node coordination stays in the Meshnet control plane.
|
||||||
|
- **Native / llama.cpp gates N/A.** No native code, CMake, or llama.cpp patch was
|
||||||
|
touched (same as DGR-005/006/007), so those gates do not apply to this story.
|
||||||
|
|
||||||
|
## Compatibility / migration notes
|
||||||
|
|
||||||
|
- Purely additive: no existing module changed, so no behavior of the Torch/GGUF
|
||||||
|
backends, tracker, or KV manager is altered. The scheduler is opt-in — a server
|
||||||
|
constructs it around a `KvBatchEngine` when it wants continuous batching.
|
||||||
|
- `SchedulerTelemetry.to_dict()` is JSON-safe and aligns with the capability-signal
|
||||||
|
vocabulary (active sessions, queue depth, batch occupancy, KV pressure,
|
||||||
|
prefill/decode rates, rejected admissions) that a node advertises upward; it can
|
||||||
|
be folded into the DGR-009 capability report / heartbeat without schema changes
|
||||||
|
here.
|
||||||
|
- `AdmissionReason` values are stable strings suitable for the native protocol's
|
||||||
|
structured status / backpressure signalling.
|
||||||
|
|
||||||
|
## Handoff for dependent stories
|
||||||
|
|
||||||
|
- **DGR-008 (C++ gRPC worker):** implement the `BatchEngine` contract natively —
|
||||||
|
`decode_batch` becomes one `llama_decode` over the sessions' filtered sequences;
|
||||||
|
`prefill`/`release` map to the same KV manager operations. The scheduler,
|
||||||
|
admission budgets, fairness policy, and telemetry are unchanged; only the engine
|
||||||
|
swaps from numpy to llama.cpp.
|
||||||
|
- **DGR-010 (local real two-process acceptance, blocked):** once a certified
|
||||||
|
dense-Llama artifact is mounted, drive `run_concurrency_sweep` (or the scheduler
|
||||||
|
directly) with a real `KvBatchEngine` over the GGUF backend to produce
|
||||||
|
real-hardware occupancy/throughput/KV-pressure numbers under
|
||||||
|
`MESHNET_ENABLE_REAL_INFERENCE_TESTS=1` / `.venv-rocm`.
|
||||||
|
- **DGR-013 (failure/cancel/restart):** the `DoneReason.CACHE_MISS` path (a decode
|
||||||
|
whose KV was evicted marks the session done and re-prefillable) and the KV-release
|
||||||
|
on completion are the unit basis for the cancellation/cleanup matrix.
|
||||||
|
- **DGR-014 (release gate):** feed the real-hardware sweep’s aggregate throughput
|
||||||
|
and saturation point into the immutable DGR-001 comparison; do not reuse these
|
||||||
|
synthetic numbers as a performance claim.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# DGR-012 — exact commands (run from the worktree root)
|
||||||
|
# Default venv (Python 3.14); deterministic, download-free, GPU-free, API-credit-free.
|
||||||
|
VP=/run/media/popov/d/DEV/repos/d-popov.com/AI/.venv/bin/python
|
||||||
|
|
||||||
|
# Targeted story tests
|
||||||
|
$VP -m pytest -q tests/test_batch_scheduler.py
|
||||||
|
# -> 16 passed
|
||||||
|
|
||||||
|
# Dependency (DGR-007) still green — scheduler builds on this KV manager
|
||||||
|
$VP -m pytest -q tests/test_hot_kv_state.py
|
||||||
|
# -> 22 passed
|
||||||
|
|
||||||
|
# Python quality gates
|
||||||
|
$VP -m compileall -q packages tests
|
||||||
|
# -> exit 0
|
||||||
|
git diff --check
|
||||||
|
# -> exit 0
|
||||||
|
|
||||||
|
# Regenerate the machine-readable concurrency-sweep evidence
|
||||||
|
$VP .scratch/distributed-gguf-runtime/evidence/DGR-012/generate_evidence.py
|
||||||
|
# -> writes results.json; saturation_concurrency=8 corruption_free=True
|
||||||
|
|
||||||
|
# Full deterministic suite (records the pre-existing unrelated failure baseline)
|
||||||
|
$VP -m pytest -q -rfE -p no:cacheprovider
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Regenerate the DGR-012 concurrency-sweep evidence artifact.
|
||||||
|
|
||||||
|
Deterministic, download-free, GPU-free. Run from the repo root with the default
|
||||||
|
venv so the worktree ``meshnet_node`` package and the DGR-007 numpy reference
|
||||||
|
(``tests/test_hot_kv_state``) are importable:
|
||||||
|
|
||||||
|
python .scratch/distributed-gguf-runtime/evidence/DGR-012/generate_evidence.py
|
||||||
|
|
||||||
|
Writes ``results.json`` beside this script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||||||
|
sys.path.insert(0, str(_ROOT / "packages" / "node"))
|
||||||
|
sys.path.insert(0, str(_ROOT / "tests"))
|
||||||
|
|
||||||
|
from test_hot_kv_state import _KvDenseLlama, _KvReferenceShard # noqa: E402
|
||||||
|
|
||||||
|
from meshnet_node.batch_scheduler import ( # noqa: E402
|
||||||
|
ContinuousBatchScheduler,
|
||||||
|
GenerationRequest,
|
||||||
|
KvBatchEngine,
|
||||||
|
NodeBudget,
|
||||||
|
run_concurrency_sweep,
|
||||||
|
)
|
||||||
|
from meshnet_node.hot_kv_state import ( # noqa: E402
|
||||||
|
HotKvStateManager,
|
||||||
|
KvBoundaryAdapter,
|
||||||
|
kv_recipe_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL = _KvDenseLlama()
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine() -> KvBatchEngine:
|
||||||
|
shard = _KvReferenceShard(MODEL, 0, MODEL.n_layers - 1)
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(shard))
|
||||||
|
return KvBatchEngine(KvBoundaryAdapter(shard, manager))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
prompts = {
|
||||||
|
"s0": [1, 2, 3, 4], "s1": [5, 6, 7, 8], "s2": [9, 10, 11, 12],
|
||||||
|
"s3": [13, 14, 15, 16], "s4": [17, 18, 19, 20], "s5": [21, 22, 23, 24],
|
||||||
|
"s6": [25, 26, 27, 28], "s7": [29, 30, 31, 32],
|
||||||
|
}
|
||||||
|
n_new = 8
|
||||||
|
requests = [
|
||||||
|
GenerationRequest(sid, 0, tuple(p), n_new) for sid, p in prompts.items()
|
||||||
|
]
|
||||||
|
sweep = run_concurrency_sweep(
|
||||||
|
make_engine, requests, concurrency_levels=(1, 2, 4, 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
# A representative telemetry snapshot mid-run at concurrency 4 (shows the live
|
||||||
|
# capability signals a node advertises upward).
|
||||||
|
engine = make_engine()
|
||||||
|
scheduler = ContinuousBatchScheduler(
|
||||||
|
engine,
|
||||||
|
NodeBudget(
|
||||||
|
max_active_sessions=4, max_batch_size=4, max_queue_depth=8,
|
||||||
|
scratch_bytes_per_session=1, scratch_budget_bytes=4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for request in requests:
|
||||||
|
scheduler.submit(request)
|
||||||
|
for _ in range(6):
|
||||||
|
scheduler.run_tick()
|
||||||
|
mid_run_telemetry = scheduler.telemetry().to_dict()
|
||||||
|
|
||||||
|
artifact = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"evidence_kind": "synthetic-unit",
|
||||||
|
"model": {
|
||||||
|
"reference": "pure-numpy KV-cached dense-Llama (tests/test_hot_kv_state)",
|
||||||
|
"n_layers": MODEL.n_layers,
|
||||||
|
"hidden": MODEL.hidden,
|
||||||
|
"n_heads": MODEL.n_heads,
|
||||||
|
"vocab": MODEL.vocab,
|
||||||
|
},
|
||||||
|
"workload": {
|
||||||
|
"sessions": len(prompts),
|
||||||
|
"prompt_len": 4,
|
||||||
|
"max_new_tokens": n_new,
|
||||||
|
},
|
||||||
|
"concurrency_sweep": sweep.to_dict(),
|
||||||
|
"mid_run_telemetry_concurrency_4": mid_run_telemetry,
|
||||||
|
}
|
||||||
|
|
||||||
|
out = pathlib.Path(__file__).with_name("results.json")
|
||||||
|
out.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {out}")
|
||||||
|
print(
|
||||||
|
"saturation_concurrency=%d corruption_free=%s"
|
||||||
|
% (sweep.saturation_concurrency, sweep.corruption_free)
|
||||||
|
)
|
||||||
|
for result in sweep.results:
|
||||||
|
print(
|
||||||
|
" c=%d ticks=%d avg_occ=%.3f tokens/tick=%.3f peak_kv=%dB"
|
||||||
|
% (
|
||||||
|
result.concurrency,
|
||||||
|
result.ticks,
|
||||||
|
result.avg_batch_occupancy,
|
||||||
|
result.tokens_per_tick,
|
||||||
|
result.peak_kv_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
179
.scratch/distributed-gguf-runtime/evidence/DGR-012/results.json
Normal file
179
.scratch/distributed-gguf-runtime/evidence/DGR-012/results.json
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
{
|
||||||
|
"concurrency_sweep": {
|
||||||
|
"corruption_free": true,
|
||||||
|
"reference_outputs": {
|
||||||
|
"s0": [
|
||||||
|
27,
|
||||||
|
8,
|
||||||
|
27,
|
||||||
|
8,
|
||||||
|
27,
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"s1": [
|
||||||
|
26,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
3,
|
||||||
|
39,
|
||||||
|
39
|
||||||
|
],
|
||||||
|
"s2": [
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
"s3": [
|
||||||
|
29,
|
||||||
|
41,
|
||||||
|
42,
|
||||||
|
47,
|
||||||
|
47,
|
||||||
|
42,
|
||||||
|
47,
|
||||||
|
42
|
||||||
|
],
|
||||||
|
"s4": [
|
||||||
|
23,
|
||||||
|
11,
|
||||||
|
44,
|
||||||
|
29,
|
||||||
|
29,
|
||||||
|
29,
|
||||||
|
41,
|
||||||
|
29
|
||||||
|
],
|
||||||
|
"s5": [
|
||||||
|
35,
|
||||||
|
11,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
11,
|
||||||
|
0,
|
||||||
|
11,
|
||||||
|
15
|
||||||
|
],
|
||||||
|
"s6": [
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
28,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
28,
|
||||||
|
28
|
||||||
|
],
|
||||||
|
"s7": [
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
39,
|
||||||
|
8,
|
||||||
|
47
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"avg_batch_occupancy": 1.0,
|
||||||
|
"cache_misses": 0,
|
||||||
|
"concurrency": 1,
|
||||||
|
"decode_batches": 56,
|
||||||
|
"decode_tokens": 56,
|
||||||
|
"max_batch_occupancy": 1,
|
||||||
|
"peak_kv_bytes": 15360,
|
||||||
|
"prefill_tokens": 32,
|
||||||
|
"rejected_admissions": 0,
|
||||||
|
"ticks": 64,
|
||||||
|
"tokens_per_tick": 1.375
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg_batch_occupancy": 1.75,
|
||||||
|
"cache_misses": 0,
|
||||||
|
"concurrency": 2,
|
||||||
|
"decode_batches": 32,
|
||||||
|
"decode_tokens": 56,
|
||||||
|
"max_batch_occupancy": 2,
|
||||||
|
"peak_kv_bytes": 29184,
|
||||||
|
"prefill_tokens": 32,
|
||||||
|
"rejected_admissions": 0,
|
||||||
|
"ticks": 33,
|
||||||
|
"tokens_per_tick": 2.6667
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg_batch_occupancy": 3.1111,
|
||||||
|
"cache_misses": 0,
|
||||||
|
"concurrency": 4,
|
||||||
|
"decode_batches": 18,
|
||||||
|
"decode_tokens": 56,
|
||||||
|
"max_batch_occupancy": 4,
|
||||||
|
"peak_kv_bytes": 52224,
|
||||||
|
"prefill_tokens": 32,
|
||||||
|
"rejected_admissions": 0,
|
||||||
|
"ticks": 19,
|
||||||
|
"tokens_per_tick": 4.6316
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"avg_batch_occupancy": 4.0,
|
||||||
|
"cache_misses": 0,
|
||||||
|
"concurrency": 8,
|
||||||
|
"decode_batches": 14,
|
||||||
|
"decode_tokens": 56,
|
||||||
|
"max_batch_occupancy": 7,
|
||||||
|
"peak_kv_bytes": 75264,
|
||||||
|
"prefill_tokens": 32,
|
||||||
|
"rejected_admissions": 0,
|
||||||
|
"ticks": 15,
|
||||||
|
"tokens_per_tick": 5.8667
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"saturation_concurrency": 8,
|
||||||
|
"schema_version": 1
|
||||||
|
},
|
||||||
|
"evidence_kind": "synthetic-unit",
|
||||||
|
"mid_run_telemetry_concurrency_4": {
|
||||||
|
"active_sessions": 4,
|
||||||
|
"batch_occupancy_avg": 4.0,
|
||||||
|
"batch_occupancy_last": 4,
|
||||||
|
"batch_occupancy_max": 4,
|
||||||
|
"completed_sessions": 0,
|
||||||
|
"decode_tokens_per_sec": 1637.355,
|
||||||
|
"decode_tokens_total": 20,
|
||||||
|
"kv_budget_bytes": 67108864,
|
||||||
|
"kv_pressure": 0.0008,
|
||||||
|
"kv_total_bytes": 55296,
|
||||||
|
"prefill_tokens_per_sec": 1309.884,
|
||||||
|
"prefill_tokens_total": 16,
|
||||||
|
"queue_depth": 4,
|
||||||
|
"rejected_admissions_total": 0,
|
||||||
|
"rejected_by_reason": {},
|
||||||
|
"scratch_budget_bytes": 4,
|
||||||
|
"scratch_pressure": 1.0,
|
||||||
|
"scratch_used_bytes": 4,
|
||||||
|
"ticks": 6,
|
||||||
|
"weight_bytes": 0
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"hidden": 32,
|
||||||
|
"n_heads": 4,
|
||||||
|
"n_layers": 6,
|
||||||
|
"reference": "pure-numpy KV-cached dense-Llama (tests/test_hot_kv_state)",
|
||||||
|
"vocab": 48
|
||||||
|
},
|
||||||
|
"schema_version": 1,
|
||||||
|
"workload": {
|
||||||
|
"max_new_tokens": 8,
|
||||||
|
"prompt_len": 4,
|
||||||
|
"sessions": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
223
.scratch/distributed-gguf-runtime/evidence/DGR-013/README.md
Normal file
223
.scratch/distributed-gguf-runtime/evidence/DGR-013/README.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# DGR-013 — Harden failure, cancellation, and restart semantics: evidence
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Date: 2026-07-16
|
||||||
|
Evidence kind: **synthetic-unit** (pure-numpy KV-cached dense-Llama reference +
|
||||||
|
node-local hardened stream). No model download, no GPU, no torch, no network, no
|
||||||
|
API credit.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implemented bounded, explicit failure/cancellation/restart semantics for the
|
||||||
|
per-Route-Session decode stream, layered on the DGR-007 Hot KV State manager
|
||||||
|
(isolated `(session, epoch)` KV) and the DGR-012 continuous-batch scheduler. The
|
||||||
|
goal (RALPH product objective) is that distributed speed never comes with hanging
|
||||||
|
or corrupted generations: every blocked op is bounded, every cancel frees state,
|
||||||
|
duplicate steps are idempotent, uncertain mutations are never silently replayed,
|
||||||
|
alpha failover restarts from token zero, and billing distinguishes what actually
|
||||||
|
completed.
|
||||||
|
|
||||||
|
Everything runs against the same deterministic numpy dense-Llama reference the
|
||||||
|
default gate uses (`tests/test_hot_kv_state.py::_KvDenseLlama` / `_KvReferenceShard`),
|
||||||
|
so the whole failure matrix is deterministic, download-free, GPU-free, and
|
||||||
|
API-credit-free while exercising the **real** KV isolation path
|
||||||
|
(`KvBoundaryAdapter` + `HotKvStateManager`). The pinned llama.cpp worker (DGR-008)
|
||||||
|
implements the identical adapter contract, so the semantics carry over to native
|
||||||
|
execution unchanged.
|
||||||
|
|
||||||
|
### What was built (`packages/node/meshnet_node/failure_semantics.py`, new)
|
||||||
|
|
||||||
|
- **`DeadlineGuard` + `StreamTerminated`** — bounds every step against an absolute
|
||||||
|
deadline and a heartbeat-timeout on an injected clock. A reached deadline or a
|
||||||
|
lost heartbeat (peer health loss) raises `StreamTerminated(kind)` so a blocked
|
||||||
|
stream terminates instead of hanging. (**AC: deadlines/heartbeat terminate
|
||||||
|
blocked ops.**)
|
||||||
|
- **`CancellationToken`, `ShardCancellationGroup`, `CancellationOutcome`** — one
|
||||||
|
cancel fans across **every** node-local Shard of a Route Session, releasing the
|
||||||
|
`(session, epoch)` KV on each shard's manager and invoking every queued-buffer
|
||||||
|
release callback (the pending activation bundles). Idempotent. The DGR-012
|
||||||
|
scheduler also gains a `cancel()` that drops queued/active work on this node and
|
||||||
|
frees its KV. (**AC: cancellation propagates across every Shard, releases KV +
|
||||||
|
queued buffers.**)
|
||||||
|
- **`IdempotencyLedger`, `StepKey`, `StepDisposition`, `UncertainMutationError`** —
|
||||||
|
records each committed `(session, epoch, step)`; a duplicate delivery returns the
|
||||||
|
recorded token with no re-mutation. A step whose mutation outcome is *uncertain*
|
||||||
|
(worker died mid-step) is marked uncertain and can **never** be replayed
|
||||||
|
silently — `begin()` on an uncertain (or still in-flight) step raises
|
||||||
|
`UncertainMutationError`, forcing verify-or-restart. (**AC: duplicate steps
|
||||||
|
idempotent; uncertain mutations never replayed silently.**)
|
||||||
|
- **`RestartController`** — alpha failover: opens the *next* route epoch, releases
|
||||||
|
every shard's prior-epoch KV, and `assert_fresh_start` fails closed if any shard
|
||||||
|
still holds new-epoch KV. The restart re-prefills the whole prompt from token
|
||||||
|
zero; the failed epoch becomes stale (KV manager rejects it). Unverified KV is
|
||||||
|
never migrated (RALPH runtime decision #14). (**AC: alpha failover restarts from
|
||||||
|
token zero rather than importing unverified KV.**)
|
||||||
|
- **`WorkStatus`, `WorkRecord`, `WorkLedger`** — a typed per-attempt work record
|
||||||
|
with four distinct statuses: `completed`, `cancelled`, `failed`, `unverified`.
|
||||||
|
Only `completed` records are billable; cancelled/failed/unverified tokens are
|
||||||
|
recorded for observability but never charged. JSON-safe for the tracker billing
|
||||||
|
handoff (`packages/tracker/meshnet_tracker/billing.py` charges only completed,
|
||||||
|
verified work). (**AC: billing/work records distinguish completed/cancelled/
|
||||||
|
failed/unverified.**)
|
||||||
|
- **`HardenedSessionRunner`** — composes all of the above to drive one session's
|
||||||
|
prefill+decode through the adapter under a deadline/heartbeat guard + cancel
|
||||||
|
token, records the typed outcome, and `run_with_failover` restarts a transient
|
||||||
|
failure from token zero on a fresh epoch.
|
||||||
|
- **`FailureKind` + `classify_exception` + `work_status_for`** — stable-string
|
||||||
|
classification of worker death, stream reset, malformed bundle, stale epoch,
|
||||||
|
cache miss, deadline, heartbeat loss, and cancel, plus the failure→billing-status
|
||||||
|
mapping. Suitable for the native protocol's structured status.
|
||||||
|
|
||||||
|
### Scheduler extension (`packages/node/meshnet_node/batch_scheduler.py`, DGR-012 file, additive)
|
||||||
|
|
||||||
|
Purely additive so the DGR-012 gate stays green (16/16):
|
||||||
|
- `DoneReason.CANCELLED` / `DoneReason.FAILED` terminal reasons.
|
||||||
|
- `ContinuousBatchScheduler.cancel(session_id, *, reason)` — drops a queued
|
||||||
|
session from the bounded queue or releases an active session's KV, moving it to
|
||||||
|
the done set with a non-completed reason (never counted as completed work).
|
||||||
|
- `SchedulerTelemetry.cancelled_sessions` / `failed_sessions` counters.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `packages/node/meshnet_node/failure_semantics.py` — new module (the whole
|
||||||
|
failure/cancel/restart layer above).
|
||||||
|
- `packages/node/meshnet_node/batch_scheduler.py` — additive `cancel()` + two
|
||||||
|
`DoneReason` members + two telemetry counters (DGR-012 file; its 16 tests still
|
||||||
|
pass unchanged).
|
||||||
|
- `tests/test_failure_semantics.py` — new, 22 tests (matrix below); reuses the
|
||||||
|
DGR-007 numpy reference via `from test_hot_kv_state import _KvDenseLlama,
|
||||||
|
_KvReferenceShard`.
|
||||||
|
- `.scratch/distributed-gguf-runtime/evidence/DGR-013/` — this README,
|
||||||
|
`commands.txt`, `generate_evidence.py`, `results.json`.
|
||||||
|
- `.ralph-tui/progress.md` — appended the DGR-013 note.
|
||||||
|
- `.scratch/distributed-gguf-runtime/issues/13-...md` — set `Status: done`.
|
||||||
|
|
||||||
|
## Acceptance criteria → evidence
|
||||||
|
|
||||||
|
| Criterion | Tests (`tests/test_failure_semantics.py`) |
|
||||||
|
|---|---|
|
||||||
|
| Deadlines/heartbeat loss terminate blocked stream ops | `test_deadline_terminates_a_blocked_stream_and_releases_kv`, `test_heartbeat_loss_terminates_a_blocked_stream`, `test_deadline_guard_reports_remaining_and_resets_on_heartbeat` |
|
||||||
|
| Cancellation propagates across every Shard, releases KV + queued buffers | `test_cancellation_token_terminates_stream_and_releases_kv`, `test_shard_cancellation_group_releases_every_shard_and_queued_buffers`, `test_scheduler_cancel_drains_queue_and_releases_active_kv`, `test_scheduler_cancel_rejects_a_completed_reason` |
|
||||||
|
| Duplicate steps idempotent; uncertain mutations never replayed silently | `test_duplicate_step_delivery_is_idempotent_no_remutation`, `test_idempotent_run_replays_tokens_without_advancing_kv`, `test_uncertain_mutation_is_never_replayed_silently`, `test_in_flight_duplicate_is_treated_as_uncertain` |
|
||||||
|
| Alpha failover restarts from token zero, no unverified KV import | `test_alpha_failover_restarts_from_token_zero_and_completes`, `test_failover_refuses_to_import_unverified_kv`, `test_non_restartable_failure_is_not_retried` |
|
||||||
|
| Worker death, stream reset, malformed bundle, stale epoch, cache miss | `test_worker_death_midstream_is_unverified_and_marks_step_uncertain`, `test_stream_reset_is_restartable_failure`, `test_malformed_bundle_is_classified_and_does_not_corrupt_kv`, `test_stale_epoch_reference_is_rejected_and_classified`, `test_cache_miss_midstream_is_restartable` |
|
||||||
|
| Billing/work records distinguish completed/cancelled/failed/unverified | `test_work_ledger_distinguishes_all_four_statuses`, `test_work_status_and_classification_mapping`, plus the clean-run billability check `test_clean_run_matches_stateless_reference_and_is_billable` |
|
||||||
|
|
||||||
|
## Failure matrix (real, deterministic — `results.json`)
|
||||||
|
|
||||||
|
Generated by `generate_evidence.py` against the numpy dense-Llama (prompt `[7,3,9,1]`,
|
||||||
|
8 new tokens):
|
||||||
|
|
||||||
|
| scenario | status | failure_kind | tokens | restartable | KV released |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| clean | completed | — | 8 | — | (held, then reaped) |
|
||||||
|
| deadline | failed | deadline-exceeded | 2 | no | yes |
|
||||||
|
| heartbeat_loss | failed | heartbeat-lost | 3 | no | yes |
|
||||||
|
| cancel | cancelled | cancelled | 3 | no | yes |
|
||||||
|
| worker_death | unverified | worker-death | 3 | yes | yes |
|
||||||
|
| stream_reset | failed | stream-reset | — | yes | yes |
|
||||||
|
| stale_epoch | failed | stale-epoch | — | no | (never opened) |
|
||||||
|
| cache_miss | failed | cache-miss | 4 | yes | (already evicted) |
|
||||||
|
| alpha_failover | **completed** (epoch 1) | — | 8 | — | old epoch stale |
|
||||||
|
|
||||||
|
Alpha failover: attempt 0 (epoch 0) dies mid-step → `unverified`; the controller
|
||||||
|
advances to epoch 1, drops epoch-0 KV, and the restart re-prefills from token zero
|
||||||
|
→ `completed`, reproducing the byte-identical stateless reference. The old epoch is
|
||||||
|
now stale (a reference to it raises `StaleRouteEpochError`). Work ledger:
|
||||||
|
`{completed: 2, cancelled: 1, failed: 0, unverified: 2}`, `billable_tokens = 16`
|
||||||
|
(only the two completed streams — the failover restart and the clean run — are
|
||||||
|
billed; the cancelled and the two unverified attempts are not).
|
||||||
|
|
||||||
|
## Commands and real results
|
||||||
|
|
||||||
|
See `commands.txt`. Key results:
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_failure_semantics.py -> 22 passed
|
||||||
|
tests/test_batch_scheduler.py -> 16 passed (DGR-012 unchanged)
|
||||||
|
tests/test_hot_kv_state.py -> 22 passed (DGR-007)
|
||||||
|
tests/test_gguf_backend.py -> 2 passed (DGR-009)
|
||||||
|
python -m compileall -q packages tests -> exit 0
|
||||||
|
git diff --check -> exit 0
|
||||||
|
python -m pytest -q -> 16 failed, 792 passed, 14 skipped in 253.93s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full-suite baseline (pre-existing, unrelated failures)
|
||||||
|
|
||||||
|
The 16 failures are **pre-existing and unrelated to DGR-013**. None import
|
||||||
|
`failure_semantics` or `batch_scheduler`; they live in the tracker/control-plane,
|
||||||
|
node-startup, doctor, calibration, and route-benchmark suites and fail on the
|
||||||
|
model-download / control-plane / recipe-admission paths (e.g.
|
||||||
|
`UnsupportedRecipeParam: worker_transport` from the DGR-009 native recipe against
|
||||||
|
the Torch backend, and Torch/HF-model startup that this deterministic sandbox does
|
||||||
|
not provide). Removing the two DGR-013 files and re-running the failing tests
|
||||||
|
reproduces the identical failures (see `commands.txt`, 4-test spot check → same
|
||||||
|
4 failures), so DGR-013 introduces no new failure.
|
||||||
|
|
||||||
|
Exact failing set (16):
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/test_dynamic_routing.py::test_admin_can_replace_a_served_model_and_release_it
|
||||||
|
tests/test_manual_route_benchmark.py::test_pinned_route_uses_named_node
|
||||||
|
tests/test_manual_route_benchmark.py::test_unknown_route_node_is_400
|
||||||
|
tests/test_manual_route_benchmark.py::test_invalid_route_shape_is_400
|
||||||
|
tests/test_manual_route_benchmark.py::test_clients_without_route_are_unaffected
|
||||||
|
tests/test_manual_route_benchmark.py::test_benchmark_records_one_and_two_node_routes
|
||||||
|
tests/test_node_doctor.py::test_the_shipped_recipes_are_all_applicable_by_the_backend
|
||||||
|
tests/test_node_doctor.py::test_cli_doctor_flags_select_what_is_validated
|
||||||
|
tests/test_node_startup.py::test_preset_model_with_hf_repo_loads_torch_backend
|
||||||
|
tests/test_node_startup.py::test_real_model_startup_registers_downloaded_inventory_without_checksum
|
||||||
|
tests/test_toploc_calibration_dispatch.py::test_calibration_run_dispatches_only_solo_capable_nodes
|
||||||
|
tests/test_toploc_calibration_dispatch.py::test_calibration_run_persists_corpus_and_results_endpoint_reports_it
|
||||||
|
tests/test_toploc_calibration_dispatch.py::test_calibration_run_node_without_commitment_endpoint_is_skipped_not_failed
|
||||||
|
tests/test_tracker_capability_admission.py::test_an_enforcing_tracker_never_routes_a_node_whose_proof_does_not_cover_it[invalid]
|
||||||
|
tests/test_tracker_routing.py::test_torch_node_applies_tracker_load_shard_directive
|
||||||
|
tests/test_tracker_routing.py::test_shard_heal_cycle_surviving_node_covers_dead_peers_gap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations and deferred work
|
||||||
|
|
||||||
|
- **Synthetic-unit, not real weights.** Semantics are exercised against the
|
||||||
|
deterministic numpy dense-Llama, not a downloaded GGUF, to keep the default gate
|
||||||
|
deterministic/download-free/GPU-free. Real worker-death/stream-reset behavior on
|
||||||
|
a live llama.cpp worker over gRPC belongs to DGR-008/DGR-010 (DGR-010 is blocked
|
||||||
|
— no certified dense-Llama artifact on this machine; see
|
||||||
|
`evidence/DGR-010/BLOCKED.md`).
|
||||||
|
- **Single-node per-session stream.** `HardenedSessionRunner` drives one full-shard
|
||||||
|
session (the node-local case); multi-node cancellation is modelled by
|
||||||
|
`ShardCancellationGroup` fanning across each node's KV manager. The cross-node
|
||||||
|
propagation *transport* (cancel frames over gRPC/relay) is the native protocol's
|
||||||
|
job (DGR-002/008); this story owns the local release + record semantics the
|
||||||
|
transport triggers.
|
||||||
|
- **Fault injection is deterministic.** Worker death is a shard that raises on the
|
||||||
|
Nth step; stream reset / deadline / heartbeat are injected via an explicit clock
|
||||||
|
and hook. This is what makes the matrix reproducible; live fault behavior is a
|
||||||
|
native/real-hardware property.
|
||||||
|
- **Greedy sampling only.** Reuses the DGR-006 greedy `SamplingContract`; the
|
||||||
|
idempotent-replay equality check depends on order-independent greedy decode.
|
||||||
|
- **Native / llama.cpp gates N/A.** No native code, CMake, or llama.cpp patch was
|
||||||
|
touched (same as DGR-005/006/007/012), so those gates do not apply.
|
||||||
|
|
||||||
|
## Compatibility / migration notes
|
||||||
|
|
||||||
|
- `failure_semantics.py` is a new, additive module — no existing behavior changes.
|
||||||
|
- `batch_scheduler.py` changes are additive (new enum members, one method, two
|
||||||
|
telemetry fields); the DGR-012 contract and its 16 tests are unchanged.
|
||||||
|
- `WorkRecord.to_dict()` / `WorkLedger.to_dict()` are JSON-safe and map cleanly to
|
||||||
|
the tracker `BillingLedger.charge_request` inputs: report `node_work` only for
|
||||||
|
`billable` (completed) records so cancelled/failed/unverified work is never
|
||||||
|
charged. `FailureKind` / `WorkStatus` are stable strings suitable for the native
|
||||||
|
protocol's structured status and the capability/heartbeat report.
|
||||||
|
|
||||||
|
## Handoff for dependent stories
|
||||||
|
|
||||||
|
- **DGR-008 (C++ gRPC worker):** implement the same contract natively — the worker
|
||||||
|
maps a transport deadline/heartbeat to `StreamTerminated`, a dropped stream to a
|
||||||
|
restartable failure, and a mid-`llama_decode` crash to an *uncertain* step
|
||||||
|
(mark-uncertain, never silent replay). `RestartController.failover` maps to
|
||||||
|
opening a fresh llama sequence under the new `(session, epoch)`; the failed
|
||||||
|
sequence's KV is dropped, never migrated.
|
||||||
|
- **DGR-010/DGR-014 (real acceptance / release gate):** drive the same failure
|
||||||
|
scenarios against the live worker to produce real cleanup/latency numbers, and
|
||||||
|
feed the `WorkLedger` status split into the billing/attribution comparison —
|
||||||
|
only `completed` work is charged.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# DGR-013 — exact commands and real results (worktree venv)
|
||||||
|
VP=/run/media/popov/d/DEV/repos/d-popov.com/AI/.venv/bin/python
|
||||||
|
|
||||||
|
# Targeted story tests (this story)
|
||||||
|
$VP -m pytest -q tests/test_failure_semantics.py
|
||||||
|
# -> 22 passed
|
||||||
|
|
||||||
|
# Dependency gates stay green
|
||||||
|
$VP -m pytest -q tests/test_batch_scheduler.py # DGR-012
|
||||||
|
# -> 16 passed
|
||||||
|
$VP -m pytest -q tests/test_hot_kv_state.py # DGR-007
|
||||||
|
# -> 22 passed
|
||||||
|
$VP -m pytest -q tests/test_gguf_backend.py # DGR-009
|
||||||
|
# -> 2 passed
|
||||||
|
|
||||||
|
# Quality gates
|
||||||
|
$VP -m compileall -q packages tests
|
||||||
|
# -> exit 0
|
||||||
|
git diff --check
|
||||||
|
# -> exit 0
|
||||||
|
|
||||||
|
# Machine-readable evidence
|
||||||
|
$VP .scratch/distributed-gguf-runtime/evidence/DGR-013/generate_evidence.py
|
||||||
|
# -> wrote results.json; work statuses {'completed':2,'cancelled':1,'failed':0,'unverified':2} billable_tokens=16
|
||||||
|
|
||||||
|
# Full deterministic suite
|
||||||
|
$VP -m pytest -q -p no:cacheprovider
|
||||||
|
# -> 16 failed, 792 passed, 14 skipped in 253.93s
|
||||||
|
|
||||||
|
# Clean-tree reproduction of the 16 pre-existing failures (DGR-013 files removed)
|
||||||
|
# rm packages/node/meshnet_node/failure_semantics.py tests/test_failure_semantics.py
|
||||||
|
$VP -m pytest -q tests/test_dynamic_routing.py::test_admin_can_replace_a_served_model_and_release_it \
|
||||||
|
tests/test_node_doctor.py::test_the_shipped_recipes_are_all_applicable_by_the_backend \
|
||||||
|
tests/test_tracker_routing.py::test_torch_node_applies_tracker_load_shard_directive \
|
||||||
|
tests/test_node_startup.py::test_preset_model_with_hf_repo_loads_torch_backend
|
||||||
|
# -> 4 failed (same failures reproduce without any DGR-013 change)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Generate deterministic DGR-013 failure/cancel/restart evidence (results.json).
|
||||||
|
|
||||||
|
Runs the real hardened per-session stream (``HardenedSessionRunner`` over the
|
||||||
|
DGR-007 ``KvBoundaryAdapter`` + ``HotKvStateManager``) through each failure mode
|
||||||
|
with the same pure-numpy dense-Llama reference the default gate uses. No model
|
||||||
|
download, no GPU, no torch, no network, no API credit.
|
||||||
|
|
||||||
|
Run from the repo root with the worktree venv:
|
||||||
|
|
||||||
|
.venv/bin/python .scratch/distributed-gguf-runtime/evidence/DGR-013/generate_evidence.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Make the worktree packages and the DGR-007 numpy reference importable, exactly
|
||||||
|
# as pytest's prepend-import + conftest do.
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "packages", "node"))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "tests"))
|
||||||
|
|
||||||
|
from meshnet_node.hot_kv_state import ( # noqa: E402
|
||||||
|
HotKvStateConfig,
|
||||||
|
HotKvStateManager,
|
||||||
|
KvBoundaryAdapter,
|
||||||
|
StaleRouteEpochError,
|
||||||
|
kv_recipe_for,
|
||||||
|
)
|
||||||
|
from meshnet_node.batch_scheduler import GenerationRequest # noqa: E402
|
||||||
|
from meshnet_node.failure_semantics import ( # noqa: E402
|
||||||
|
CancellationToken,
|
||||||
|
FailureKind,
|
||||||
|
HardenedSessionRunner,
|
||||||
|
RestartController,
|
||||||
|
StreamTerminated,
|
||||||
|
WorkLedger,
|
||||||
|
WorkStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
from test_hot_kv_state import _KvDenseLlama, _KvReferenceShard # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FaultyShard(_KvReferenceShard):
|
||||||
|
def __init__(self, model, start, end, *, fail_at_call=None):
|
||||||
|
super().__init__(model, start, end)
|
||||||
|
self._fail_at_call = fail_at_call
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def run_layers_cached(self, hidden, *, positions, past_kv):
|
||||||
|
self.calls += 1
|
||||||
|
if self._fail_at_call is not None and self.calls == self._fail_at_call:
|
||||||
|
raise RuntimeError("worker died mid-step")
|
||||||
|
return super().run_layers_cached(hidden, positions=positions, past_kv=past_kv)
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 0.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, d):
|
||||||
|
self.now += d
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter(model, *, config=None, shard=None):
|
||||||
|
shard = shard or _KvReferenceShard(model, 0, model.n_layers - 1)
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(shard), config=config)
|
||||||
|
return KvBoundaryAdapter(shard, manager)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen(sid, prompt, n, epoch=0):
|
||||||
|
return GenerationRequest(
|
||||||
|
session_id=sid, route_epoch=epoch,
|
||||||
|
prompt_token_ids=tuple(prompt), max_new_tokens=n,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kv_released(manager, sid, epoch):
|
||||||
|
from meshnet_node.hot_kv_state import CacheMiss
|
||||||
|
return isinstance(manager.resolve(sid, epoch), CacheMiss)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
prompt = [7, 3, 9, 1]
|
||||||
|
n_new = 8
|
||||||
|
ledger = WorkLedger()
|
||||||
|
scenarios = []
|
||||||
|
|
||||||
|
# 1. Clean baseline.
|
||||||
|
ad = _adapter(model)
|
||||||
|
r = HardenedSessionRunner(ad, work_ledger=ledger).run(_gen("clean", prompt, n_new))
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "clean",
|
||||||
|
"status": r.status.value,
|
||||||
|
"tokens": r.token_count,
|
||||||
|
"matches_reference": list(r.tokens) == model.stateless_greedy(prompt, n_new),
|
||||||
|
"kv_released": _kv_released(ad.manager, "clean", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Deadline terminates a blocked stream.
|
||||||
|
clk = _Clock()
|
||||||
|
ad = _adapter(model)
|
||||||
|
r = HardenedSessionRunner(ad, clock=clk).run(
|
||||||
|
_gen("deadline", prompt, 50), deadline=3.0,
|
||||||
|
before_step=lambda _s: clk.advance(1.0),
|
||||||
|
)
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "deadline", "status": r.status.value,
|
||||||
|
"failure_kind": r.failure_kind.value, "tokens": r.token_count,
|
||||||
|
"kv_released": _kv_released(ad.manager, "deadline", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Heartbeat/health loss terminates a blocked stream.
|
||||||
|
clk = _Clock()
|
||||||
|
ad = _adapter(model)
|
||||||
|
r = HardenedSessionRunner(ad, clock=clk).run(
|
||||||
|
_gen("heartbeat", prompt, 50), heartbeat_timeout=1.5,
|
||||||
|
heartbeat=lambda step: step < 2,
|
||||||
|
before_step=lambda _s: clk.advance(1.0),
|
||||||
|
)
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "heartbeat_loss", "status": r.status.value,
|
||||||
|
"failure_kind": r.failure_kind.value, "tokens": r.token_count,
|
||||||
|
"kv_released": _kv_released(ad.manager, "heartbeat", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Explicit client cancellation releases KV.
|
||||||
|
ad = _adapter(model)
|
||||||
|
tok = CancellationToken()
|
||||||
|
r = HardenedSessionRunner(ad, work_ledger=ledger).run(
|
||||||
|
_gen("cancel", prompt, 50), cancel_token=tok,
|
||||||
|
before_step=lambda step: tok.cancel("client-hangup") if step == 3 else None,
|
||||||
|
)
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "cancel", "status": r.status.value,
|
||||||
|
"failure_kind": r.failure_kind.value, "tokens": r.token_count,
|
||||||
|
"kv_released": _kv_released(ad.manager, "cancel", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. Worker death mid-step -> unverified.
|
||||||
|
ad = _adapter(model, shard=_FaultyShard(model, 0, model.n_layers - 1, fail_at_call=4))
|
||||||
|
r = HardenedSessionRunner(ad, work_ledger=ledger).run(_gen("worker", prompt, n_new))
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "worker_death", "status": r.status.value,
|
||||||
|
"failure_kind": r.failure_kind.value, "tokens": r.token_count,
|
||||||
|
"restartable": r.restartable, "kv_released": _kv_released(ad.manager, "worker", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 6. Stream reset -> failed, restartable.
|
||||||
|
ad = _adapter(model)
|
||||||
|
def reset(step):
|
||||||
|
if step == 2:
|
||||||
|
raise StreamTerminated(FailureKind.STREAM_RESET, "peer reset")
|
||||||
|
r = HardenedSessionRunner(ad).run(_gen("reset", prompt, n_new), before_step=reset)
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "stream_reset", "status": r.status.value,
|
||||||
|
"failure_kind": r.failure_kind.value, "restartable": r.restartable,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 7. Stale epoch -> failed.
|
||||||
|
ad = _adapter(model)
|
||||||
|
ad.manager.open("stale", 5)
|
||||||
|
r = HardenedSessionRunner(ad).run(_gen("stale", prompt, n_new, epoch=3))
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "stale_epoch", "status": r.status.value,
|
||||||
|
"failure_kind": r.failure_kind.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 8. Cache miss mid-stream -> restartable.
|
||||||
|
ad = _adapter(model)
|
||||||
|
mgr = ad.manager
|
||||||
|
r = HardenedSessionRunner(ad).run(
|
||||||
|
_gen("miss", prompt, 12),
|
||||||
|
before_step=lambda step: mgr.release("miss", 0) if step == 4 else None,
|
||||||
|
)
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "cache_miss", "status": r.status.value,
|
||||||
|
"failure_kind": r.failure_kind.value, "tokens": r.token_count,
|
||||||
|
"restartable": r.restartable,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 9. Alpha failover: restart from token zero, no unverified KV import.
|
||||||
|
faulty = _FaultyShard(model, 0, model.n_layers - 1, fail_at_call=3)
|
||||||
|
ad = _adapter(model, shard=faulty)
|
||||||
|
runner = HardenedSessionRunner(ad, work_ledger=ledger)
|
||||||
|
controller = RestartController([ad.manager])
|
||||||
|
fo = runner.run_with_failover(_gen("failover", prompt, n_new, epoch=0), controller,
|
||||||
|
max_restarts=2)
|
||||||
|
old_epoch_stale = False
|
||||||
|
try:
|
||||||
|
ad.manager.resolve("failover", 0)
|
||||||
|
except StaleRouteEpochError:
|
||||||
|
old_epoch_stale = True
|
||||||
|
scenarios.append({
|
||||||
|
"scenario": "alpha_failover",
|
||||||
|
"final_status": fo.outcome.status.value,
|
||||||
|
"final_epoch": fo.outcome.route_epoch,
|
||||||
|
"restarts": fo.restarts,
|
||||||
|
"restarted_from_token_zero": list(fo.outcome.tokens) == model.stateless_greedy(prompt, n_new),
|
||||||
|
"old_epoch_stale": old_epoch_stale,
|
||||||
|
"attempt_statuses": [a.status.value for a in fo.attempts],
|
||||||
|
})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"evidence_kind": "synthetic-unit",
|
||||||
|
"model": {
|
||||||
|
"architecture": model.architecture_adapter,
|
||||||
|
"n_layers": model.n_layers, "vocab": model.vocab, "hidden": model.hidden,
|
||||||
|
},
|
||||||
|
"scenarios": scenarios,
|
||||||
|
"work_ledger": ledger.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
out_path = os.path.join(os.path.dirname(__file__), "results.json")
|
||||||
|
with open(out_path, "w") as fh:
|
||||||
|
json.dump(result, fh, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
counts = ledger.counts_by_status()
|
||||||
|
print(f"wrote {out_path}")
|
||||||
|
print(f"work statuses: {counts} billable_tokens={ledger.billable_tokens()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
135
.scratch/distributed-gguf-runtime/evidence/DGR-013/results.json
Normal file
135
.scratch/distributed-gguf-runtime/evidence/DGR-013/results.json
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"evidence_kind": "synthetic-unit",
|
||||||
|
"model": {
|
||||||
|
"architecture": "dense-llama",
|
||||||
|
"n_layers": 6,
|
||||||
|
"vocab": 48,
|
||||||
|
"hidden": 32
|
||||||
|
},
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"scenario": "clean",
|
||||||
|
"status": "completed",
|
||||||
|
"tokens": 8,
|
||||||
|
"matches_reference": true,
|
||||||
|
"kv_released": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "deadline",
|
||||||
|
"status": "failed",
|
||||||
|
"failure_kind": "deadline-exceeded",
|
||||||
|
"tokens": 2,
|
||||||
|
"kv_released": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "heartbeat_loss",
|
||||||
|
"status": "failed",
|
||||||
|
"failure_kind": "heartbeat-lost",
|
||||||
|
"tokens": 3,
|
||||||
|
"kv_released": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "cancel",
|
||||||
|
"status": "cancelled",
|
||||||
|
"failure_kind": "cancelled",
|
||||||
|
"tokens": 3,
|
||||||
|
"kv_released": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "worker_death",
|
||||||
|
"status": "unverified",
|
||||||
|
"failure_kind": "worker-death",
|
||||||
|
"tokens": 3,
|
||||||
|
"restartable": true,
|
||||||
|
"kv_released": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "stream_reset",
|
||||||
|
"status": "failed",
|
||||||
|
"failure_kind": "stream-reset",
|
||||||
|
"restartable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "stale_epoch",
|
||||||
|
"status": "failed",
|
||||||
|
"failure_kind": "stale-epoch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "cache_miss",
|
||||||
|
"status": "failed",
|
||||||
|
"failure_kind": "cache-miss",
|
||||||
|
"tokens": 4,
|
||||||
|
"restartable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scenario": "alpha_failover",
|
||||||
|
"final_status": "completed",
|
||||||
|
"final_epoch": 1,
|
||||||
|
"restarts": 1,
|
||||||
|
"restarted_from_token_zero": true,
|
||||||
|
"old_epoch_stale": true,
|
||||||
|
"attempt_statuses": [
|
||||||
|
"unverified",
|
||||||
|
"completed"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"work_ledger": {
|
||||||
|
"schema_version": 1,
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"session_id": "clean",
|
||||||
|
"route_epoch": 0,
|
||||||
|
"status": "completed",
|
||||||
|
"tokens": 8,
|
||||||
|
"failure_kind": null,
|
||||||
|
"detail": "",
|
||||||
|
"billable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "cancel",
|
||||||
|
"route_epoch": 0,
|
||||||
|
"status": "cancelled",
|
||||||
|
"tokens": 3,
|
||||||
|
"failure_kind": "cancelled",
|
||||||
|
"detail": "operation cancelled: client-hangup",
|
||||||
|
"billable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "worker",
|
||||||
|
"route_epoch": 0,
|
||||||
|
"status": "unverified",
|
||||||
|
"tokens": 3,
|
||||||
|
"failure_kind": "worker-death",
|
||||||
|
"detail": "worker died mid-step",
|
||||||
|
"billable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "failover",
|
||||||
|
"route_epoch": 0,
|
||||||
|
"status": "unverified",
|
||||||
|
"tokens": 2,
|
||||||
|
"failure_kind": "worker-death",
|
||||||
|
"detail": "worker died mid-step",
|
||||||
|
"billable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "failover",
|
||||||
|
"route_epoch": 1,
|
||||||
|
"status": "completed",
|
||||||
|
"tokens": 8,
|
||||||
|
"failure_kind": null,
|
||||||
|
"detail": "",
|
||||||
|
"billable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts_by_status": {
|
||||||
|
"completed": 2,
|
||||||
|
"cancelled": 1,
|
||||||
|
"failed": 0,
|
||||||
|
"unverified": 2
|
||||||
|
},
|
||||||
|
"billable_tokens": 16
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# DGR-014 — Blocked handoff
|
||||||
|
|
||||||
|
Status: blocked
|
||||||
|
Date: 2026-07-16
|
||||||
|
|
||||||
|
## Blocker
|
||||||
|
|
||||||
|
This release-gate story cannot be completed in the current workspace state because the prerequisite real-model comparison chain is still missing its certified dense-Llama artifact on mounted storage.
|
||||||
|
|
||||||
|
Verified blockers:
|
||||||
|
|
||||||
|
- `DGR-011` is still not passed in `.scratch/distributed-gguf-runtime/prd.json`.
|
||||||
|
- `DGR-011` is explicitly blocked in `.scratch/distributed-gguf-runtime/evidence/DGR-011/BLOCKED.md`.
|
||||||
|
- `DGR-011` depends on `DGR-010`, and `DGR-010` is blocked because there is no certified dense-Llama artifact available on the mounted drive.
|
||||||
|
- Current mounted-model storage still only shows Qwen artifacts and llama.cpp vocab GGUFs, not the certified dense-Llama GGUF/safetensors pair needed for a comparable real run.
|
||||||
|
|
||||||
|
## Verified current state
|
||||||
|
|
||||||
|
- The DGR-001 performance contract exists and defines the benchmark lanes, metrics, and stop condition that later release gates must keep unchanged.
|
||||||
|
- The DGR-012 scheduler and DGR-013 failure semantics evidence are present and usable as supporting context, but they do not satisfy the real final comparison required here.
|
||||||
|
- `packages/node/meshnet_node/performance_contract.py` already contains the contract metadata and a live endpoint benchmark shim, but there is no recorded DGR-014 release-gate run and no final immutable comparison artifact.
|
||||||
|
- `evidence/DGR-014/README.md` does not exist yet because the acceptance criteria could not be completed.
|
||||||
|
|
||||||
|
## Commands run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,260p' .claude/memory/MEMORY.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/RALPH-CONTEXT.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/issues/14-enforce-the-gguf-versus-safetensors-release-gate.md
|
||||||
|
sed -n '1,260p' .ralph-tui/progress.md
|
||||||
|
git status --short
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/prd.json
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-001/README.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-012/README.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-013/README.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-011/BLOCKED.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-010/BLOCKED.md
|
||||||
|
find /run/media/popov/d/DEV/models /run/media/popov/d/DEV/llamacpp/llama.cpp/models -maxdepth 4 \( -iname '*llama*' -o -iname '*deepseek*' -o -iname '*dense*' -o -name '*.gguf' -o -name '*.safetensors' -o -name 'config.json' \)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- No certified dense-Llama artifact is mounted, so the real distributed safetensors-versus-GGUF comparison cannot be executed.
|
||||||
|
- No immutable release-gate evidence can be produced without that artifact and the completed DGR-011 route comparison.
|
||||||
|
- No code was changed in this iteration.
|
||||||
|
|
||||||
|
## Compatibility notes
|
||||||
|
|
||||||
|
- The DGR-001 contract remains the source of truth for thresholds and metric names.
|
||||||
|
- Any future DGR-014 run must keep those thresholds unchanged and compare the same certified model/hardware/network scenario for both routes.
|
||||||
|
|
||||||
|
## Dependent-story handoff
|
||||||
|
|
||||||
|
- Finish `DGR-010` and `DGR-011` first with a certified dense-Llama artifact on mounted storage.
|
||||||
|
- Then run the current distributed safetensors and distributed GGUF routes on the same comparable scenario, record the final numbers in `evidence/DGR-014/README.md`, and update the issue status only after the gate passes.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# DGR-015 — Blocked handoff
|
||||||
|
|
||||||
|
Status: blocked
|
||||||
|
Date: 2026-07-16
|
||||||
|
|
||||||
|
## Blocker
|
||||||
|
|
||||||
|
This story cannot be completed in the current workspace state because its
|
||||||
|
mandatory prerequisite, DGR-014, is still not passed.
|
||||||
|
|
||||||
|
Verified blocker chain:
|
||||||
|
|
||||||
|
- `.scratch/distributed-gguf-runtime/prd.json` still marks `DGR-014` as
|
||||||
|
`"passes": false`, so DGR-015 is not released for completion.
|
||||||
|
- `.scratch/distributed-gguf-runtime/evidence/DGR-014/BLOCKED.md` records the
|
||||||
|
release-gate blocker: the certified dense-Llama artifact required for the
|
||||||
|
comparable real-model comparison is not mounted on this machine.
|
||||||
|
- `DGR-014` depends on `DGR-011`, which is also blocked because `DGR-010`
|
||||||
|
cannot run without that same certified dense-Llama artifact.
|
||||||
|
- The current codebase still fails closed for `qwen3` / `qwen3-moe` in
|
||||||
|
`packages/node/meshnet_node/boundary_adapter.py`, which is correct for the
|
||||||
|
current state but means no Qwen3 family recipe is certified yet.
|
||||||
|
|
||||||
|
## Verified current state
|
||||||
|
|
||||||
|
- Dense-Llama boundary semantics, Hot KV isolation, batching, and failure
|
||||||
|
semantics are already implemented and covered by prior stories.
|
||||||
|
- Qwen3 strings are present in tracker/model metadata, but they are not yet
|
||||||
|
backed by a certified architecture adapter or real-model acceptance evidence.
|
||||||
|
- No `evidence/DGR-015/README.md` exists yet because the acceptance criteria
|
||||||
|
could not be completed.
|
||||||
|
|
||||||
|
## Commands run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,260p' .claude/memory/MEMORY.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/RALPH-CONTEXT.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/issues/15-add-and-certify-a-qwen3-qwen3-moe-adapter.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/architecture.md
|
||||||
|
sed -n '1,260p' CONTEXT.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/prd.json
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-014/BLOCKED.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-013/README.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-012/README.md
|
||||||
|
sed -n '1,260p' packages/node/meshnet_node/boundary_adapter.py
|
||||||
|
sed -n '1,260p' packages/node/meshnet_node/model_catalog.py
|
||||||
|
sed -n '1,220p' packages/node/meshnet_node/model_metadata.json
|
||||||
|
sed -n '1,260p' packages/tracker/meshnet_tracker/capability.py
|
||||||
|
sed -n '1,260p' packages/tracker/meshnet_tracker/server.py
|
||||||
|
rg -n "qwen3|qwen3-moe|Qwen3|MoE|router|top-k|shared expert|shared_expert|expert" packages/node/meshnet_node packages/tracker/meshnet_tracker tests -g '!**/__pycache__/**'
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- No certified dense-Llama artifact is mounted, so DGR-014 cannot complete and
|
||||||
|
DGR-015 remains blocked behind it.
|
||||||
|
- No real consumer-hardware Qwen3 acceptance run was possible in this workspace.
|
||||||
|
- No code was changed in this iteration.
|
||||||
|
|
||||||
|
## Compatibility notes
|
||||||
|
|
||||||
|
- The current boundary adapter intentionally fails closed for uncertified
|
||||||
|
architectures. That is the correct behavior until a dedicated Qwen3 adapter is
|
||||||
|
implemented and certified.
|
||||||
|
- Existing dense-Llama coverage and Hot KV semantics remain the source of truth
|
||||||
|
for the shared protocol and cache behavior.
|
||||||
|
|
||||||
|
## Dependent-story handoff
|
||||||
|
|
||||||
|
- Finish `DGR-010`, `DGR-011`, and `DGR-014` first with a certified dense-Llama
|
||||||
|
artifact on mounted storage.
|
||||||
|
- Once the release gate passes, implement the Qwen3 family adapter as a separate
|
||||||
|
certified architecture rather than by extending dense-Llama with unchecked name
|
||||||
|
substitutions.
|
||||||
|
- Record the real-model Qwen3 parity, admission, memory, and communication
|
||||||
|
evidence in `evidence/DGR-015/README.md`, then update the issue status only
|
||||||
|
after the gate passes.
|
||||||
145
.scratch/distributed-gguf-runtime/evidence/DGR-016/README.md
Normal file
145
.scratch/distributed-gguf-runtime/evidence/DGR-016/README.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# DGR-016 — Upstream llama.cpp collaboration package
|
||||||
|
|
||||||
|
Status: partial, blocked by DGR-010
|
||||||
|
Date: 2026-07-16
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Assembled the upstream-facing collaboration package for llama.cpp without
|
||||||
|
pulling Meshnet routing or control-plane logic into the upstream ask.
|
||||||
|
|
||||||
|
Durable outputs created for this story:
|
||||||
|
|
||||||
|
- `api-note.md` with the generic hook split and patch-per-concern proposal
|
||||||
|
- `outreach.md` with a maintainer-facing draft for Georgi/llama.cpp
|
||||||
|
|
||||||
|
The package is grounded in the existing research artifacts and the already
|
||||||
|
implemented deterministic tests for:
|
||||||
|
|
||||||
|
- range-aware GGUF ownership and introspection
|
||||||
|
- architecture boundary input/output
|
||||||
|
- layer-filtered KV/session ownership
|
||||||
|
- reproducible pinned worker build wiring
|
||||||
|
|
||||||
|
The story itself remains blocked because DGR-010 is still marked `passes: false`
|
||||||
|
and only has a blocked handoff, not a completed real-model acceptance README.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `.scratch/distributed-gguf-runtime/evidence/DGR-016/README.md`
|
||||||
|
- `.scratch/distributed-gguf-runtime/evidence/DGR-016/api-note.md`
|
||||||
|
- `.scratch/distributed-gguf-runtime/evidence/DGR-016/outreach.md`
|
||||||
|
|
||||||
|
## Commands run and real results
|
||||||
|
|
||||||
|
### Dependency and context review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/RALPH-CONTEXT.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/issues/16-produce-the-upstream-llama-cpp-collaboration-package.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-010/BLOCKED.md
|
||||||
|
sed -n '1,260p' docs/adr/0024-distributed-gguf-runtime.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/architecture.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/decision-framework.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/implementation-strategy.md
|
||||||
|
sed -n '1,260p' CONTEXT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- confirmed the runtime target is a small pinned llama.cpp worker with Meshnet
|
||||||
|
kept outside upstream
|
||||||
|
- confirmed DGR-010 is still blocked because there is no certified dense-Llama
|
||||||
|
artifact on mounted storage
|
||||||
|
|
||||||
|
### Package-relevant targeted pytest
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q tests/test_llama_worker_build.py tests/test_gguf_backend.py tests/test_gguf_ownership.py tests/test_boundary_adapter.py tests/test_hot_kv_state.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- `50 passed in 0.90s`
|
||||||
|
|
||||||
|
### Broader focused pytest slice
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -q tests/test_llama_worker_build.py tests/test_native_shard_protocol.py tests/test_gguf_backend.py tests/test_boundary_adapter.py tests/test_gguf_ownership.py tests/test_hot_kv_state.py tests/test_kv_cache_distributed.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- `58 passed, 1 skipped, 9 failed, 12 errors in 1.27s`
|
||||||
|
- failures were pre-existing environment issues, not this documentation-only
|
||||||
|
package:
|
||||||
|
- `tests/test_native_shard_protocol.py` imported generated protobuf code built
|
||||||
|
against gencode 7.35.0 while the active runtime is 6.33.6
|
||||||
|
- `tests/test_kv_cache_distributed.py` hit sandbox socket `PermissionError`
|
||||||
|
when trying to bind localhost servers
|
||||||
|
|
||||||
|
### Research evidence review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,260p' docs/research/distributed-gguf-landscape.md
|
||||||
|
sed -n '1,260p' docs/research/distributed-gguf-github-followup.md
|
||||||
|
sed -n '1,220p' .scratch/distributed-gguf-runtime/evidence/DGR-004/README.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-006/README.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-007/README.md
|
||||||
|
sed -n '1,260p' .scratch/distributed-gguf-runtime/evidence/DGR-009/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- confirmed Nakshatra and prima.cpp are the right source/test donors for the
|
||||||
|
upstream ask
|
||||||
|
- confirmed the generic API surface is range loading, boundary I/O, and KV
|
||||||
|
ownership, not Meshnet policy
|
||||||
|
|
||||||
|
### Package assembly
|
||||||
|
|
||||||
|
No code generation, downloads, or model execution were required for this story.
|
||||||
|
The package is documentation-only and deterministic.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m compileall -q packages tests
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- both commands exited 0
|
||||||
|
|
||||||
|
## Correctness / performance / hardware classification
|
||||||
|
|
||||||
|
- Correctness evidence: research-only, no live model execution
|
||||||
|
- Performance evidence: none in this story
|
||||||
|
- Hardware evidence: none in this story
|
||||||
|
|
||||||
|
## Known limitations and deferred work
|
||||||
|
|
||||||
|
- DGR-010 remains blocked, so this package cannot be treated as the final
|
||||||
|
release-ready upstream handoff.
|
||||||
|
- The outreach draft is human-ready but not sent.
|
||||||
|
- The doc package does not change llama.cpp source code; it only prepares the
|
||||||
|
upstream ask and test mapping.
|
||||||
|
|
||||||
|
## Compatibility / migration notes
|
||||||
|
|
||||||
|
- Exact upstream pin for the eventual patch series: `b3c9d1b846cc80a6360adb6aeaa4fcd8c4c8dcac`
|
||||||
|
- The proposed patch split is:
|
||||||
|
1. range-aware loading and ownership introspection
|
||||||
|
2. boundary input/output and named tensor bundles
|
||||||
|
3. layer-filtered KV and local sequence ownership
|
||||||
|
- Meshnet routing, billing, relay transport, and volunteer-network policy stay
|
||||||
|
outside llama.cpp.
|
||||||
|
- The deterministic examples already exist in the tree and can be trimmed into
|
||||||
|
upstream-facing MREs when the human maintainer sends the package.
|
||||||
|
|
||||||
|
## Dependent-story handoff
|
||||||
|
|
||||||
|
- DGR-010 must clear before any real-model validation can be cited as the final
|
||||||
|
end-to-end proof for this upstream package.
|
||||||
|
- Once DGR-010 has a completed evidence README, the package can be refreshed
|
||||||
|
with the real-model context and sent to the llama.cpp maintainers as a
|
||||||
|
smaller review bundle.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# DGR-016 API note: narrow llama.cpp hooks, no Meshnet policy
|
||||||
|
|
||||||
|
This note is the upstream-facing shape for the collaboration package.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Keep the llama.cpp ask small:
|
||||||
|
|
||||||
|
- expose generic model-layer hooks that are useful to any local or remote
|
||||||
|
layer-worker setup;
|
||||||
|
- keep Meshnet routing, session ownership, billing, and relay transport out of
|
||||||
|
llama.cpp;
|
||||||
|
- preserve one patch per concern so the series rebases cleanly on the pinned
|
||||||
|
upstream commit.
|
||||||
|
|
||||||
|
## Concern 1: range-aware loading and authoritative tensor ownership
|
||||||
|
|
||||||
|
Requested surface:
|
||||||
|
|
||||||
|
- accept a contiguous `[start_layer, end_layer)` range;
|
||||||
|
- expose whether the worker owns embeddings, final norm, and final head;
|
||||||
|
- make the loaded range authoritative from the model state, not from CLI
|
||||||
|
claims;
|
||||||
|
- allow unowned tensors to be absent rather than fabricated.
|
||||||
|
|
||||||
|
Why this is upstreamable:
|
||||||
|
|
||||||
|
- it is generic loader and introspection plumbing;
|
||||||
|
- it helps any local partitioned inference mode;
|
||||||
|
- it does not require any Meshnet identity, route, or transport type.
|
||||||
|
|
||||||
|
Minimal examples/tests:
|
||||||
|
|
||||||
|
- `tests/test_gguf_ownership.py`
|
||||||
|
- `tests/test_llama_worker_build.py`
|
||||||
|
|
||||||
|
## Concern 2: architecture boundary input/output
|
||||||
|
|
||||||
|
Requested surface:
|
||||||
|
|
||||||
|
- accept a versioned boundary bundle carrying one or more named tensors;
|
||||||
|
- support an unnormalized residual stream as the intermediate handoff;
|
||||||
|
- keep final norm, LM head, and sampling on the tail shard only;
|
||||||
|
- keep the bundle format explicit about name, shape, dtype, byte order, and
|
||||||
|
fragments.
|
||||||
|
|
||||||
|
Why this is upstreamable:
|
||||||
|
|
||||||
|
- it matches both dense Llama and other certified adapter families;
|
||||||
|
- it does not assume Meshnet or any specific wire protocol;
|
||||||
|
- it gives a stable ABI for a layer-worker boundary.
|
||||||
|
|
||||||
|
Minimal examples/tests:
|
||||||
|
|
||||||
|
- `tests/test_boundary_adapter.py`
|
||||||
|
- `tests/test_native_shard_protocol.py`
|
||||||
|
|
||||||
|
## Concern 3: layer-filtered KV and session mapping
|
||||||
|
|
||||||
|
Requested surface:
|
||||||
|
|
||||||
|
- let the worker own KV only for its layer range;
|
||||||
|
- map a stable session/context identifier to the local sequence;
|
||||||
|
- allow cache miss, stale epoch, truncate, release, and eviction semantics;
|
||||||
|
- reject incompatible cache recipes rather than trying to heal them silently.
|
||||||
|
|
||||||
|
Why this is upstreamable:
|
||||||
|
|
||||||
|
- it is a local sequence/KV API, not a network scheduler;
|
||||||
|
- it is useful to any supervisor that needs one process per layer range;
|
||||||
|
- it keeps session semantics outside llama.cpp while still making the worker
|
||||||
|
stateful in a controlled way.
|
||||||
|
|
||||||
|
Minimal examples/tests:
|
||||||
|
|
||||||
|
- `tests/test_hot_kv_state.py`
|
||||||
|
- `tests/test_kv_cache_distributed.py`
|
||||||
|
|
||||||
|
## Suggested patch split
|
||||||
|
|
||||||
|
Keep the series narrow and independently reviewable against the exact pinned
|
||||||
|
commit `b3c9d1b846cc80a6360adb6aeaa4fcd8c4c8dcac`:
|
||||||
|
|
||||||
|
1. `range-aware-loading` and ownership introspection.
|
||||||
|
2. `boundary-input-output` and named tensor bundle handoff.
|
||||||
|
3. `layer-filtered-kv` and sequence ownership.
|
||||||
|
|
||||||
|
The current Meshnet worker scaffold remains a project-owned wrapper and is not
|
||||||
|
part of the upstream ask.
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# DGR-016 outreach draft
|
||||||
|
|
||||||
|
Subject: Narrow llama.cpp hooks for range loading, boundary I/O, and local KV ownership
|
||||||
|
|
||||||
|
Hi Georgi and llama.cpp maintainers,
|
||||||
|
|
||||||
|
We have been building a distributed GGUF route on top of a Meshnet control
|
||||||
|
plane, and the narrow upstreamable seam is now clear enough to summarize.
|
||||||
|
|
||||||
|
We are not asking llama.cpp to own Meshnet routing, billing, relay transport,
|
||||||
|
or any volunteer-network policy. The upstream ask is limited to generic local
|
||||||
|
hooks that make partitioned inference easier to implement and easier to review:
|
||||||
|
|
||||||
|
1. Range-aware loading and ownership introspection for contiguous layer ranges.
|
||||||
|
2. Architecture-defined boundary input/output using an explicit named-tensor
|
||||||
|
bundle.
|
||||||
|
3. Layer-filtered KV ownership and stable local sequence mapping.
|
||||||
|
|
||||||
|
Why we think this is generally useful:
|
||||||
|
|
||||||
|
- Nakshatra already demonstrates the value of a narrow layer-worker seam and
|
||||||
|
partial GGUF loading.
|
||||||
|
- prima.cpp shows the same idea from a different angle with selective loading,
|
||||||
|
local KV, and boundary residual transport.
|
||||||
|
- Both projects suggest the same conclusion: the missing API is not Meshnet
|
||||||
|
specific, it is a local runtime seam that any layer-partitioned supervisor can
|
||||||
|
use.
|
||||||
|
|
||||||
|
The package we would upstream is intentionally split into one concern per patch
|
||||||
|
so review stays small:
|
||||||
|
|
||||||
|
- range-aware loading and tensor ownership;
|
||||||
|
- boundary I/O for intermediate residual state;
|
||||||
|
- layer-filtered KV and sequence ownership.
|
||||||
|
|
||||||
|
If useful, we can send the concrete MRE/test mapping next. We already have
|
||||||
|
deterministic examples covering the loader, boundary contract, and KV/session
|
||||||
|
semantics in the Meshnet tree, and we can trim them into upstream-focused test
|
||||||
|
cases.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
Meshnet maintainers
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 13 — Harden failure, cancellation, and restart semantics
|
# 13 — Harden failure, cancellation, and restart semantics
|
||||||
|
|
||||||
Status: ready-for-agent
|
Status: done
|
||||||
|
|
||||||
## Mandatory fresh-session context
|
## Mandatory fresh-session context
|
||||||
|
|
||||||
|
|||||||
1024
packages/node/meshnet_node/batch_scheduler.py
Normal file
1024
packages/node/meshnet_node/batch_scheduler.py
Normal file
File diff suppressed because it is too large
Load Diff
893
packages/node/meshnet_node/failure_semantics.py
Normal file
893
packages/node/meshnet_node/failure_semantics.py
Normal file
@@ -0,0 +1,893 @@
|
|||||||
|
"""Bounded failure, cancellation, and restart semantics for Shard streams (DGR-013).
|
||||||
|
|
||||||
|
Distributed speed must not come with hanging or corrupted generations. This module
|
||||||
|
hardens the per-Route-Session decode stream that runs over the DGR-007 Hot KV State
|
||||||
|
manager (isolated ``(session, epoch)`` KV) and the DGR-012 continuous-batch
|
||||||
|
scheduler. It is deliberately backend-agnostic and pure-Python: it drives the same
|
||||||
|
``KvBoundaryAdapter`` the default deterministic gate uses, so the whole matrix stays
|
||||||
|
download-free, GPU-free, and API-credit-free while exercising the *real* KV
|
||||||
|
isolation path (the pinned llama.cpp worker, DGR-008, implements the identical
|
||||||
|
adapter contract).
|
||||||
|
|
||||||
|
The guarantees, mapped to the story's acceptance criteria:
|
||||||
|
|
||||||
|
* **Deadlines and heartbeat/health loss terminate blocked stream operations.**
|
||||||
|
:class:`DeadlineGuard` bounds every step against an absolute deadline and a
|
||||||
|
heartbeat-timeout; when either is breached it raises :class:`StreamTerminated`
|
||||||
|
so a blocked stream never hangs.
|
||||||
|
* **Cancellation propagates across every Shard and releases local KV and queued
|
||||||
|
buffers.** :class:`ShardCancellationGroup` fans a single cancel across every
|
||||||
|
node-local KV manager serving a Route Session and releases queued activation
|
||||||
|
buffers; the DGR-012 scheduler's :meth:`~meshnet_node.batch_scheduler.
|
||||||
|
ContinuousBatchScheduler.cancel` drops queued/active work on this node.
|
||||||
|
* **Duplicate steps are idempotent; uncertain mutations are never replayed
|
||||||
|
silently.** :class:`IdempotencyLedger` records each committed
|
||||||
|
``(session, epoch, step)`` and returns the recorded token for a duplicate
|
||||||
|
delivery instead of re-running it. A step whose outcome is *uncertain* (the
|
||||||
|
worker died mid-mutation) is marked uncertain and can never be silently
|
||||||
|
replayed — a replay attempt raises :class:`UncertainMutationError`, forcing an
|
||||||
|
explicit verify-or-restart.
|
||||||
|
* **Alpha failover restarts from token zero on a newly compatible route rather
|
||||||
|
than importing unverified KV.** :class:`RestartController` opens a *new* route
|
||||||
|
epoch, releases every shard's prior-epoch KV, and the restart re-prefills the
|
||||||
|
whole prompt from token zero. The old epoch becomes stale (rejected by the KV
|
||||||
|
manager); unverified KV is never migrated (RALPH runtime decision #14).
|
||||||
|
* **Billing/work records distinguish completed, cancelled, failed, and unverified
|
||||||
|
work.** :class:`WorkLedger` records a typed :class:`WorkRecord` per attempt;
|
||||||
|
only :attr:`WorkStatus.COMPLETED` records are billable, so cancelled, failed,
|
||||||
|
and uncertain (unverified) work is accounted but never charged.
|
||||||
|
|
||||||
|
:class:`HardenedSessionRunner` composes these into one drivable stream: it runs a
|
||||||
|
single session's prefill+decode through the adapter under a deadline/heartbeat
|
||||||
|
guard and a cancellation token, records the typed work outcome, and — via
|
||||||
|
:meth:`HardenedSessionRunner.run_with_failover` — restarts a transient failure
|
||||||
|
from token zero on a fresh epoch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Mapping, Sequence
|
||||||
|
|
||||||
|
from meshnet_node.batch_scheduler import DoneReason, GenerationRequest
|
||||||
|
from meshnet_node.boundary_adapter import BoundaryContractError, TailOutput
|
||||||
|
from meshnet_node.hot_kv_state import (
|
||||||
|
CacheMiss,
|
||||||
|
HotKvStateManager,
|
||||||
|
IncompatibleCacheRecipeError,
|
||||||
|
KvBoundaryAdapter,
|
||||||
|
KvCacheMissError,
|
||||||
|
StaleRouteEpochError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FailureSemanticsError(RuntimeError):
|
||||||
|
"""Base class for failure/cancellation/restart errors."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Typed outcomes: failure kinds and billing/work statuses.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class FailureKind(str, Enum):
|
||||||
|
"""Why a stream step failed. Stable strings for the protocol's structured status."""
|
||||||
|
|
||||||
|
# Bounded termination of a blocked op.
|
||||||
|
DEADLINE_EXCEEDED = "deadline-exceeded"
|
||||||
|
HEARTBEAT_LOST = "heartbeat-lost"
|
||||||
|
# Transport / worker loss (transient — a restart from token zero may succeed).
|
||||||
|
WORKER_DEATH = "worker-death"
|
||||||
|
STREAM_RESET = "stream-reset"
|
||||||
|
# Protocol violations (deterministic — a restart would fail identically).
|
||||||
|
MALFORMED_BUNDLE = "malformed-bundle"
|
||||||
|
STALE_EPOCH = "stale-epoch"
|
||||||
|
INCOMPATIBLE_RECIPE = "incompatible-recipe"
|
||||||
|
# KV state expected by the caller is gone; re-prefill from token zero.
|
||||||
|
CACHE_MISS = "cache-miss"
|
||||||
|
# Explicit client cancellation.
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
# Failure kinds that a from-token-zero restart on a fresh route may recover from.
|
||||||
|
# A protocol violation or an explicit bound (deadline/cancel) is NOT restartable —
|
||||||
|
# retrying it would hang or fail identically, so we surface it instead.
|
||||||
|
_RESTARTABLE = frozenset(
|
||||||
|
{
|
||||||
|
FailureKind.WORKER_DEATH,
|
||||||
|
FailureKind.STREAM_RESET,
|
||||||
|
FailureKind.CACHE_MISS,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Failure kinds whose mutation outcome is *uncertain* — the KV may or may not have
|
||||||
|
# advanced, so the confirmed work is billed as UNVERIFIED and never replayed
|
||||||
|
# silently. Only an *unexpected* error raised while a step was executing is
|
||||||
|
# uncertain (mapped to WORKER_DEATH). A stream reset, deadline, or cache miss
|
||||||
|
# detected at a step boundary is certain: nothing committed for that step.
|
||||||
|
_UNCERTAIN = frozenset({FailureKind.WORKER_DEATH})
|
||||||
|
|
||||||
|
|
||||||
|
class WorkStatus(str, Enum):
|
||||||
|
"""The billing-relevant outcome class of a unit of work (AC: billing records).
|
||||||
|
|
||||||
|
Only :attr:`COMPLETED` work is billable. Cancelled, failed, and unverified
|
||||||
|
work is recorded distinctly so a client is never charged for a generation that
|
||||||
|
hung, was cancelled, or whose mutations could not be verified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
|
UNVERIFIED = "unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def work_status_for(kind: FailureKind) -> WorkStatus:
|
||||||
|
"""Map a terminal failure kind to its billing/work status."""
|
||||||
|
if kind is FailureKind.CANCELLED:
|
||||||
|
return WorkStatus.CANCELLED
|
||||||
|
if kind in _UNCERTAIN:
|
||||||
|
return WorkStatus.UNVERIFIED
|
||||||
|
return WorkStatus.FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def classify_exception(exc: BaseException) -> FailureKind:
|
||||||
|
"""Classify a raised error into a :class:`FailureKind`.
|
||||||
|
|
||||||
|
Protocol violations map to their specific kind; a :class:`StreamTerminated`
|
||||||
|
carries its own kind; any *unexpected* error is treated as worker death
|
||||||
|
(an uncertain, transient loss), never silently ignored.
|
||||||
|
"""
|
||||||
|
if isinstance(exc, StreamTerminated):
|
||||||
|
return exc.kind
|
||||||
|
if isinstance(exc, OperationCancelled):
|
||||||
|
return FailureKind.CANCELLED
|
||||||
|
if isinstance(exc, StaleRouteEpochError):
|
||||||
|
return FailureKind.STALE_EPOCH
|
||||||
|
if isinstance(exc, IncompatibleCacheRecipeError):
|
||||||
|
return FailureKind.INCOMPATIBLE_RECIPE
|
||||||
|
if isinstance(exc, BoundaryContractError):
|
||||||
|
return FailureKind.MALFORMED_BUNDLE
|
||||||
|
if isinstance(exc, KvCacheMissError):
|
||||||
|
return FailureKind.CACHE_MISS
|
||||||
|
return FailureKind.WORKER_DEATH
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Deadlines and heartbeat/health loss.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class StreamTerminated(FailureSemanticsError):
|
||||||
|
"""A blocked stream op was terminated by a deadline or heartbeat/health loss."""
|
||||||
|
|
||||||
|
def __init__(self, kind: FailureKind, detail: str = "") -> None:
|
||||||
|
self.kind = kind
|
||||||
|
self.detail = detail
|
||||||
|
suffix = f": {detail}" if detail else ""
|
||||||
|
super().__init__(f"stream terminated ({kind.value}){suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
class OperationCancelled(FailureSemanticsError):
|
||||||
|
"""Raised when a step observes its :class:`CancellationToken` is cancelled."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str = "client-cancel") -> None:
|
||||||
|
self.reason = reason
|
||||||
|
super().__init__(f"operation cancelled: {reason}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeadlineGuard:
|
||||||
|
"""Bounds a blocked stream op against an absolute deadline and heartbeat loss.
|
||||||
|
|
||||||
|
``deadline`` is an absolute time on ``clock``'s scale (``None`` disables it).
|
||||||
|
``heartbeat_timeout`` is the maximum tolerated gap since the last observed
|
||||||
|
heartbeat; when the peer stops sending heartbeats (its health is lost) the gap
|
||||||
|
grows past the timeout and :meth:`check` raises rather than blocking forever.
|
||||||
|
Both bounds are checked with an injected ``clock`` so the matrix is
|
||||||
|
deterministic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
deadline: float | None = None
|
||||||
|
heartbeat_timeout: float | None = None
|
||||||
|
clock: Callable[[], float] = time.monotonic
|
||||||
|
_last_heartbeat: float = field(default=0.0, init=False)
|
||||||
|
_started: bool = field(default=False, init=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.heartbeat_timeout is not None and self.heartbeat_timeout <= 0:
|
||||||
|
raise FailureSemanticsError("heartbeat_timeout must be positive")
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._last_heartbeat = self.clock()
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
def heartbeat(self) -> None:
|
||||||
|
"""Record that the peer is alive (resets the heartbeat gap)."""
|
||||||
|
self._last_heartbeat = self.clock()
|
||||||
|
|
||||||
|
def check(self) -> None:
|
||||||
|
"""Raise :class:`StreamTerminated` if the deadline or heartbeat lapsed."""
|
||||||
|
if not self._started:
|
||||||
|
self.start()
|
||||||
|
now = self.clock()
|
||||||
|
if self.deadline is not None and now >= self.deadline:
|
||||||
|
raise StreamTerminated(
|
||||||
|
FailureKind.DEADLINE_EXCEEDED,
|
||||||
|
f"deadline {self.deadline} reached at {now}",
|
||||||
|
)
|
||||||
|
if self.heartbeat_timeout is not None:
|
||||||
|
gap = now - self._last_heartbeat
|
||||||
|
if gap > self.heartbeat_timeout:
|
||||||
|
raise StreamTerminated(
|
||||||
|
FailureKind.HEARTBEAT_LOST,
|
||||||
|
f"no heartbeat for {gap} > {self.heartbeat_timeout}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def remaining(self) -> float | None:
|
||||||
|
if self.deadline is None:
|
||||||
|
return None
|
||||||
|
return self.deadline - self.clock()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Cancellation that propagates across shards and releases KV + queued buffers.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class CancellationToken:
|
||||||
|
"""A thread-safe one-shot cancellation flag shared by a Route Session's steps."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._cancelled = False
|
||||||
|
self._reason = ""
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def cancel(self, reason: str = "client-cancel") -> None:
|
||||||
|
with self._lock:
|
||||||
|
if not self._cancelled:
|
||||||
|
self._cancelled = True
|
||||||
|
self._reason = reason
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cancelled(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return self._cancelled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reason(self) -> str:
|
||||||
|
with self._lock:
|
||||||
|
return self._reason
|
||||||
|
|
||||||
|
def raise_if_cancelled(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._cancelled:
|
||||||
|
raise OperationCancelled(self._reason)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CancellationOutcome:
|
||||||
|
"""What a :meth:`ShardCancellationGroup.cancel` released (for observability)."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
route_epoch: int
|
||||||
|
shards_released: int
|
||||||
|
buffers_released: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"route_epoch": self.route_epoch,
|
||||||
|
"shards_released": self.shards_released,
|
||||||
|
"buffers_released": self.buffers_released,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ShardCancellationGroup:
|
||||||
|
"""Fan one cancellation across every node-local Shard of a Route Session.
|
||||||
|
|
||||||
|
A Route Session spans a chain of Shards, each with its own local Hot KV State
|
||||||
|
manager (KV is never migrated between nodes). Cancelling the session must free
|
||||||
|
*all* of that state: this group releases the ``(session, epoch)`` KV on every
|
||||||
|
registered manager and invokes every registered queued-buffer release callback
|
||||||
|
(the pending activation bundles a node holds for the session). Release is
|
||||||
|
idempotent, so cancelling twice is safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, route_epoch: int) -> None:
|
||||||
|
if not isinstance(session_id, str) or not session_id.strip():
|
||||||
|
raise FailureSemanticsError("session_id must be a non-empty string")
|
||||||
|
self.session_id = session_id
|
||||||
|
self.route_epoch = int(route_epoch)
|
||||||
|
self._managers: list[HotKvStateManager] = []
|
||||||
|
self._buffers: list[Callable[[], None]] = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._cancelled = False
|
||||||
|
|
||||||
|
def add_shard(self, manager: HotKvStateManager) -> "ShardCancellationGroup":
|
||||||
|
with self._lock:
|
||||||
|
self._managers.append(manager)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_queued_buffer(
|
||||||
|
self, release: Callable[[], None]
|
||||||
|
) -> "ShardCancellationGroup":
|
||||||
|
"""Register a queued activation buffer's release callback."""
|
||||||
|
with self._lock:
|
||||||
|
self._buffers.append(release)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cancelled(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return self._cancelled
|
||||||
|
|
||||||
|
def cancel(self) -> CancellationOutcome:
|
||||||
|
"""Release every shard's KV and every queued buffer for this session."""
|
||||||
|
with self._lock:
|
||||||
|
managers = list(self._managers)
|
||||||
|
buffers = list(self._buffers)
|
||||||
|
self._buffers.clear()
|
||||||
|
self._cancelled = True
|
||||||
|
shards_released = 0
|
||||||
|
for manager in managers:
|
||||||
|
if manager.release(self.session_id, self.route_epoch):
|
||||||
|
shards_released += 1
|
||||||
|
buffers_released = 0
|
||||||
|
for release in buffers:
|
||||||
|
release()
|
||||||
|
buffers_released += 1
|
||||||
|
return CancellationOutcome(
|
||||||
|
session_id=self.session_id,
|
||||||
|
route_epoch=self.route_epoch,
|
||||||
|
shards_released=shards_released,
|
||||||
|
buffers_released=buffers_released,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Idempotency: duplicate steps are no-ops; uncertain mutations never replay.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class StepPhase(str, Enum):
|
||||||
|
IN_FLIGHT = "in-flight"
|
||||||
|
COMMITTED = "committed"
|
||||||
|
UNCERTAIN = "uncertain"
|
||||||
|
|
||||||
|
|
||||||
|
class UncertainMutationError(FailureSemanticsError):
|
||||||
|
"""Raised when a caller tries to replay a step whose outcome is uncertain.
|
||||||
|
|
||||||
|
A step is uncertain when its mutation may or may not have been applied (worker
|
||||||
|
death / stream reset mid-append). Replaying it silently could double-apply KV
|
||||||
|
or bill unverified work, so the ledger refuses: the caller must verify against
|
||||||
|
the actual KV length or restart from token zero on a fresh epoch instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StepKey:
|
||||||
|
"""Identity of one idempotent stream step within a route epoch."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
route_epoch: int
|
||||||
|
step_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StepDisposition:
|
||||||
|
"""What :meth:`IdempotencyLedger.begin` decided for a step."""
|
||||||
|
|
||||||
|
fresh: bool
|
||||||
|
token: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duplicate(self) -> bool:
|
||||||
|
return not self.fresh
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyLedger:
|
||||||
|
"""Records committed/uncertain stream steps so duplicates never re-mutate.
|
||||||
|
|
||||||
|
Keyed by ``(session, epoch, step_index)`` — the protocol's idempotency step.
|
||||||
|
|
||||||
|
* :meth:`begin` on a *fresh* key marks it in-flight and returns "execute".
|
||||||
|
* :meth:`begin` on a *committed* key returns the recorded token so a duplicate
|
||||||
|
delivery is a no-op (idempotent replay).
|
||||||
|
* :meth:`begin` on an *in-flight* or *uncertain* key raises
|
||||||
|
:class:`UncertainMutationError` — a concurrent duplicate or a replay of an
|
||||||
|
unverified mutation is never silently applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._phase: dict[StepKey, StepPhase] = {}
|
||||||
|
self._token: dict[StepKey, int] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def begin(self, key: StepKey) -> StepDisposition:
|
||||||
|
with self._lock:
|
||||||
|
phase = self._phase.get(key)
|
||||||
|
if phase is None:
|
||||||
|
self._phase[key] = StepPhase.IN_FLIGHT
|
||||||
|
return StepDisposition(fresh=True)
|
||||||
|
if phase is StepPhase.COMMITTED:
|
||||||
|
return StepDisposition(fresh=False, token=self._token[key])
|
||||||
|
# IN_FLIGHT (concurrent duplicate) or UNCERTAIN (post-crash replay):
|
||||||
|
# both are unverified and must not be silently re-applied.
|
||||||
|
raise UncertainMutationError(
|
||||||
|
f"step {key.step_index} for session {key.session_id[:8]} epoch "
|
||||||
|
f"{key.route_epoch} is {phase.value}; refusing silent replay"
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit(self, key: StepKey, token: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._phase[key] = StepPhase.COMMITTED
|
||||||
|
self._token[key] = int(token)
|
||||||
|
|
||||||
|
def mark_uncertain(self, key: StepKey, detail: str = "") -> None:
|
||||||
|
with self._lock:
|
||||||
|
# A committed step is verified; never downgrade it.
|
||||||
|
if self._phase.get(key) is StepPhase.COMMITTED:
|
||||||
|
return
|
||||||
|
self._phase[key] = StepPhase.UNCERTAIN
|
||||||
|
|
||||||
|
def phase_of(self, key: StepKey) -> StepPhase | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._phase.get(key)
|
||||||
|
|
||||||
|
def committed_token(self, key: StepKey) -> int | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._token.get(key)
|
||||||
|
|
||||||
|
def has_uncertain(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return any(p is StepPhase.UNCERTAIN for p in self._phase.values())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Restart / alpha failover: from token zero on a fresh compatible route.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class RestartController:
|
||||||
|
"""Alpha failover that restarts from token zero, never importing prior KV.
|
||||||
|
|
||||||
|
RALPH runtime decision #14: when the alpha (the head owning embedding + final
|
||||||
|
head) fails, the route retries from token zero; unverified KV is never
|
||||||
|
migrated. :meth:`failover` opens the *next* route epoch and releases every
|
||||||
|
node-local shard's prior-epoch KV, so the restart begins with empty caches. The
|
||||||
|
KV manager then treats the failed epoch as stale (a later reference to it is
|
||||||
|
rejected), which is what keeps a half-computed cache from being reused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, managers: Sequence[HotKvStateManager]) -> None:
|
||||||
|
self._managers = list(managers)
|
||||||
|
|
||||||
|
def failover(self, session_id: str, failed_epoch: int) -> int:
|
||||||
|
"""Advance to a fresh epoch and drop the failed epoch's KV on every shard."""
|
||||||
|
new_epoch = int(failed_epoch) + 1
|
||||||
|
for manager in self._managers:
|
||||||
|
manager.release(session_id, failed_epoch)
|
||||||
|
return new_epoch
|
||||||
|
|
||||||
|
def assert_fresh_start(self, session_id: str, new_epoch: int) -> None:
|
||||||
|
"""Verify no shard carries KV for the new epoch (a true token-zero restart).
|
||||||
|
|
||||||
|
Any residual KV under the new epoch would be unverified imported state;
|
||||||
|
this fails closed so a restart can never silently attend over it.
|
||||||
|
"""
|
||||||
|
for manager in self._managers:
|
||||||
|
result = manager.resolve(session_id, new_epoch)
|
||||||
|
if not isinstance(result, CacheMiss):
|
||||||
|
raise FailureSemanticsError(
|
||||||
|
f"restart epoch {new_epoch} for session {session_id[:8]} is not "
|
||||||
|
"empty; refusing to import unverified KV"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Billing / work records.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkRecord:
|
||||||
|
"""A typed unit of served work, distinguishing what may be billed.
|
||||||
|
|
||||||
|
``tokens`` counts only *committed* generated tokens. Only a
|
||||||
|
:attr:`WorkStatus.COMPLETED` record is billable; cancelled/failed/unverified
|
||||||
|
records carry their confirmed token count for observability but are excluded
|
||||||
|
from billing so uncompleted or unverified work is never charged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
route_epoch: int
|
||||||
|
status: WorkStatus
|
||||||
|
tokens: int
|
||||||
|
failure_kind: FailureKind | None = None
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def billable(self) -> bool:
|
||||||
|
return self.status is WorkStatus.COMPLETED
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"route_epoch": self.route_epoch,
|
||||||
|
"status": self.status.value,
|
||||||
|
"tokens": self.tokens,
|
||||||
|
"failure_kind": self.failure_kind.value if self.failure_kind else None,
|
||||||
|
"detail": self.detail,
|
||||||
|
"billable": self.billable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkLedger:
|
||||||
|
"""Append-only ledger of :class:`WorkRecord`, split by billing status."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._records: list[WorkRecord] = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def record(self, record: WorkRecord) -> WorkRecord:
|
||||||
|
with self._lock:
|
||||||
|
self._records.append(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def records(self) -> list[WorkRecord]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._records)
|
||||||
|
|
||||||
|
def records_for(self, session_id: str) -> list[WorkRecord]:
|
||||||
|
with self._lock:
|
||||||
|
return [r for r in self._records if r.session_id == session_id]
|
||||||
|
|
||||||
|
def billable_records(self) -> list[WorkRecord]:
|
||||||
|
with self._lock:
|
||||||
|
return [r for r in self._records if r.billable]
|
||||||
|
|
||||||
|
def billable_tokens(self) -> int:
|
||||||
|
"""Total tokens that may be charged (completed work only)."""
|
||||||
|
with self._lock:
|
||||||
|
return sum(r.tokens for r in self._records if r.billable)
|
||||||
|
|
||||||
|
def counts_by_status(self) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {s.value: 0 for s in WorkStatus}
|
||||||
|
with self._lock:
|
||||||
|
for record in self._records:
|
||||||
|
counts[record.status.value] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
records = [r.to_dict() for r in self._records]
|
||||||
|
counts: dict[str, int] = {s.value: 0 for s in WorkStatus}
|
||||||
|
for record in records:
|
||||||
|
counts[record["status"]] += 1
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"records": records,
|
||||||
|
"counts_by_status": counts,
|
||||||
|
"billable_tokens": sum(r["tokens"] for r in records if r["billable"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The hardened single-session stream runner.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunOutcome:
|
||||||
|
"""The typed result of one hardened generation attempt."""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
route_epoch: int
|
||||||
|
status: WorkStatus
|
||||||
|
tokens: tuple[int, ...]
|
||||||
|
failure_kind: FailureKind | None
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completed(self) -> bool:
|
||||||
|
return self.status is WorkStatus.COMPLETED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token_count(self) -> int:
|
||||||
|
return len(self.tokens)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def restartable(self) -> bool:
|
||||||
|
return self.failure_kind in _RESTARTABLE
|
||||||
|
|
||||||
|
def work_record(self) -> WorkRecord:
|
||||||
|
return WorkRecord(
|
||||||
|
session_id=self.session_id,
|
||||||
|
route_epoch=self.route_epoch,
|
||||||
|
status=self.status,
|
||||||
|
tokens=len(self.tokens),
|
||||||
|
failure_kind=self.failure_kind,
|
||||||
|
detail=self.detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FailoverResult:
|
||||||
|
"""The result of a run that may have restarted from token zero after a failure."""
|
||||||
|
|
||||||
|
outcome: RunOutcome
|
||||||
|
attempts: tuple[RunOutcome, ...]
|
||||||
|
restarts: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completed(self) -> bool:
|
||||||
|
return self.outcome.completed
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"final_status": self.outcome.status.value,
|
||||||
|
"final_epoch": self.outcome.route_epoch,
|
||||||
|
"restarts": self.restarts,
|
||||||
|
"attempts": [
|
||||||
|
{
|
||||||
|
"route_epoch": a.route_epoch,
|
||||||
|
"status": a.status.value,
|
||||||
|
"failure_kind": a.failure_kind.value if a.failure_kind else None,
|
||||||
|
"tokens": a.token_count,
|
||||||
|
}
|
||||||
|
for a in self.attempts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HardenedSessionRunner:
|
||||||
|
"""Drive one Route Session's decode stream with bounded failure semantics.
|
||||||
|
|
||||||
|
The runner owns a single full-shard :class:`KvBoundaryAdapter` (head **and**
|
||||||
|
tail, so a step samples a token) and threads every DGR-013 guarantee through a
|
||||||
|
step loop:
|
||||||
|
|
||||||
|
* every step is bounded by a :class:`DeadlineGuard` and can observe a
|
||||||
|
:class:`CancellationToken`;
|
||||||
|
* every step is idempotent through an :class:`IdempotencyLedger` (a duplicate
|
||||||
|
returns the recorded token; an uncertain mutation is never replayed);
|
||||||
|
* any failure releases this session's KV (cancellation) and is recorded as a
|
||||||
|
typed :class:`WorkRecord` in the :class:`WorkLedger`;
|
||||||
|
* :meth:`run_with_failover` restarts a transient failure from token zero on a
|
||||||
|
fresh epoch via a :class:`RestartController`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: KvBoundaryAdapter,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], float] | None = None,
|
||||||
|
work_ledger: WorkLedger | None = None,
|
||||||
|
idempotency: IdempotencyLedger | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not (adapter.is_head and adapter.is_tail):
|
||||||
|
raise FailureSemanticsError(
|
||||||
|
"HardenedSessionRunner needs a full (head+tail) shard so decode "
|
||||||
|
"steps sample tokens; got a partial range "
|
||||||
|
f"(head={adapter.is_head} tail={adapter.is_tail})"
|
||||||
|
)
|
||||||
|
self._adapter = adapter
|
||||||
|
self._manager: HotKvStateManager = adapter.manager
|
||||||
|
self._clock = clock or time.monotonic
|
||||||
|
self.work_ledger = work_ledger or WorkLedger()
|
||||||
|
self.idempotency = idempotency or IdempotencyLedger()
|
||||||
|
|
||||||
|
# -- single attempt -------------------------------------------------------
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
request: GenerationRequest,
|
||||||
|
*,
|
||||||
|
deadline: float | None = None,
|
||||||
|
heartbeat_timeout: float | None = None,
|
||||||
|
cancel_token: CancellationToken | None = None,
|
||||||
|
heartbeat: Callable[[int], bool] | None = None,
|
||||||
|
before_step: Callable[[int], None] | None = None,
|
||||||
|
) -> RunOutcome:
|
||||||
|
"""Run one attempt of ``request``; record and return a typed outcome.
|
||||||
|
|
||||||
|
``deadline`` (absolute, on the injected clock) and ``heartbeat_timeout``
|
||||||
|
bound blocked steps. ``cancel_token`` lets a client cancel mid-stream.
|
||||||
|
``heartbeat(step)`` returns ``True`` when a heartbeat was heard before that
|
||||||
|
step (resetting the health timer); ``before_step(step)`` is a fault-
|
||||||
|
injection / clock-advance hook run before each step and may raise
|
||||||
|
:class:`StreamTerminated` (e.g. a stream reset) or
|
||||||
|
:class:`OperationCancelled`.
|
||||||
|
"""
|
||||||
|
sid = request.session_id
|
||||||
|
epoch = request.route_epoch
|
||||||
|
guard = DeadlineGuard(
|
||||||
|
deadline=deadline,
|
||||||
|
heartbeat_timeout=heartbeat_timeout,
|
||||||
|
clock=self._clock,
|
||||||
|
)
|
||||||
|
guard.start()
|
||||||
|
tokens: list[int] = []
|
||||||
|
current_key: StepKey | None = None
|
||||||
|
try:
|
||||||
|
# step 0 is the prefill (emits the first token); steps 1..N are decodes.
|
||||||
|
for step_index in range(request.max_new_tokens):
|
||||||
|
# before_step is the fault-injection / clock-advance hook and may
|
||||||
|
# itself terminate the step (stream reset, cancel); run it first so
|
||||||
|
# a fault it raises takes effect on this step, then re-check the
|
||||||
|
# bounds it may have advanced (deadline / heartbeat / cancel).
|
||||||
|
if before_step is not None:
|
||||||
|
before_step(step_index)
|
||||||
|
if cancel_token is not None:
|
||||||
|
cancel_token.raise_if_cancelled()
|
||||||
|
if heartbeat is not None and heartbeat(step_index):
|
||||||
|
guard.heartbeat()
|
||||||
|
guard.check()
|
||||||
|
|
||||||
|
current_key = StepKey(sid, epoch, step_index)
|
||||||
|
disposition = self.idempotency.begin(current_key)
|
||||||
|
if disposition.duplicate:
|
||||||
|
# Idempotent replay: reuse the recorded token, do not re-mutate.
|
||||||
|
assert disposition.token is not None
|
||||||
|
tokens.append(disposition.token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
token = self._execute_step(request, step_index, tokens)
|
||||||
|
if isinstance(token, CacheMiss):
|
||||||
|
# The expected KV was gone; the append never started, so this is
|
||||||
|
# a certain (not uncertain) miss — restartable from token zero.
|
||||||
|
return self._finish_failure(
|
||||||
|
request,
|
||||||
|
tokens,
|
||||||
|
FailureKind.CACHE_MISS,
|
||||||
|
str(token),
|
||||||
|
cancel_token,
|
||||||
|
)
|
||||||
|
self.idempotency.commit(current_key, token)
|
||||||
|
tokens.append(token)
|
||||||
|
except (StreamTerminated, OperationCancelled) as exc:
|
||||||
|
return self._finish_failure(
|
||||||
|
request, tokens, classify_exception(exc), str(exc), cancel_token
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
BoundaryContractError,
|
||||||
|
StaleRouteEpochError,
|
||||||
|
IncompatibleCacheRecipeError,
|
||||||
|
KvCacheMissError,
|
||||||
|
) as exc:
|
||||||
|
# Deterministic protocol/state errors, all validated before any KV
|
||||||
|
# append committed — certain, not uncertain.
|
||||||
|
return self._finish_failure(
|
||||||
|
request, tokens, classify_exception(exc), str(exc), cancel_token
|
||||||
|
)
|
||||||
|
except UncertainMutationError as exc:
|
||||||
|
# A replay of an unverified step reached the ledger — never silent.
|
||||||
|
return self._finish_failure(
|
||||||
|
request, tokens, FailureKind.WORKER_DEATH, str(exc), cancel_token
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - unexpected == worker death
|
||||||
|
# An unexpected error mid-step may have left the KV half-mutated; mark
|
||||||
|
# the step uncertain so it can never be silently replayed, then fail
|
||||||
|
# closed as unverified work.
|
||||||
|
if current_key is not None:
|
||||||
|
self.idempotency.mark_uncertain(current_key, str(exc))
|
||||||
|
return self._finish_failure(
|
||||||
|
request, tokens, FailureKind.WORKER_DEATH, str(exc), cancel_token
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._finish_completed(request, tokens)
|
||||||
|
|
||||||
|
def _execute_step(
|
||||||
|
self, request: GenerationRequest, step_index: int, tokens: list[int]
|
||||||
|
) -> int | CacheMiss:
|
||||||
|
sid = request.session_id
|
||||||
|
epoch = request.route_epoch
|
||||||
|
if step_index == 0:
|
||||||
|
out = self._adapter.prefill(
|
||||||
|
sid, epoch, token_ids=list(request.prompt_token_ids)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# expected_seq_len defends the KV layer against a desynchronised decode:
|
||||||
|
# prompt positions plus the tokens already committed this run.
|
||||||
|
expected = request.prompt_len + (step_index - 1)
|
||||||
|
out = self._adapter.decode(
|
||||||
|
sid,
|
||||||
|
epoch,
|
||||||
|
token_ids=[tokens[-1]],
|
||||||
|
expected_seq_len=expected,
|
||||||
|
)
|
||||||
|
if isinstance(out, CacheMiss):
|
||||||
|
return out
|
||||||
|
if not isinstance(out, TailOutput):
|
||||||
|
raise FailureSemanticsError(
|
||||||
|
"full-shard step did not yield a sampled token; got "
|
||||||
|
f"{type(out).__name__}"
|
||||||
|
)
|
||||||
|
return int(out.token_id)
|
||||||
|
|
||||||
|
# -- failover across restarts --------------------------------------------
|
||||||
|
|
||||||
|
def run_with_failover(
|
||||||
|
self,
|
||||||
|
request: GenerationRequest,
|
||||||
|
controller: RestartController,
|
||||||
|
*,
|
||||||
|
max_restarts: int = 3,
|
||||||
|
**run_kwargs: Any,
|
||||||
|
) -> FailoverResult:
|
||||||
|
"""Run ``request``, restarting a transient failure from token zero.
|
||||||
|
|
||||||
|
On a restartable failure (worker death, stream reset, cache miss) the
|
||||||
|
controller advances to a fresh epoch and drops the failed epoch's KV; the
|
||||||
|
next attempt re-prefills the whole prompt from token zero. A deterministic
|
||||||
|
failure (deadline, cancel, malformed bundle, stale epoch) is returned as-is
|
||||||
|
— retrying it would hang or fail identically. Per-attempt fault-injection
|
||||||
|
hooks (``before_step`` / ``heartbeat``) are only applied to the *first*
|
||||||
|
attempt so a restart runs clean.
|
||||||
|
"""
|
||||||
|
if max_restarts < 0:
|
||||||
|
raise FailureSemanticsError("max_restarts must be >= 0")
|
||||||
|
epoch = request.route_epoch
|
||||||
|
attempts: list[RunOutcome] = []
|
||||||
|
first_kwargs = run_kwargs
|
||||||
|
for attempt in range(max_restarts + 1):
|
||||||
|
attempt_request = replace(request, route_epoch=epoch)
|
||||||
|
kwargs = first_kwargs if attempt == 0 else {}
|
||||||
|
outcome = self.run(attempt_request, **kwargs)
|
||||||
|
attempts.append(outcome)
|
||||||
|
if outcome.completed or not outcome.restartable or attempt == max_restarts:
|
||||||
|
return FailoverResult(
|
||||||
|
outcome=outcome, attempts=tuple(attempts), restarts=attempt
|
||||||
|
)
|
||||||
|
# Alpha failover: fresh epoch, drop prior-epoch KV on every shard, and
|
||||||
|
# verify the new epoch starts empty (no unverified KV import).
|
||||||
|
epoch = controller.failover(request.session_id, epoch)
|
||||||
|
controller.assert_fresh_start(request.session_id, epoch)
|
||||||
|
# Unreachable: the loop always returns, but keep the type-checker happy.
|
||||||
|
raise FailureSemanticsError("run_with_failover exhausted without returning")
|
||||||
|
|
||||||
|
# -- outcome bookkeeping --------------------------------------------------
|
||||||
|
|
||||||
|
def _finish_completed(
|
||||||
|
self, request: GenerationRequest, tokens: list[int]
|
||||||
|
) -> RunOutcome:
|
||||||
|
outcome = RunOutcome(
|
||||||
|
session_id=request.session_id,
|
||||||
|
route_epoch=request.route_epoch,
|
||||||
|
status=WorkStatus.COMPLETED,
|
||||||
|
tokens=tuple(tokens),
|
||||||
|
failure_kind=None,
|
||||||
|
detail="",
|
||||||
|
)
|
||||||
|
self.work_ledger.record(outcome.work_record())
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
def _finish_failure(
|
||||||
|
self,
|
||||||
|
request: GenerationRequest,
|
||||||
|
tokens: list[int],
|
||||||
|
kind: FailureKind,
|
||||||
|
detail: str,
|
||||||
|
cancel_token: CancellationToken | None,
|
||||||
|
) -> RunOutcome:
|
||||||
|
# Cancellation semantics: release this session's local KV so a failed or
|
||||||
|
# cancelled stream never leaks its cache. release() is idempotent.
|
||||||
|
self._manager.release(request.session_id, request.route_epoch)
|
||||||
|
if cancel_token is not None and kind is not FailureKind.CANCELLED:
|
||||||
|
# Ensure downstream shards sharing the token also stop.
|
||||||
|
cancel_token.cancel(kind.value)
|
||||||
|
outcome = RunOutcome(
|
||||||
|
session_id=request.session_id,
|
||||||
|
route_epoch=request.route_epoch,
|
||||||
|
status=work_status_for(kind),
|
||||||
|
tokens=tuple(tokens),
|
||||||
|
failure_kind=kind,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
self.work_ledger.record(outcome.work_record())
|
||||||
|
return outcome
|
||||||
472
tests/test_batch_scheduler.py
Normal file
472
tests/test_batch_scheduler.py
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
"""Continuous batching and bounded admission (DGR-012).
|
||||||
|
|
||||||
|
These tests drive the node-local continuous-batching scheduler with the *same*
|
||||||
|
pure-numpy KV-cached dense-Llama reference the Hot KV State manager uses
|
||||||
|
(DGR-007), imported from ``test_hot_kv_state``. That keeps the whole gate
|
||||||
|
deterministic, download-free, GPU-free, and API-credit-free while exercising the
|
||||||
|
real KV isolation path (``KvBoundaryAdapter`` + ``HotKvStateManager``) rather than
|
||||||
|
a mock.
|
||||||
|
|
||||||
|
Coverage maps to the story's acceptance criteria:
|
||||||
|
|
||||||
|
* bounded admission against weight/KV/scratch/queue budgets,
|
||||||
|
* compatible decode steps batched with per-session positions/outputs preserved,
|
||||||
|
* prefill never starving in-flight decode (explicit decode-first policy),
|
||||||
|
* backpressure when the bounded queue is full,
|
||||||
|
* capability telemetry reporting every required signal,
|
||||||
|
* a deterministic 1/2/4/8 concurrency sweep showing saturation and no
|
||||||
|
cross-session corruption.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meshnet_node.hot_kv_state import (
|
||||||
|
HotKvStateConfig,
|
||||||
|
HotKvStateManager,
|
||||||
|
KvBoundaryAdapter,
|
||||||
|
kv_recipe_for,
|
||||||
|
)
|
||||||
|
from meshnet_node.batch_scheduler import (
|
||||||
|
AdmissionReason,
|
||||||
|
ContinuousBatchScheduler,
|
||||||
|
GenerationRequest,
|
||||||
|
KvBatchEngine,
|
||||||
|
NodeBudget,
|
||||||
|
Phase,
|
||||||
|
run_concurrency_sweep,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reuse the certified numpy dense-Llama reference and shard from the DGR-007 gate.
|
||||||
|
from test_hot_kv_state import _KvDenseLlama, _KvReferenceShard
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpers.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 0.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, delta: float) -> None:
|
||||||
|
self.now += delta
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine(
|
||||||
|
model: _KvDenseLlama | None = None,
|
||||||
|
*,
|
||||||
|
config: HotKvStateConfig | None = None,
|
||||||
|
) -> KvBatchEngine:
|
||||||
|
"""A full-shard KV batch engine over the deterministic numpy dense-Llama."""
|
||||||
|
model = model or _KvDenseLlama()
|
||||||
|
shard = _KvReferenceShard(model, 0, model.n_layers - 1)
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(shard), config=config)
|
||||||
|
adapter = KvBoundaryAdapter(shard, manager)
|
||||||
|
return KvBatchEngine(adapter)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_tokens(model: _KvDenseLlama, prompt, n_new: int) -> list[int]:
|
||||||
|
return model.stateless_greedy(list(prompt), n_new)
|
||||||
|
|
||||||
|
|
||||||
|
def _generation(session_id: str, prompt, n_new: int, epoch: int = 0) -> GenerationRequest:
|
||||||
|
return GenerationRequest(
|
||||||
|
session_id=session_id,
|
||||||
|
route_epoch=epoch,
|
||||||
|
prompt_token_ids=tuple(prompt),
|
||||||
|
max_new_tokens=n_new,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Bounded admission (weight / KV / scratch / queue budgets).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_admission_respects_active_scratch_and_queue_budgets():
|
||||||
|
"Admission fills active slots, queues the overflow, then rejects a full queue.\n\nTags: node, scheduler, admission"
|
||||||
|
engine = _make_engine()
|
||||||
|
budget = NodeBudget(
|
||||||
|
max_active_sessions=2,
|
||||||
|
scratch_bytes_per_session=1,
|
||||||
|
scratch_budget_bytes=2, # scratch also caps at 2 concurrent
|
||||||
|
max_queue_depth=1,
|
||||||
|
max_batch_size=2,
|
||||||
|
)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget)
|
||||||
|
|
||||||
|
a = scheduler.submit(_generation("a", [1, 2, 3], 4))
|
||||||
|
b = scheduler.submit(_generation("b", [4, 5, 6], 4))
|
||||||
|
assert a.reason is AdmissionReason.ADMITTED
|
||||||
|
assert b.reason is AdmissionReason.ADMITTED
|
||||||
|
|
||||||
|
# Two active slots full -> the next goes to the bounded queue.
|
||||||
|
c = scheduler.submit(_generation("c", [7, 8, 9], 4))
|
||||||
|
assert c.reason is AdmissionReason.QUEUED
|
||||||
|
|
||||||
|
# Queue depth 1 is now full -> backpressure rejection.
|
||||||
|
d = scheduler.submit(_generation("d", [1, 1, 1], 4))
|
||||||
|
assert d.reason is AdmissionReason.REJECTED_QUEUE_FULL
|
||||||
|
assert d.rejected
|
||||||
|
|
||||||
|
telem = scheduler.telemetry()
|
||||||
|
assert telem.active_sessions == 2
|
||||||
|
assert telem.queue_depth == 1
|
||||||
|
assert telem.rejected_admissions_total == 1
|
||||||
|
assert telem.rejected_by_reason[AdmissionReason.REJECTED_QUEUE_FULL.value] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_admission_rejects_a_session_that_cannot_fit_the_kv_budget():
|
||||||
|
"A generation whose whole KV cannot fit the node budget is rejected up front.\n\nTags: node, scheduler, admission"
|
||||||
|
engine = _make_engine()
|
||||||
|
per_token = engine._manager.recipe.bytes_per_token()
|
||||||
|
# Budget holds only 3 positions; a prompt(4)+7 new = 10 final positions cannot fit.
|
||||||
|
budget = NodeBudget(kv_budget_bytes=per_token * 3)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget)
|
||||||
|
decision = scheduler.submit(_generation("big", [1, 2, 3, 4], 7))
|
||||||
|
assert decision.reason is AdmissionReason.REJECTED_KV_BUDGET
|
||||||
|
assert scheduler.telemetry().rejected_admissions_total == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_admission_rejects_when_per_session_scratch_exceeds_budget():
|
||||||
|
"A per-session scratch larger than the whole scratch envelope is rejected.\n\nTags: node, scheduler, admission"
|
||||||
|
engine = _make_engine()
|
||||||
|
budget = NodeBudget(scratch_bytes_per_session=1024, scratch_budget_bytes=512)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget)
|
||||||
|
decision = scheduler.submit(_generation("s", [1, 2], 2))
|
||||||
|
assert decision.reason is AdmissionReason.REJECTED_SCRATCH_BUDGET
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_submission_is_rejected():
|
||||||
|
"Submitting a session id that is already scheduled is rejected as a duplicate.\n\nTags: node, scheduler, admission"
|
||||||
|
engine = _make_engine()
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, NodeBudget(max_active_sessions=4))
|
||||||
|
assert scheduler.submit(_generation("dup", [1, 2], 3)).reason is AdmissionReason.ADMITTED
|
||||||
|
assert scheduler.submit(_generation("dup", [3, 4], 3)).reason is AdmissionReason.REJECTED_DUPLICATE
|
||||||
|
|
||||||
|
|
||||||
|
def test_weight_budget_is_reported_in_telemetry():
|
||||||
|
"The resident weight footprint is surfaced as a capability signal.\n\nTags: node, scheduler, telemetry"
|
||||||
|
engine = _make_engine()
|
||||||
|
budget = NodeBudget(weight_bytes=123_456)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget)
|
||||||
|
assert scheduler.telemetry().weight_bytes == 123_456
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Continuous batching preserves per-session positions and outputs.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_batched_decode_preserves_per_session_positions_and_outputs():
|
||||||
|
"Four sessions batched together each reproduce their own stateless tokens.\n\nTags: node, scheduler, batching"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
engine = _make_engine(model)
|
||||||
|
budget = NodeBudget(max_active_sessions=4, max_batch_size=4, max_queue_depth=4)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget)
|
||||||
|
|
||||||
|
prompts = {
|
||||||
|
"alpha": [1, 2, 3, 4],
|
||||||
|
"bravo": [40, 39, 2, 15],
|
||||||
|
"charlie": [7, 7, 7, 7],
|
||||||
|
"delta": [31, 5, 18, 22],
|
||||||
|
}
|
||||||
|
n_new = 10
|
||||||
|
references = {sid: _reference_tokens(model, p, n_new) for sid, p in prompts.items()}
|
||||||
|
# The four references must diverge, else "no cross-talk" would be vacuous.
|
||||||
|
assert len({tuple(v) for v in references.values()}) == 4
|
||||||
|
|
||||||
|
for sid, prompt in prompts.items():
|
||||||
|
assert scheduler.submit(_generation(sid, prompt, n_new)).running
|
||||||
|
|
||||||
|
outputs = scheduler.run_to_completion()
|
||||||
|
for sid in prompts:
|
||||||
|
assert outputs[sid] == references[sid], sid
|
||||||
|
|
||||||
|
telem = scheduler.telemetry()
|
||||||
|
# A genuine batch formed: at least one decode tick carried all four sessions.
|
||||||
|
assert telem.batch_occupancy_max == 4
|
||||||
|
assert telem.completed_sessions == 4
|
||||||
|
assert telem.active_sessions == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_are_isolated_across_different_prompt_lengths():
|
||||||
|
"Sessions with different prompt lengths keep independent positions when batched.\n\nTags: node, scheduler, batching"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
engine = _make_engine(model)
|
||||||
|
scheduler = ContinuousBatchScheduler(
|
||||||
|
engine, NodeBudget(max_active_sessions=3, max_batch_size=3, max_queue_depth=3)
|
||||||
|
)
|
||||||
|
jobs = {
|
||||||
|
"short": ([5], 6),
|
||||||
|
"medium": ([2, 9, 14], 6),
|
||||||
|
"long": ([1, 2, 3, 4, 5, 6, 7], 6),
|
||||||
|
}
|
||||||
|
refs = {sid: _reference_tokens(model, p, n) for sid, (p, n) in jobs.items()}
|
||||||
|
for sid, (prompt, n) in jobs.items():
|
||||||
|
scheduler.submit(_generation(sid, prompt, n))
|
||||||
|
outputs = scheduler.run_to_completion()
|
||||||
|
for sid in jobs:
|
||||||
|
assert outputs[sid] == refs[sid], sid
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Prefill does not starve decode.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefill_does_not_starve_in_flight_decode():
|
||||||
|
"A burst of new prefills never stalls an already-decoding session.\n\nTags: node, scheduler, fairness"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
engine = _make_engine(model)
|
||||||
|
# One prefill per tick (budget == a single prompt) so prefill is throttled and
|
||||||
|
# we can observe that decode still advances every tick.
|
||||||
|
budget = NodeBudget(
|
||||||
|
max_active_sessions=8,
|
||||||
|
max_batch_size=8,
|
||||||
|
max_queue_depth=8,
|
||||||
|
scratch_bytes_per_session=1,
|
||||||
|
scratch_budget_bytes=8,
|
||||||
|
max_prefill_tokens_per_tick=4,
|
||||||
|
)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget)
|
||||||
|
|
||||||
|
# Session A starts and prefills on tick 1.
|
||||||
|
scheduler.submit(_generation("A", [3, 14, 1, 5], 12))
|
||||||
|
scheduler.run_tick()
|
||||||
|
a_state = scheduler.session_result("A")
|
||||||
|
assert a_state.phase is Phase.DECODING
|
||||||
|
a_len = len(a_state.generated)
|
||||||
|
assert a_len == 1
|
||||||
|
|
||||||
|
# Burst of new work arrives while A is decoding.
|
||||||
|
for sid in ("B", "C", "D", "E"):
|
||||||
|
scheduler.submit(_generation(sid, [2, 27, 18, 4], 12))
|
||||||
|
|
||||||
|
# Over the next few ticks A must decode on *every* tick (never starved),
|
||||||
|
# while at most one new session prefills per tick (prefill is bounded).
|
||||||
|
prefill_counts = []
|
||||||
|
for _ in range(4):
|
||||||
|
report = scheduler.run_tick()
|
||||||
|
new_a_len = len(scheduler.session_result("A").generated)
|
||||||
|
assert new_a_len == a_len + 1, "decode of A stalled while prefills were pending"
|
||||||
|
a_len = new_a_len
|
||||||
|
assert "A" in report.decoded
|
||||||
|
prefill_counts.append(len(report.prefilled))
|
||||||
|
|
||||||
|
assert max(prefill_counts) <= 1, "prefill was not bounded per tick"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_first_policy_is_explicit_in_a_single_tick():
|
||||||
|
"In one tick decode of active sessions precedes prefill of new ones.\n\nTags: node, scheduler, fairness"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
engine = _make_engine(model)
|
||||||
|
scheduler = ContinuousBatchScheduler(
|
||||||
|
engine,
|
||||||
|
NodeBudget(max_active_sessions=4, max_batch_size=4, max_queue_depth=4,
|
||||||
|
scratch_bytes_per_session=1, scratch_budget_bytes=4),
|
||||||
|
)
|
||||||
|
scheduler.submit(_generation("live", [1, 2, 3], 8))
|
||||||
|
scheduler.run_tick() # 'live' prefills, now decoding
|
||||||
|
scheduler.submit(_generation("fresh", [9, 8, 7], 8))
|
||||||
|
report = scheduler.run_tick()
|
||||||
|
assert "live" in report.decoded
|
||||||
|
assert "fresh" in report.prefilled
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Backpressure and bounded memory.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_backpressure_signals_when_queue_full_then_recovers():
|
||||||
|
"A full queue rejects new work; a completed session frees a slot for the queue.\n\nTags: node, scheduler, backpressure"
|
||||||
|
engine = _make_engine()
|
||||||
|
budget = NodeBudget(
|
||||||
|
max_active_sessions=1,
|
||||||
|
max_batch_size=1,
|
||||||
|
max_queue_depth=1,
|
||||||
|
scratch_bytes_per_session=1,
|
||||||
|
scratch_budget_bytes=1,
|
||||||
|
)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget)
|
||||||
|
assert scheduler.submit(_generation("first", [1, 2], 2)).running
|
||||||
|
assert scheduler.submit(_generation("second", [3, 4], 2)).reason is AdmissionReason.QUEUED
|
||||||
|
# Both a slot and the queue are full now.
|
||||||
|
assert scheduler.submit(_generation("third", [5, 6], 2)).reason is AdmissionReason.REJECTED_QUEUE_FULL
|
||||||
|
|
||||||
|
# Drain 'first'; the queued 'second' must be pulled into the freed slot.
|
||||||
|
scheduler.run_to_completion()
|
||||||
|
outputs = scheduler.outputs()
|
||||||
|
assert set(outputs) == {"first", "second"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_sessions_release_kv_so_growth_is_bounded():
|
||||||
|
"Finished sessions release their KV, so total KV returns to zero.\n\nTags: node, scheduler, backpressure"
|
||||||
|
engine = _make_engine()
|
||||||
|
scheduler = ContinuousBatchScheduler(
|
||||||
|
engine, NodeBudget(max_active_sessions=2, max_batch_size=2, max_queue_depth=8)
|
||||||
|
)
|
||||||
|
for sid in ("a", "b", "c", "d"):
|
||||||
|
scheduler.submit(_generation(sid, [1, 2, 3], 4))
|
||||||
|
scheduler.run_to_completion()
|
||||||
|
telem = scheduler.telemetry()
|
||||||
|
assert telem.kv_total_bytes == 0, "KV not released after completion"
|
||||||
|
assert telem.active_sessions == 0
|
||||||
|
assert telem.completed_sessions == 4
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Telemetry.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_telemetry_reports_every_required_signal():
|
||||||
|
"The capability snapshot reports sessions, queue, batch, KV, rates, rejections.\n\nTags: node, scheduler, telemetry"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
engine = _make_engine(model)
|
||||||
|
clock = _FakeClock()
|
||||||
|
budget = NodeBudget(max_active_sessions=2, max_batch_size=2, max_queue_depth=1)
|
||||||
|
scheduler = ContinuousBatchScheduler(engine, budget, clock=clock)
|
||||||
|
|
||||||
|
scheduler.submit(_generation("x", [1, 2, 3], 4))
|
||||||
|
scheduler.submit(_generation("y", [4, 5, 6], 4))
|
||||||
|
scheduler.submit(_generation("z", [7, 8, 9], 4)) # queued
|
||||||
|
rejected = scheduler.submit(_generation("w", [1, 1, 1], 4)) # queue full
|
||||||
|
assert rejected.rejected
|
||||||
|
|
||||||
|
clock.advance(1.0)
|
||||||
|
scheduler.run_tick() # both prefill
|
||||||
|
clock.advance(1.0)
|
||||||
|
scheduler.run_tick() # both decode as a batch of 2
|
||||||
|
|
||||||
|
clock.advance(2.0)
|
||||||
|
telem = scheduler.telemetry()
|
||||||
|
snap = telem.to_dict()
|
||||||
|
for key in (
|
||||||
|
"active_sessions", "queue_depth", "batch_occupancy_last",
|
||||||
|
"batch_occupancy_avg", "batch_occupancy_max", "weight_bytes",
|
||||||
|
"kv_total_bytes", "kv_budget_bytes", "kv_pressure",
|
||||||
|
"scratch_used_bytes", "scratch_budget_bytes", "scratch_pressure",
|
||||||
|
"prefill_tokens_total", "decode_tokens_total",
|
||||||
|
"prefill_tokens_per_sec", "decode_tokens_per_sec",
|
||||||
|
"rejected_admissions_total", "rejected_by_reason",
|
||||||
|
"completed_sessions", "ticks",
|
||||||
|
):
|
||||||
|
assert key in snap, key
|
||||||
|
|
||||||
|
assert telem.batch_occupancy_max == 2
|
||||||
|
assert telem.prefill_tokens_total == 6 # two prompts of length 3
|
||||||
|
assert telem.decode_tokens_total == 2 # one batched decode step, two sessions
|
||||||
|
assert telem.rejected_admissions_total == 1
|
||||||
|
# Rates are deterministic under the injected clock: 4 seconds elapsed.
|
||||||
|
assert telem.decode_tokens_per_sec == pytest.approx(2 / 4.0)
|
||||||
|
assert telem.prefill_tokens_per_sec == pytest.approx(6 / 4.0)
|
||||||
|
assert 0.0 < telem.kv_pressure <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Concurrency 1/2/4/8 sweep: saturation and no corruption.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrency_sweep_identifies_saturation_without_corruption():
|
||||||
|
"A 1/2/4/8 sweep raises batch occupancy, cuts ticks, and never corrupts output.\n\nTags: node, scheduler, benchmark"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
prompts = {
|
||||||
|
"s0": [1, 2, 3, 4], "s1": [5, 6, 7, 8], "s2": [9, 10, 11, 12],
|
||||||
|
"s3": [13, 14, 15, 16], "s4": [17, 18, 19, 20], "s5": [21, 22, 23, 24],
|
||||||
|
"s6": [25, 26, 27, 28], "s7": [29, 30, 31, 32],
|
||||||
|
}
|
||||||
|
n_new = 8
|
||||||
|
requests = [_generation(sid, p, n_new) for sid, p in prompts.items()]
|
||||||
|
|
||||||
|
sweep = run_concurrency_sweep(
|
||||||
|
lambda: _make_engine(model),
|
||||||
|
requests,
|
||||||
|
concurrency_levels=(1, 2, 4, 8),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sweep.corruption_free
|
||||||
|
assert [r.concurrency for r in sweep.results] == [1, 2, 4, 8]
|
||||||
|
|
||||||
|
# No session hit a cache miss (budgets are sized to never evict here).
|
||||||
|
assert all(r.cache_misses == 0 for r in sweep.results)
|
||||||
|
assert all(r.rejected_admissions == 0 for r in sweep.results)
|
||||||
|
|
||||||
|
# Each per-session stream matches the serialized (concurrency-1) reference.
|
||||||
|
for sid, prompt in prompts.items():
|
||||||
|
assert list(sweep.reference_outputs[sid]) == _reference_tokens(model, prompt, n_new)
|
||||||
|
|
||||||
|
occupancies = [r.avg_batch_occupancy for r in sweep.results]
|
||||||
|
ticks = [r.ticks for r in sweep.results]
|
||||||
|
tokens_per_tick = [r.tokens_per_tick for r in sweep.results]
|
||||||
|
|
||||||
|
# Batching packs more sessions per decode step as concurrency rises, so
|
||||||
|
# average occupancy strictly increases and total ticks strictly decrease.
|
||||||
|
assert occupancies == sorted(occupancies) and len(set(occupancies)) == 4
|
||||||
|
assert ticks == sorted(ticks, reverse=True) and len(set(ticks)) == 4
|
||||||
|
# Aggregate work per tick rises with concurrency (the throughput win).
|
||||||
|
assert tokens_per_tick == sorted(tokens_per_tick)
|
||||||
|
|
||||||
|
# For eight equal-length jobs the node keeps saturating up to the top level.
|
||||||
|
assert sweep.saturation_concurrency == 8
|
||||||
|
|
||||||
|
# The report is JSON-safe for durable evidence.
|
||||||
|
import json
|
||||||
|
|
||||||
|
json.dumps(sweep.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrency_sweep_saturates_below_max_when_load_is_small():
|
||||||
|
"With fewer concurrent jobs than slots, saturation is found below the top level.\n\nTags: node, scheduler, benchmark"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
# Only three jobs: at concurrency 4 and 8 the batch can never exceed 3, so
|
||||||
|
# occupancy stops rising past the load and saturation is detected early.
|
||||||
|
requests = [
|
||||||
|
_generation("j0", [1, 2, 3], 6),
|
||||||
|
_generation("j1", [4, 5, 6], 6),
|
||||||
|
_generation("j2", [7, 8, 9], 6),
|
||||||
|
]
|
||||||
|
sweep = run_concurrency_sweep(
|
||||||
|
lambda: _make_engine(model), requests, concurrency_levels=(1, 2, 4, 8)
|
||||||
|
)
|
||||||
|
assert sweep.corruption_free
|
||||||
|
assert sweep.saturation_concurrency <= 4
|
||||||
|
# Levels at or above the load size share the same occupancy/tick profile.
|
||||||
|
top = [r for r in sweep.results if r.concurrency >= 4]
|
||||||
|
assert len({r.ticks for r in top}) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Engine contract guards.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_kv_batch_engine_requires_a_full_shard():
|
||||||
|
"The batch engine rejects a partial (non head+tail) shard.\n\nTags: node, scheduler"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
head = _KvReferenceShard(model, 0, 2) # head only, not tail
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(head))
|
||||||
|
adapter = KvBoundaryAdapter(head, manager)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
KvBatchEngine(adapter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_to_completion_is_bounded_against_misconfiguration():
|
||||||
|
"run_to_completion raises rather than looping forever when work cannot drain.\n\nTags: node, scheduler"
|
||||||
|
engine = _make_engine()
|
||||||
|
scheduler = ContinuousBatchScheduler(
|
||||||
|
engine, NodeBudget(max_active_sessions=1, max_batch_size=1, max_queue_depth=4)
|
||||||
|
)
|
||||||
|
scheduler.submit(_generation("only", [1, 2], 3))
|
||||||
|
# A tiny explicit tick ceiling is exceeded deterministically.
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
scheduler.run_to_completion(max_ticks=1)
|
||||||
611
tests/test_failure_semantics.py
Normal file
611
tests/test_failure_semantics.py
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
"""Bounded failure, cancellation, and restart semantics (DGR-013).
|
||||||
|
|
||||||
|
These tests drive the hardened per-session decode stream with the *same*
|
||||||
|
pure-numpy KV-cached dense-Llama reference the Hot KV State manager (DGR-007) and
|
||||||
|
the continuous-batch scheduler (DGR-012) use, imported from ``test_hot_kv_state``.
|
||||||
|
The whole matrix stays deterministic, download-free, GPU-free, and API-credit-free
|
||||||
|
while exercising the real KV isolation path (``KvBoundaryAdapter`` +
|
||||||
|
``HotKvStateManager``) rather than a mock.
|
||||||
|
|
||||||
|
Coverage maps to the story's acceptance criteria:
|
||||||
|
|
||||||
|
* deadlines and heartbeat/health loss terminate blocked stream operations,
|
||||||
|
* cancellation propagates across every Shard and releases KV + queued buffers,
|
||||||
|
* duplicate steps are idempotent; uncertain mutations are never replayed silently,
|
||||||
|
* alpha failover restarts from token zero rather than importing unverified KV,
|
||||||
|
* worker death / stream reset / malformed bundle / stale epoch / cache miss,
|
||||||
|
* billing/work records distinguish completed, cancelled, failed, and unverified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meshnet_node.batch_scheduler import (
|
||||||
|
ContinuousBatchScheduler,
|
||||||
|
DoneReason,
|
||||||
|
GenerationRequest,
|
||||||
|
KvBatchEngine,
|
||||||
|
NodeBudget,
|
||||||
|
)
|
||||||
|
from meshnet_node.boundary_adapter import BoundaryBundle, BoundaryContractError
|
||||||
|
from meshnet_node.hot_kv_state import (
|
||||||
|
CacheMiss,
|
||||||
|
CacheMissReason,
|
||||||
|
HotKvStateConfig,
|
||||||
|
HotKvStateManager,
|
||||||
|
KvBoundaryAdapter,
|
||||||
|
StaleRouteEpochError,
|
||||||
|
kv_recipe_for,
|
||||||
|
)
|
||||||
|
from meshnet_node.failure_semantics import (
|
||||||
|
CancellationToken,
|
||||||
|
DeadlineGuard,
|
||||||
|
FailureKind,
|
||||||
|
HardenedSessionRunner,
|
||||||
|
IdempotencyLedger,
|
||||||
|
OperationCancelled,
|
||||||
|
RestartController,
|
||||||
|
ShardCancellationGroup,
|
||||||
|
StepKey,
|
||||||
|
StreamTerminated,
|
||||||
|
UncertainMutationError,
|
||||||
|
WorkLedger,
|
||||||
|
WorkRecord,
|
||||||
|
WorkStatus,
|
||||||
|
classify_exception,
|
||||||
|
work_status_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reuse the certified numpy dense-Llama reference and shard from the DGR-007 gate.
|
||||||
|
from test_hot_kv_state import _KvDenseLlama, _KvReferenceShard
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Helpers.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.now = 0.0
|
||||||
|
|
||||||
|
def __call__(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, delta: float) -> None:
|
||||||
|
self.now += delta
|
||||||
|
|
||||||
|
|
||||||
|
class _FaultyShard(_KvReferenceShard):
|
||||||
|
"""A full-shard reference that raises on the Nth ``run_layers_cached`` call.
|
||||||
|
|
||||||
|
``run_layers_cached`` is invoked once per stream step, so ``fail_at_call=k``
|
||||||
|
simulates a worker dying at step ``k-1`` (calls are 1-indexed). The call
|
||||||
|
counter persists across attempts, so a restart on a fresh epoch keeps counting
|
||||||
|
and does not re-trip the same fault.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model, start, end, *, fail_at_call=None, error=None):
|
||||||
|
super().__init__(model, start, end)
|
||||||
|
self._fail_at_call = fail_at_call
|
||||||
|
self._error = error or RuntimeError("worker died mid-step")
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def run_layers_cached(self, hidden, *, positions, past_kv):
|
||||||
|
self.calls += 1
|
||||||
|
if self._fail_at_call is not None and self.calls == self._fail_at_call:
|
||||||
|
raise self._error
|
||||||
|
return super().run_layers_cached(hidden, positions=positions, past_kv=past_kv)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter(model=None, *, config=None, shard=None):
|
||||||
|
"""A full-shard KV boundary adapter over the deterministic numpy dense-Llama."""
|
||||||
|
model = model or _KvDenseLlama()
|
||||||
|
shard = shard or _KvReferenceShard(model, 0, model.n_layers - 1)
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(shard), config=config)
|
||||||
|
adapter = KvBoundaryAdapter(shard, manager)
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
def _generation(session_id, prompt, n_new, epoch=0):
|
||||||
|
return GenerationRequest(
|
||||||
|
session_id=session_id,
|
||||||
|
route_epoch=epoch,
|
||||||
|
prompt_token_ids=tuple(prompt),
|
||||||
|
max_new_tokens=n_new,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Happy path (the baseline the failure paths deviate from).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_run_matches_stateless_reference_and_is_billable():
|
||||||
|
"A clean stream reproduces the stateless tokens and records completed work.\n\nTags: node, failure, billing"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
adapter = _make_adapter(model)
|
||||||
|
runner = HardenedSessionRunner(adapter)
|
||||||
|
prompt = [1, 2, 3, 4]
|
||||||
|
n_new = 8
|
||||||
|
outcome = runner.run(_generation("clean", prompt, n_new))
|
||||||
|
assert outcome.status is WorkStatus.COMPLETED
|
||||||
|
assert list(outcome.tokens) == model.stateless_greedy(prompt, n_new)
|
||||||
|
record = runner.work_ledger.records_for("clean")[0]
|
||||||
|
assert record.billable
|
||||||
|
assert record.tokens == n_new
|
||||||
|
assert runner.work_ledger.billable_tokens() == n_new
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Deadlines and heartbeat/health loss terminate blocked operations.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_terminates_a_blocked_stream_and_releases_kv():
|
||||||
|
"A deadline reached mid-stream terminates the run and frees its KV.\n\nTags: node, failure, deadline"
|
||||||
|
clock = _FakeClock()
|
||||||
|
adapter = _make_adapter()
|
||||||
|
manager = adapter.manager
|
||||||
|
runner = HardenedSessionRunner(adapter, clock=clock)
|
||||||
|
|
||||||
|
# Each step advances the clock by 1.0; the deadline fires at t=3.
|
||||||
|
def before_step(_step):
|
||||||
|
clock.advance(1.0)
|
||||||
|
|
||||||
|
outcome = runner.run(
|
||||||
|
_generation("slow", [5, 6, 7], 20),
|
||||||
|
deadline=3.0,
|
||||||
|
before_step=before_step,
|
||||||
|
)
|
||||||
|
assert outcome.status is WorkStatus.FAILED
|
||||||
|
assert outcome.failure_kind is FailureKind.DEADLINE_EXCEEDED
|
||||||
|
# The stream did not hang and did not finish: only the steps before the
|
||||||
|
# deadline committed, and the session's KV was released.
|
||||||
|
assert outcome.token_count < 20
|
||||||
|
assert isinstance(manager.resolve("slow", 0), CacheMiss)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heartbeat_loss_terminates_a_blocked_stream():
|
||||||
|
"Losing the peer heartbeat past the timeout terminates the stream.\n\nTags: node, failure, heartbeat"
|
||||||
|
clock = _FakeClock()
|
||||||
|
adapter = _make_adapter()
|
||||||
|
runner = HardenedSessionRunner(adapter, clock=clock)
|
||||||
|
|
||||||
|
def before_step(_step):
|
||||||
|
clock.advance(1.0)
|
||||||
|
|
||||||
|
# Heartbeats stop arriving after step 2; with a timeout of 1.5 the gap grows
|
||||||
|
# past the bound and the stream is terminated (health loss).
|
||||||
|
def heartbeat(step):
|
||||||
|
return step < 2
|
||||||
|
|
||||||
|
outcome = runner.run(
|
||||||
|
_generation("hb", [9, 8, 7], 20),
|
||||||
|
heartbeat_timeout=1.5,
|
||||||
|
heartbeat=heartbeat,
|
||||||
|
before_step=before_step,
|
||||||
|
)
|
||||||
|
assert outcome.status is WorkStatus.FAILED
|
||||||
|
assert outcome.failure_kind is FailureKind.HEARTBEAT_LOST
|
||||||
|
assert outcome.token_count < 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_guard_reports_remaining_and_resets_on_heartbeat():
|
||||||
|
"The guard exposes remaining time and a heartbeat resets the health timer.\n\nTags: node, failure, deadline"
|
||||||
|
clock = _FakeClock()
|
||||||
|
guard = DeadlineGuard(deadline=10.0, heartbeat_timeout=2.0, clock=clock)
|
||||||
|
guard.start()
|
||||||
|
guard.check()
|
||||||
|
assert guard.remaining() == 10.0
|
||||||
|
clock.advance(1.5)
|
||||||
|
guard.heartbeat() # health refreshed at t=1.5
|
||||||
|
clock.advance(1.0) # gap since heartbeat is 1.0 < 2.0
|
||||||
|
guard.check()
|
||||||
|
clock.advance(2.5) # gap since heartbeat is now 3.5 > 2.0
|
||||||
|
with pytest.raises(StreamTerminated) as exc:
|
||||||
|
guard.check()
|
||||||
|
assert exc.value.kind is FailureKind.HEARTBEAT_LOST
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Cancellation propagates across shards and releases KV + queued buffers.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancellation_token_terminates_stream_and_releases_kv():
|
||||||
|
"A client cancel mid-stream stops the run and releases the session KV.\n\nTags: node, failure, cancel"
|
||||||
|
adapter = _make_adapter()
|
||||||
|
manager = adapter.manager
|
||||||
|
token = CancellationToken()
|
||||||
|
runner = HardenedSessionRunner(adapter)
|
||||||
|
|
||||||
|
# Cancel after two steps have run.
|
||||||
|
def before_step(step):
|
||||||
|
if step == 2:
|
||||||
|
token.cancel("client-hangup")
|
||||||
|
|
||||||
|
outcome = runner.run(
|
||||||
|
_generation("cancelme", [1, 2, 3], 20),
|
||||||
|
cancel_token=token,
|
||||||
|
before_step=before_step,
|
||||||
|
)
|
||||||
|
assert outcome.status is WorkStatus.CANCELLED
|
||||||
|
assert outcome.failure_kind is FailureKind.CANCELLED
|
||||||
|
assert outcome.token_count == 2 # steps 0 and 1 committed before the cancel
|
||||||
|
assert isinstance(manager.resolve("cancelme", 0), CacheMiss)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shard_cancellation_group_releases_every_shard_and_queued_buffers():
|
||||||
|
"One cancel frees KV on every node-local shard and releases queued buffers.\n\nTags: node, failure, cancel"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
# Three node-local shards of the same route, each with its own KV manager.
|
||||||
|
managers = []
|
||||||
|
for start, end in ((0, 1), (2, 3), (4, 5)):
|
||||||
|
shard = _KvReferenceShard(model, start, end)
|
||||||
|
mgr = HotKvStateManager(kv_recipe_for(shard))
|
||||||
|
mgr.open("route", 0) # each holds live state for the session
|
||||||
|
managers.append(mgr)
|
||||||
|
|
||||||
|
released_buffers = []
|
||||||
|
group = ShardCancellationGroup("route", 0)
|
||||||
|
for mgr in managers:
|
||||||
|
group.add_shard(mgr)
|
||||||
|
group.add_queued_buffer(lambda: released_buffers.append("bundle-a"))
|
||||||
|
group.add_queued_buffer(lambda: released_buffers.append("bundle-b"))
|
||||||
|
|
||||||
|
outcome = group.cancel()
|
||||||
|
assert outcome.shards_released == 3
|
||||||
|
assert outcome.buffers_released == 2
|
||||||
|
assert released_buffers == ["bundle-a", "bundle-b"]
|
||||||
|
# Every shard's KV is gone: a lookup now yields an explicit released miss.
|
||||||
|
for mgr in managers:
|
||||||
|
miss = mgr.resolve("route", 0)
|
||||||
|
assert isinstance(miss, CacheMiss)
|
||||||
|
assert miss.reason is CacheMissReason.RELEASED
|
||||||
|
# Cancellation is idempotent.
|
||||||
|
again = group.cancel()
|
||||||
|
assert again.shards_released == 0
|
||||||
|
assert again.buffers_released == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_cancel_drains_queue_and_releases_active_kv():
|
||||||
|
"The scheduler cancel drops queued work and frees an active session's KV.\n\nTags: node, scheduler, cancel"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
shard = _KvReferenceShard(model, 0, model.n_layers - 1)
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(shard))
|
||||||
|
engine = KvBatchEngine(KvBoundaryAdapter(shard, manager))
|
||||||
|
scheduler = ContinuousBatchScheduler(
|
||||||
|
engine, NodeBudget(max_active_sessions=1, max_batch_size=1, max_queue_depth=4)
|
||||||
|
)
|
||||||
|
assert scheduler.submit(_generation("active", [1, 2, 3], 8)).running
|
||||||
|
assert scheduler.submit(_generation("waiting", [4, 5, 6], 8)).reason.value == "queued"
|
||||||
|
scheduler.run_tick() # 'active' prefills and starts decoding, holding KV
|
||||||
|
|
||||||
|
# Cancel the queued one: it leaves the queue without ever taking a slot.
|
||||||
|
assert scheduler.cancel("waiting") is True
|
||||||
|
# Cancel the active one: its KV is released and it is recorded as cancelled.
|
||||||
|
assert scheduler.cancel("active") is True
|
||||||
|
assert manager.total_bytes == 0
|
||||||
|
|
||||||
|
telem = scheduler.telemetry()
|
||||||
|
assert telem.cancelled_sessions == 2
|
||||||
|
assert telem.completed_sessions == 0
|
||||||
|
assert telem.active_sessions == 0
|
||||||
|
assert telem.queue_depth == 0
|
||||||
|
# Cancelling an unknown / already-finished session is a no-op.
|
||||||
|
assert scheduler.cancel("active") is False
|
||||||
|
assert scheduler.cancel("never-seen") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_cancel_rejects_a_completed_reason():
|
||||||
|
"cancel() refuses a non-terminal reason so completed work is never faked.\n\nTags: node, scheduler, cancel"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
shard = _KvReferenceShard(model, 0, model.n_layers - 1)
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(shard))
|
||||||
|
engine = KvBatchEngine(KvBoundaryAdapter(shard, manager))
|
||||||
|
scheduler = ContinuousBatchScheduler(engine)
|
||||||
|
scheduler.submit(_generation("x", [1, 2], 4))
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
scheduler.cancel("x", reason=DoneReason.COMPLETED)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Idempotency: duplicate steps are no-ops; uncertain mutations never replay.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_step_delivery_is_idempotent_no_remutation():
|
||||||
|
"Replaying a committed step returns the recorded token without re-mutating KV.\n\nTags: node, failure, idempotency"
|
||||||
|
ledger = IdempotencyLedger()
|
||||||
|
key = StepKey("s", 0, 5)
|
||||||
|
disposition = ledger.begin(key)
|
||||||
|
assert disposition.fresh
|
||||||
|
ledger.commit(key, 42)
|
||||||
|
# A duplicate delivery of the same step returns the recorded token and is a
|
||||||
|
# no-op — the caller must not re-run the mutation.
|
||||||
|
replay = ledger.begin(key)
|
||||||
|
assert replay.duplicate
|
||||||
|
assert replay.token == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent_run_replays_tokens_without_advancing_kv():
|
||||||
|
"Re-running a completed stream on the same ledger/epoch re-mutates nothing.\n\nTags: node, failure, idempotency"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
adapter = _make_adapter(model)
|
||||||
|
ledger = IdempotencyLedger()
|
||||||
|
runner = HardenedSessionRunner(adapter, idempotency=ledger)
|
||||||
|
request = _generation("idem", [3, 1, 4], 6)
|
||||||
|
|
||||||
|
first = runner.run(request)
|
||||||
|
assert first.status is WorkStatus.COMPLETED
|
||||||
|
kv_len_after_first = adapter.manager.get("idem", 0).seq_len
|
||||||
|
|
||||||
|
# A duplicate delivery of the entire stream: every step is a committed
|
||||||
|
# duplicate, so the runner replays the identical tokens and the KV length is
|
||||||
|
# unchanged (no double-append).
|
||||||
|
second = runner.run(request)
|
||||||
|
assert second.status is WorkStatus.COMPLETED
|
||||||
|
assert list(second.tokens) == list(first.tokens)
|
||||||
|
assert adapter.manager.get("idem", 0).seq_len == kv_len_after_first
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncertain_mutation_is_never_replayed_silently():
|
||||||
|
"A step marked uncertain refuses a silent replay; it must be verified/restarted.\n\nTags: node, failure, idempotency"
|
||||||
|
ledger = IdempotencyLedger()
|
||||||
|
key = StepKey("s", 0, 3)
|
||||||
|
ledger.begin(key)
|
||||||
|
ledger.mark_uncertain(key, "worker died before ack")
|
||||||
|
# Replaying an uncertain mutation is refused rather than silently re-applied.
|
||||||
|
with pytest.raises(UncertainMutationError):
|
||||||
|
ledger.begin(key)
|
||||||
|
assert ledger.has_uncertain()
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_flight_duplicate_is_treated_as_uncertain():
|
||||||
|
"A second begin before commit is refused (concurrent duplicate is unverified).\n\nTags: node, failure, idempotency"
|
||||||
|
ledger = IdempotencyLedger()
|
||||||
|
key = StepKey("s", 0, 1)
|
||||||
|
ledger.begin(key) # in-flight, not yet committed
|
||||||
|
with pytest.raises(UncertainMutationError):
|
||||||
|
ledger.begin(key)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Worker death, stream reset, malformed bundle, stale epoch, cache miss.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_death_midstream_is_unverified_and_marks_step_uncertain():
|
||||||
|
"A worker dying mid-step yields unverified work and an unreplayable step.\n\nTags: node, failure, worker-death"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
# Fail on the 3rd step call (step index 2), after two tokens committed.
|
||||||
|
shard = _FaultyShard(model, 0, model.n_layers - 1, fail_at_call=3)
|
||||||
|
adapter = _make_adapter(model, shard=shard)
|
||||||
|
ledger = IdempotencyLedger()
|
||||||
|
runner = HardenedSessionRunner(adapter, idempotency=ledger)
|
||||||
|
|
||||||
|
outcome = runner.run(_generation("dead", [1, 2, 3], 8))
|
||||||
|
assert outcome.status is WorkStatus.UNVERIFIED
|
||||||
|
assert outcome.failure_kind is FailureKind.WORKER_DEATH
|
||||||
|
assert outcome.token_count == 2 # the two committed steps
|
||||||
|
assert not outcome.completed
|
||||||
|
# The failed step is uncertain and can never be silently replayed.
|
||||||
|
assert ledger.has_uncertain()
|
||||||
|
with pytest.raises(UncertainMutationError):
|
||||||
|
ledger.begin(StepKey("dead", 0, 2))
|
||||||
|
# KV was released on failure.
|
||||||
|
assert isinstance(adapter.manager.resolve("dead", 0), CacheMiss)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_reset_is_restartable_failure():
|
||||||
|
"A stream reset injected mid-stream fails the run as a restartable transport loss.\n\nTags: node, failure, stream-reset"
|
||||||
|
adapter = _make_adapter()
|
||||||
|
runner = HardenedSessionRunner(adapter)
|
||||||
|
|
||||||
|
def before_step(step):
|
||||||
|
if step == 2:
|
||||||
|
raise StreamTerminated(FailureKind.STREAM_RESET, "peer reset the stream")
|
||||||
|
|
||||||
|
outcome = runner.run(_generation("reset", [1, 2, 3], 8), before_step=before_step)
|
||||||
|
assert outcome.status is WorkStatus.FAILED
|
||||||
|
assert outcome.failure_kind is FailureKind.STREAM_RESET
|
||||||
|
assert outcome.restartable
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_bundle_is_classified_and_does_not_corrupt_kv():
|
||||||
|
"A malformed activation bundle is rejected and leaves the KV context empty.\n\nTags: node, failure, malformed-bundle"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
mid = _KvReferenceShard(model, 2, 3) # middle range: not head, not tail
|
||||||
|
manager = HotKvStateManager(kv_recipe_for(mid))
|
||||||
|
adapter = KvBoundaryAdapter(mid, manager)
|
||||||
|
assert not adapter.is_head and not adapter.is_tail
|
||||||
|
|
||||||
|
# A bundle that hands over at the wrong layer is malformed.
|
||||||
|
bad = BoundaryBundle(
|
||||||
|
architecture_adapter=adapter.architecture.adapter,
|
||||||
|
schema_version=adapter.architecture.boundary_schema_version,
|
||||||
|
tensor_name=adapter.architecture.boundary_tensor_name,
|
||||||
|
residual=np.zeros((1, 3, model.hidden), dtype=np.float32),
|
||||||
|
positions=np.arange(3, dtype=np.int64)[None, :],
|
||||||
|
next_layer=adapter.start_layer + 5, # wrong handover layer
|
||||||
|
normalized=False,
|
||||||
|
)
|
||||||
|
with pytest.raises(BoundaryContractError) as exc:
|
||||||
|
adapter.prefill("mal", 0, boundary=bad)
|
||||||
|
assert classify_exception(exc.value) is FailureKind.MALFORMED_BUNDLE
|
||||||
|
# The malformed step never appended KV: the context is empty, not corrupted.
|
||||||
|
assert manager.get("mal", 0).seq_len == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_epoch_reference_is_rejected_and_classified():
|
||||||
|
"A reference to a superseded epoch is rejected as stale, never silently reused.\n\nTags: node, failure, stale-epoch"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
adapter = _make_adapter(model)
|
||||||
|
manager = adapter.manager
|
||||||
|
manager.open("sess", 5) # current epoch is now 5
|
||||||
|
with pytest.raises(StaleRouteEpochError) as exc:
|
||||||
|
manager.resolve("sess", 4) # epoch 4 is stale
|
||||||
|
assert classify_exception(exc.value) is FailureKind.STALE_EPOCH
|
||||||
|
|
||||||
|
# Driving the hardened runner on the stale epoch fails closed as STALE_EPOCH.
|
||||||
|
runner = HardenedSessionRunner(adapter)
|
||||||
|
outcome = runner.run(_generation("sess", [1, 2, 3], 4, epoch=3))
|
||||||
|
assert outcome.status is WorkStatus.FAILED
|
||||||
|
assert outcome.failure_kind is FailureKind.STALE_EPOCH
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss_midstream_is_restartable():
|
||||||
|
"A KV eviction mid-stream surfaces an explicit cache miss the head can restart.\n\nTags: node, failure, cache-miss"
|
||||||
|
adapter = _make_adapter()
|
||||||
|
manager = adapter.manager
|
||||||
|
runner = HardenedSessionRunner(adapter)
|
||||||
|
|
||||||
|
# Evict the session's KV just before step 3's decode.
|
||||||
|
def before_step(step):
|
||||||
|
if step == 3:
|
||||||
|
manager.release("evict", 0)
|
||||||
|
|
||||||
|
outcome = runner.run(_generation("evict", [1, 2, 3], 10), before_step=before_step)
|
||||||
|
assert outcome.failure_kind is FailureKind.CACHE_MISS
|
||||||
|
assert outcome.restartable
|
||||||
|
assert outcome.token_count == 3 # steps 0..2 committed before the eviction
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Alpha failover: restart from token zero, never import unverified KV.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_alpha_failover_restarts_from_token_zero_and_completes():
|
||||||
|
"A transient worker death fails over to a fresh epoch and reproduces the tokens.\n\nTags: node, failure, failover"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
# Die on the 3rd step of the first attempt; the persistent call counter means
|
||||||
|
# the restart (which keeps counting) does not re-trip the fault.
|
||||||
|
shard = _FaultyShard(model, 0, model.n_layers - 1, fail_at_call=3)
|
||||||
|
adapter = _make_adapter(model, shard=shard)
|
||||||
|
manager = adapter.manager
|
||||||
|
runner = HardenedSessionRunner(adapter)
|
||||||
|
controller = RestartController([manager])
|
||||||
|
|
||||||
|
prompt = [7, 3, 9, 1]
|
||||||
|
n_new = 6
|
||||||
|
result = runner.run_with_failover(
|
||||||
|
_generation("alpha", prompt, n_new, epoch=0), controller, max_restarts=2
|
||||||
|
)
|
||||||
|
assert result.completed
|
||||||
|
assert result.restarts == 1
|
||||||
|
# The restart began on a fresh epoch and reproduced the full stateless stream
|
||||||
|
# from token zero — no half-computed KV was imported.
|
||||||
|
assert result.outcome.route_epoch == 1
|
||||||
|
assert list(result.outcome.tokens) == model.stateless_greedy(prompt, n_new)
|
||||||
|
# The failed epoch's KV is gone and the epoch is now stale.
|
||||||
|
with pytest.raises(StaleRouteEpochError):
|
||||||
|
manager.resolve("alpha", 0)
|
||||||
|
# First attempt was unverified, the restart completed: only the restart bills.
|
||||||
|
statuses = [a.status for a in result.attempts]
|
||||||
|
assert statuses == [WorkStatus.UNVERIFIED, WorkStatus.COMPLETED]
|
||||||
|
assert runner.work_ledger.billable_tokens() == n_new
|
||||||
|
|
||||||
|
|
||||||
|
def test_failover_refuses_to_import_unverified_kv():
|
||||||
|
"assert_fresh_start fails closed if any shard still holds new-epoch KV.\n\nTags: node, failure, failover"
|
||||||
|
model = _KvDenseLlama()
|
||||||
|
adapter = _make_adapter(model)
|
||||||
|
manager = adapter.manager
|
||||||
|
controller = RestartController([manager])
|
||||||
|
|
||||||
|
new_epoch = controller.failover("s", 0)
|
||||||
|
assert new_epoch == 1
|
||||||
|
# A clean fresh start passes.
|
||||||
|
controller.assert_fresh_start("s", new_epoch)
|
||||||
|
# If unverified KV were present under the new epoch, the guard refuses it.
|
||||||
|
manager.open("s", new_epoch)
|
||||||
|
manager.append(
|
||||||
|
"s",
|
||||||
|
new_epoch,
|
||||||
|
{i: (np.zeros((1, model.n_heads, model.head_dim), dtype=np.float32),
|
||||||
|
np.zeros((1, model.n_heads, model.head_dim), dtype=np.float32))
|
||||||
|
for i in range(model.n_layers)},
|
||||||
|
)
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
controller.assert_fresh_start("s", new_epoch)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_restartable_failure_is_not_retried():
|
||||||
|
"A deterministic failure (deadline) returns immediately without a restart.\n\nTags: node, failure, failover"
|
||||||
|
clock = _FakeClock()
|
||||||
|
adapter = _make_adapter()
|
||||||
|
runner = HardenedSessionRunner(adapter, clock=clock)
|
||||||
|
controller = RestartController([adapter.manager])
|
||||||
|
|
||||||
|
def before_step(_step):
|
||||||
|
clock.advance(1.0)
|
||||||
|
|
||||||
|
result = runner.run_with_failover(
|
||||||
|
_generation("bounded", [1, 2, 3], 20),
|
||||||
|
controller,
|
||||||
|
max_restarts=3,
|
||||||
|
deadline=2.0,
|
||||||
|
before_step=before_step,
|
||||||
|
)
|
||||||
|
assert not result.completed
|
||||||
|
assert result.restarts == 0
|
||||||
|
assert result.outcome.failure_kind is FailureKind.DEADLINE_EXCEEDED
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Billing / work records distinguish completed, cancelled, failed, unverified.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_ledger_distinguishes_all_four_statuses():
|
||||||
|
"The work ledger keeps completed/cancelled/failed/unverified distinct.\n\nTags: node, failure, billing"
|
||||||
|
ledger = WorkLedger()
|
||||||
|
ledger.record(WorkRecord("a", 0, WorkStatus.COMPLETED, tokens=8))
|
||||||
|
ledger.record(WorkRecord("b", 0, WorkStatus.CANCELLED, tokens=3,
|
||||||
|
failure_kind=FailureKind.CANCELLED))
|
||||||
|
ledger.record(WorkRecord("c", 0, WorkStatus.FAILED, tokens=1,
|
||||||
|
failure_kind=FailureKind.DEADLINE_EXCEEDED))
|
||||||
|
ledger.record(WorkRecord("d", 0, WorkStatus.UNVERIFIED, tokens=2,
|
||||||
|
failure_kind=FailureKind.WORKER_DEATH))
|
||||||
|
|
||||||
|
counts = ledger.counts_by_status()
|
||||||
|
assert counts == {
|
||||||
|
"completed": 1, "cancelled": 1, "failed": 1, "unverified": 1,
|
||||||
|
}
|
||||||
|
# Only completed work is billable — cancelled/failed/unverified tokens are
|
||||||
|
# recorded for observability but never charged.
|
||||||
|
assert ledger.billable_tokens() == 8
|
||||||
|
assert [r.session_id for r in ledger.billable_records()] == ["a"]
|
||||||
|
# JSON-safe for durable evidence.
|
||||||
|
payload = ledger.to_dict()
|
||||||
|
assert payload["billable_tokens"] == 8
|
||||||
|
assert payload["counts_by_status"]["unverified"] == 1
|
||||||
|
json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_work_status_and_classification_mapping():
|
||||||
|
"Failure kinds map to the right billing status and exception classes.\n\nTags: node, failure, billing"
|
||||||
|
assert work_status_for(FailureKind.CANCELLED) is WorkStatus.CANCELLED
|
||||||
|
assert work_status_for(FailureKind.WORKER_DEATH) is WorkStatus.UNVERIFIED
|
||||||
|
# A stream reset detected at a step boundary is a certain failure (nothing
|
||||||
|
# committed for that step) — only an unexpected mid-step error is unverified.
|
||||||
|
assert work_status_for(FailureKind.STREAM_RESET) is WorkStatus.FAILED
|
||||||
|
assert work_status_for(FailureKind.DEADLINE_EXCEEDED) is WorkStatus.FAILED
|
||||||
|
assert work_status_for(FailureKind.MALFORMED_BUNDLE) is WorkStatus.FAILED
|
||||||
|
assert work_status_for(FailureKind.STALE_EPOCH) is WorkStatus.FAILED
|
||||||
|
assert work_status_for(FailureKind.CACHE_MISS) is WorkStatus.FAILED
|
||||||
|
|
||||||
|
assert classify_exception(OperationCancelled()) is FailureKind.CANCELLED
|
||||||
|
assert classify_exception(StaleRouteEpochError("x")) is FailureKind.STALE_EPOCH
|
||||||
|
assert classify_exception(BoundaryContractError("x")) is FailureKind.MALFORMED_BUNDLE
|
||||||
|
assert classify_exception(RuntimeError("boom")) is FailureKind.WORKER_DEATH
|
||||||
|
assert (
|
||||||
|
classify_exception(StreamTerminated(FailureKind.HEARTBEAT_LOST))
|
||||||
|
is FailureKind.HEARTBEAT_LOST
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user