Continuous batching under load
Sweep concurrency and watch TTFT, TPOT, and throughput trade against each other; find the knee.
Throughput and latency are not two dials. They are one dial, and its name is concurrency. This lab sweeps it, watches TTFT, TPOT and tokens per second trade against each other, finds the knee, and then makes you name the resource that produced the knee — because "saturation" is not a cause.
One GPU big enough to serve a 7–8B model: 24 GB is plenty, and a smaller card works
if you lower --max-model-len. You need one running server, either engine, and
nothing on the client side but Python's standard library. The client is stdlib-only on purpose so
that a box with no GPU can still drive a server on another box — but the server
needs a GPU, and there is no substitute for it here. Without one, read this page and treat the
cited knee-shape discussion in
§1.3 as the answer;
no number below is measured.
run.py was written against the API fields and metric definitions cited below, and
its argument handling was exercised, but it has not been run against a live engine —
no GPU was available while writing. Every number it prints is a measurement you make.
Nothing on this page is one.
What you measure
Four quantities per concurrency level, defined exactly as vLLM's own serving benchmark defines
them so your numbers are comparable with vllm bench serve:
| Metric | Definition | Moves when |
|---|---|---|
| TTFT | first token arrival minus request send | queueing grows, or a prefill gets bigger or shares an iteration with more work |
| TPOT | (latency − TTFT) / (tokens − 1) |
the decode step gets slower — more resident KV, more rows, preemption |
| ITL | the raw gaps between consecutive tokens | same as TPOT, but it keeps the tail. A p99 ITL spike is invisible in a mean TPOT. |
| output tok/s | tokens generated over wall time | the batch gets wider — until it stops paying |
TPOT is one line, and the definition matters because the alternatives differ by a token:
tpot = 0.0
if output_len > 1:
latency_minus_ttft = outputs[i].latency - outputs[i].ttft
tpot = latency_minus_ttft / (output_len - 1)
tpots.append(tpot)
# Note: if output_len <= 1, we regard tpot as 0 for goodput
all_tpots.append(tpot)
TTFT and ITL are taken client-side, one timestamp per SSE data frame, which is what a user actually experiences:
timestamp = time.perf_counter()
data = json.loads(chunk)
if choices := data.get("choices"):
content = choices[0]["delta"].get("content")
# First token
if ttft == 0.0:
ttft = timestamp - st
output.ttft = ttft
# Decoding phase
else:
output.itl.append(timestamp - most_recent_timestamp)
Closed loop, deliberately
run.py holds exactly N requests in flight: each of N
workers starts a new request the instant its previous one finishes. That answers "with
N users each waiting on an answer, what do they see?" — and it is the right
question for a batching experiment, because concurrency is the independent variable and is held
exactly.
It is the wrong question for capacity planning. "What happens at 40 requests per second?" needs open-loop arrivals, where a slow server does not slow the arrival process down; that distinction, and why closed-loop benchmarks systematically flatter a saturated server, belongs to §10.3 and lab 11. Both engines' harnesses take the same pair of knobs, and the help text spells out the interaction:
parser.add_argument(
"--max-concurrency",
type=int,
default=None,
help="Maximum number of concurrent requests. This can be used "
"to help simulate an environment where a higher level component "
"is enforcing a maximum number of concurrent requests. While the "
"--request-rate argument controls the rate at which requests are "
"initiated, this argument will control how many are actually allowed "
"to execute at a time. This means that when used in combination, the "
"actual request rate may be lower than specified with --request-rate, "
"if the server is not processing requests fast enough to keep up.",
parser.add_argument(
"--max-concurrency",
type=int,
default=None,
help="Maximum number of concurrent requests. This can be used "
"to help simulate an environment where a higher level component "
"is enforcing a maximum number of concurrent requests. While the "
"--request-rate argument controls the rate at which requests are "
"initiated, this argument will control how many are actually allowed "
"to execute at a time. This means that when used in combination, the "
"actual request rate may be lower than specified with --request-rate, "
"if the server is not processing requests fast enough to keep up.",
Word-for-word identical, which is worth noticing: SGLang's serving benchmark descends from vLLM's, so when you compare engines with these two harnesses you are at least comparing the same definitions.
Running it
$ vllm serve meta-llama/Meta-Llama-3-8B-Instruct --max-num-seqs 256 --port 8000
$ python3 run.py --port 8000 --concurrency 1 2 4 8 16 32 64 128 --requests-per-level 512
$ python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct \
--max-running-requests 256 --port 30000
$ python3 run.py --port 30000 --concurrency 1 2 4 8 16 32 64 128 --requests-per-level 512 --json sglang.json
$ python3 run.py --help # every knob, including --api, --input-len, --knee-threshold
Requests are pinned to a fixed output length with ignore_eos, which both engines
accept on their OpenAI-compatible routes as an extension field. Without it, output lengths vary,
batch composition varies with them, and you are measuring a different experiment at every
level.
Prompts are distinct pseudo-random integer strings rather than one repeated prompt. A repeated prompt is served almost entirely out of the prefix cache after the first request, which quietly converts a batching experiment into a caching experiment — that one is lab 04, and it has its own README.
Predict first
- Output tok/s at concurrency 1. Use
βfrom lab 01 and the Llama-3-8B weight stream:β / 15.01 GBtokens per second is the ceiling, and it is derived in §0.4 as about 223 tok/s on H100 spec-sheet bandwidth. - The concurrency at which throughput stops doubling. Predict it from the ridge point rather
than guessing: a decode step's weight GEMMs sit at
I = Bin bf16, so the batch stops being weight-bound somewhere belowI*. - What TTFT p99 will do across the sweep, and whether TPOT p99 will move more or less than TTFT p99 does.
What to expect
The shape the book's derivations predict is this: throughput climbs steeply, bends, and
flattens; TTFT climbs the whole way and then accelerates; TPOT climbs slowly and then jumps. The
climb is batching amortising the weight stream
(§0.4); the
flattening is the point at which it has nothing left to amortise, or the pool has nothing left to
give (§1.3). The bend is
the knee. run.py reports it as the first level whose marginal throughput gain
falls below --knee-threshold of what perfectly linear scaling would have given.
Whether your curve has that shape is the first thing to check, and a curve that does not is a
finding worth chasing.
Use at least 512 requests for the advertised maximum concurrency of 128, report actual active concurrency, and separate ramp-up/drain from a sustained measurement interval. Repeat runs and retain failures; 512 is a starting workload size, not a guarantee of precise p99 estimates.
Finding the knee is easy. Naming its cause is the exercise, and start with these candidates, then inspect compute, launch and host-side limits:
| Cause | Signature in the sweep | Signature in the server log |
|---|---|---|
| Request-count cap reached | throughput flat, TTFT rising linearly with concurrency, TPOT flat | vLLM: Running pinned at --max-num-seqs while
Waiting climbs. SGLang: #running-req pinned at
--max-running-requests, #queue-req climbing. |
| KV pool exhausted | throughput flat or falling, TPOT p99 spiking, TTFT very high variance | vLLM: GPU KV cache usage near 100% and a Preemptions counter
appearing. SGLang: KV cache pool is full. Retract requests. |
| Decode gone KV-bandwidth-bound | throughput flattens smoothly with no preemption anywhere; TPOT rises roughly linearly with concurrency | Nothing unusual. This is the healthy ceiling — the one derived in §0.4, where batching amortises weights but never amortises KV reads. |
| Client-side bottleneck | throughput flat, and server-side Running is well below your concurrency |
The server is not busy. Your Python client, the network, or a proxy is the limit — run the client on another machine and re-check. |
The two vLLM signatures live in one log line, assembled here:
# Format and print output.
log_parts = [
"Avg prompt throughput: %.1f tokens/s",
"Avg generation throughput: %.1f tokens/s",
"Running: %d reqs",
"Waiting: %d reqs",
]
# ...
if self.num_preemptions > 0:
log_parts.append("Preemptions: %d")
log_args.append(self.num_preemptions)
log_parts.extend(
[
"GPU KV cache usage: %.1f%%",
"Prefix cache hit rate: %.1f%%",
]
The Preemptions line is conditional — it only appears once the counter is
non-zero, so its absence is information too. SGLang assembles the equivalent pair into one
message, the running count and pool usage at the front and the queue depth appended at the end:
iter_msg = f" [{batch_iter}]" if LOG_FORWARD_ITERS else ""
msg = f"Decode batch{iter_msg}, #running-req: {num_running_reqs}, {token_usage_msg}"
# ...
msg += (
f"{self._graph_backend_label}: {can_run_cuda_graph}, "
f"gen throughput (token/s): {self.last_gen_throughput:.2f}, "
f"#queue-req: {len(self.scheduler.waiting_queue)}"
)
and its retraction warning is unmistakable when the pool runs dry:
msg_prefix = (
"KV cache pool is full. Retract requests. "
if kv_full_retract_flag
else "Testing retraction. "
)
msg_details = f"#retracted_reqs: {len(retracted_reqs)}, #new_tokens_gained: {new_token_gained}"
if mamba_num_gained is not None:
msg_details += f", #mamba_num_gained: {mamba_num_gained}"
if kv_full_retract_flag:
msg_details += (
f", #new_token_ratio: {old_ratio:.4f} -> {new_token_ratio:.4f}"
)
logger.warning(msg_prefix + msg_details)
A retraction is not a crash and not a leak. It is the scheduler telling you it admitted more than it can carry, and that you are now paying to recompute prefills you already paid for. If your knee coincides with retraction warnings, the fix is capacity — fewer resident tokens per request, or a smaller admission cap — not a bigger batch.
Both engines cap the batch twice: by request count and by tokens per iteration. Raising
--max-num-seqs (vLLM) or --max-running-requests (SGLang) without KV
cache to back it converts a throughput knob into a preemption generator. Sweep the client-side
concurrency first, find the knee, and only then decide whether the cap is the thing to move.
Exercises
- Run the default sweep and identify the knee. Then, with the server log open, classify it against the four causes above. Quote the log line you used.
- Predict, then verify: re-run with
--max-num-seqs 8on the server. Predict what happens to the throughput curve, to TTFT p99, and to the position of the knee, then check all three. - Do the arithmetic. At your knee concurrency
B, compute the decode step's weight GEMM intensity (I = Bin bf16) and compare it against the ridge point you measured in lab 01. Is the batch compute-bound at the knee? If not, what is the binding resource? - Re-run with
--input-len 4096and the same concurrency levels. The knee should move. Predict which direction and by roughly how much before you run it, using the KV-per-token figure for Llama-3-8B. - Read the source. In
vllm/v1/metrics/loggers.py, thePreemptionsline is appended only when the counter is above zero. Given that, what can you conclude from a log that showsGPU KV cache usage: 99.8%and noPreemptionsfield at all?
Answers
- Not answerable in advance — the classification is the exercise. What is answerable is the discipline: "it saturated" is not a cause. Every knee has a named resource behind it, and the four rows are not exhaustive: compute saturation, launch overhead, CPU preprocessing, client saturation and thermal throttling can also produce knees.
- Throughput flattens at concurrency 8 and stays flat however high you push the client, since the server will not run more than eight rows. TTFT p99 then rises roughly linearly with client concurrency, because every extra client is pure queueing. The knee does not merely move — it changes kind, from a bandwidth knee to an admission-cap knee, and the KV-usage line stops being informative.
- Derived: a decode step's weight GEMMs have
I = Bd/(2B + d), which forB << dis approximatelyBin bf16. On H100 spec-sheet numbers the ridge is 295 for the stated square bf16 GEMM model. This predicts a bandwidth ceiling for that operator, not the bottleneck of the whole server. Distinguish weight traffic, KV traffic, launch overhead, other kernels and host work with counters and a profile; batching alone does not prove the knee is KV bandwidth (§0.4). - The knee moves down, to a lower concurrency. KV bytes per token are
2 · L · h_kv · d_h · b = 131,072bytes = 128 KiB for Llama-3-8B in bf16, so 4096-token contexts hold 8× the KV of 512-token ones. Each row now costs 8× as much pool and contributes 8× the per-step KV read, so both the capacity ceiling and the bandwidth ceiling arrive roughly 8× sooner in concurrency. (Derived; arithmetic.) - That the engine has been running right at the edge of its pool without ever having to evict anyone — which is the ideal operating point, not a warning. High KV usage is a utilisation figure; preemption is the failure. Tuning to keep the first high and the second absent is the actual goal, and it is why the two are logged separately.
Key takeaways
- Concurrency is the one dial. Throughput, TTFT and TPOT are three views of the same setting, and any two of them can be made to look good by sacrificing the third.
- The knee is easy to find and useless on its own. Its cause — request cap, KV pool, KV bandwidth, or a slow client — is the result, and each one has a distinct log signature.
- Closed-loop concurrency answers "what do N waiting users see?", not "what happens at R requests per second". Do not report the first as if it were the second.
- Repeated prompts turn this into a prefix-cache benchmark; unpinned output lengths turn each
level into a different experiment. Both defaults in
run.pyexist to prevent a specific wrong answer. - Sustained retraction or preemption means the admission policy is writing cheques the KV pool cannot cash. The fix is capacity, not a wider batch.
Replay a scheduler trace without a GPU
The server benchmark above measures a real deployment. This companion lab needs only Python 3.10 or newer and the standard library. It answers a different question: can a scheduler account for every input token, reserved page, cancellation and cleanup event on a fixed workload? Download scheduler_sim.py, then run these commands from its directory. No model download, API key, engine install or GPU is used.
python scheduler_sim.py --output continuous.json
python scheduler_sim.py --policy serial --output serial.json
python -m json.tool continuous.json
Every dispatch lasts one synthetic tick regardless of its token count. Report synthetic TTFT, queue delay and dispatch count, not milliseconds or tokens per second. The serial policy admits one request at a time; the continuous policy admits up to max_active and mixes decode rows with chunked prefills. Neither implements padded static batch formation. The model exposes reservation and lifecycle differences without pretending to measure attention bandwidth.
Input and output contracts
Supply --trace workload.jsonl to replay your own workload. Each nonblank line is one JSON object in either of the following forms; the built-in workload is reproduced here in full. Timestamps are nonnegative integer boundaries, prompt and requested output lengths are positive integers, and all request IDs must be unique for the entire trace. A cancellation must reference a request arriving at or before its timestamp. Unknown fields and booleans masquerading as integers are rejected.
{"time": 0, "event": "arrival", "id": "long", "prompt_tokens": 8, "output_tokens": 4}
{"time": 0, "event": "arrival", "id": "short", "prompt_tokens": 2, "output_tokens": 2}
{"time": 1, "event": "arrival", "id": "cancel-me", "prompt_tokens": 6, "output_tokens": 4}
{"time": 2, "event": "cancel", "id": "cancel-me"}
{"time": 2, "event": "arrival", "id": "late", "prompt_tokens": 3, "output_tokens": 2}
The output is one JSON document with schema_version, time_unit, the full config, a per-request table, an ordered events trace and summary counts. Each request records arrival, admission, terminal time, output timestamps, queue ticks, TTFT ticks and individual ITL ticks. TTFT is null when no output was produced; it is never manufactured as zero for cancelled or rejected requests. A one-token response has an empty ITL list because there is no inter-token interval.
Each snapshot includes logical block tables, allocated and free counts, active reservation credits, draining pages and remaining admission headroom. Snapshots occur after boundary events and admission but before dispatch. Dispatch events list the request, phase and input-token count for every participating row. The subsequent token and terminal events occur at the completion boundary. Arrival, cancellation and release events make missing work distinguishable from missing instrumentation.
Expected built-in result
| Request | Output ticks | TTFT ticks | Terminal state |
|---|---|---|---|
| long | 4, 5, 6, 7 | 4 | finished at 7 |
| short | 1, 2 | 1 | finished at 2 |
| cancel-me | none | null | cancelled at 2 |
| late | 4, 5 | 2 | finished at 5 |
The default continuous run performs seven dispatches and processes 19 input tokens: 11 for long, three for short, four for late, and one prefill token wasted on cancel-me. Three requests finish, one cancels, none rejects. All eight pages are reusable by tick eight, one tick after the last response completes. That final drain time is not the last request's response latency.
from scheduler_sim import demo_trace, simulate
report = simulate(demo_trace())
assert report["time_unit"] == "synthetic_tick"
assert report["requests"]["long"]["token_times"] == [4, 5, 6, 7]
assert report["requests"]["cancel-me"]["ttft_ticks"] is None
assert report["summary"] == {
"drained_at_tick": 8, "dispatches": 7,
"processed_input_tokens": 19, "free_blocks": 8,
"finished": 3, "cancelled": 1, "rejected": 0,
}
assert report == simulate(demo_trace())
print("deterministic trace and complete cleanup: OK")
Experiments with an answer you can check
- Reservation versus allocation. Set capacity to two four-token pages and submit a one-token prompt requesting eight outputs, followed by a one-token prompt requesting one output. The first request reserves both pages before allocating either, so the second queues despite a nonempty physical free list. Inspect
reserved_blocksandadmission_headroom, not onlyfree_blocks. - Cancellation versus reuse. Use a one-page pool, two one-token prompts with one-token outputs, and
--release-delay 3. The first response finishes at tick one, but the second cannot be admitted until tick four. Reducing the delay to zero allows admission at tick one. Delayed release is a storage-lifetime constraint, not extra request computation. - Boundary races. Cancel a one-token request at tick one after it was dispatched at tick zero. Completion wins because dispatch results commit before boundary arrivals and cancellations. Cancel the same request at tick zero and it never runs. The trace must show
cancel_ignoredin the first case andcancelledin the second. - Decode priority. Reduce
--token-budgetto one while multiple requests are active. A decode row can consume the entire budget, delaying prefill. Rotation shares decode opportunities across the current rows; it does not erase the phase-priority tradeoff. Compare each request's token timestamps instead of averaging away the delayed request. - Failure conservation. Submit a prompt that cannot fit even alone. It must become
rejected, not remain queued forever or acquire negative headroom. Add a duplicate arrival ID and confirm the input is rejected before simulation, preventing ambiguous cancellation routing across ID reuse.
Verification and limits
From a repository checkout run python site/test_inference_scheduler.py. Tests execute both printed Python examples, the CLI JSONL roundtrip, deterministic boundary cases, 60 seeded scheduler workloads and 5,000 randomized allocator operation choices against a separate logical-token oracle. See the ownership lesson for shared partial prefixes, synchronous copy-on-write, transfer pins and failure-atomic allocation.
No real request traces or performance measurements are bundled. Replay accepts arrivals, intended lengths and cancellations, not worker kernel traces. The scheduler has no preemption, cache-hit-aware admission, distributed routing, deadline objective or prefill/decode-specific cost curve; the allocator's shared-prefix operations are tested separately from scheduling. Keep the JSON configuration with any result, and retain these exclusions when discussing what an experiment demonstrates. Extend one policy at a time and rerun the conservation tests before adding a performance interpretation.
Preemption, replay and exactly-once emission
The previous arrival simulator deliberately reserves worst-case capacity and has no victim-selection policy. This separate lifecycle exercise adds explicit preemption and full recomputation without changing that scheduler's results. Download scheduler_faults.py beside scheduler_sim.py. Both use only the Python standard library. Run python scheduler_faults.py --output faults.json for a deterministic JSON event trace; run python site/test_inference_faults.py from the repository for the executable contracts.
Three histories that must not be confused
A request retains its original prompt, the tokens already sent to the client, and the input rows whose KV has been materialized. For a prompt [10, 11, 12] and sampled outputs [20, 21], the committed KV rows are [10, 11, 12, 20]. Token 21 has been sampled but has not yet been fed back. Rebuilding all five tokens now and feeding 21 again on the next decode step would duplicate an input row. Re-emitting 20 and 21 would instead duplicate the client-visible output stream. These are different bugs.
RecomputeRequest keeps prompt and emitted-token history on the host. preempt() releases its page-table ownership and marks it preempted; it does not erase that history. resume() rebuilds the prompt plus all but the most recent sampled output, then makes the request active. The next sample() first commits the pending sampled token and records one new output. The supplied token is a hypothetical model result: this module does not implement a model, sampling algorithm, RNG restoration or logits evaluation.
| Operation | Client output history | Materialized KV rows | New replay work |
|---|---|---|---|
| Initial prefill | [] | [10, 11, 12] | 0 replay rows; 3 initial rows |
| Sample 20 | [20] | [10, 11, 12] | 0 |
| Sample 21 | [20, 21] | [10, 11, 12, 20] | 0 |
| Preempt | [20, 21] | No request-owned rows | 0 |
| Resume | [20, 21] | [10, 11, 12, 20] | 4 |
| Sample 22 | [20, 21, 22] | [10, 11, 12, 20, 21] | 0 |
from scheduler_faults import PagedKV, RecomputeRequest
pool = PagedKV(capacity=4, block_size=2)
request = RecomputeRequest(pool, "r", [10, 11, 12])
assert request.resume()
request.sample(20)
request.sample(21)
request.preempt()
assert len(pool.free) == 4
assert request.resume()
assert request.recomputed_rows == 4
assert pool.read("r") == [10, 11, 12, 20]
request.sample(22)
assert request.outputs == [20, 21, 22]
request.cancel()
assert len(pool.free) == 4
Admission failure is not partial progress
Reconstruction needs enough pages for the entire committed prefix in this model. If allocation fails halfway through, resume() releases every partially reconstructed page, leaves the request queued or preempted, returns False, and does not increment the successful-replay counter. A failed decode allocation similarly emits no new token. The test suite deliberately leaves one competing page resident so a two-page replay starts allocating but cannot finish, then verifies that no page leaks and a later retry succeeds.
This atomic operation is a teaching simplification, not chunked-prefill scheduling. It counts successfully reconstructed input rows, not wasted partial reconstruction attempts or FLOPs. A real scheduler may checkpoint reconstruction progress across several token-budget-limited dispatches. Its accounting must distinguish useful rows, failed work, retained partial KV and client-visible progress. Attention over a warm suffix also has different work from a cold prompt of the same suffix length; token counts alone do not establish a swap-versus-recompute speedup.
What the model proves, and what it does not
The invariant is exact: while active, the page-table read equals prompt plus emitted outputs excluding the newest output. Every emission event has one increasing output index, and replay produces no emission events. Cancellation is idempotent; a cancelled request cannot resume. Forty fixed random seeds exercise 2,000 preempt/sample operation choices against an independently retained output-history oracle.
The model does not prove output equality after an actual engine replay. Real equivalence additionally depends on positions, attention masks, model and adapter identity, numeric execution, sampling/RNG state and the exact accepted history under speculative decoding. Nor does it prove scheduling fairness: a victim-selection policy can keep preempting the same request forever. To evaluate such a policy, add a bounded-arrival workload, a starvation metric and an explicit progress assumption rather than inferring fairness from memory conservation. See the scheduler's recomputation contract.
Asynchronous transfer faults and fenced cleanup
A request is cancelled in the control plane while a transport still holds its source and destination addresses. Returning those pages to the free list immediately is unsafe: a delayed write could corrupt another request. TransferManager models this lifetime mismatch using the existing allocator's transfer pins. Source and destination use one pool so every page is observable; real P and D workers have separate address spaces, allocators and failure detectors.
State machine and publication boundary
begin(source, room) snapshots the source rows, pins their storage, reserves private destination pages and pins those pages too. If reservation fails, all newly allocated pages and the source pin are rolled back. The destination is unpublished: read() raises until complete() copies the snapshot and changes the transfer to ready. This is an atomic control-plane model of a completion acknowledgement, not a simulation of bytes arriving over a network.
| Current state | Event | Next state | Storage rule |
|---|---|---|---|
| in_flight | Completion acknowledged | ready | Unpin both sides; destination owner retains published KV |
| in_flight | Cancel, timeout, producer or consumer failure | draining | Remove destination request ownership but retain both transport pins |
| draining | Late completion notification | draining | Ignore publication; no storage becomes reusable |
| draining | No-further-access drain acknowledged | drained | Unpin both sides; collect only ownerless pages |
| ready | Consumer releases output | released | Remove destination ownership |
A cancellation arriving after successful publication loses this particular state-machine race: abort() returns False, and the caller releases the already published output through release_output(). An abort that arrives first prevents subsequent publication. A duplicate completion, duplicate drain acknowledgement or unknown transfer key returns False without touching memory. This explicit event ordering makes the example reproducible; it is not a claim that every engine resolves races identically.
Worked cancellation ledger
Take four pages of two rows each. A three-row producer occupies two pages; destination reservation occupies the other two. The producer cancels and drops its owner. The transfer aborts and drops the destination owner. There are now zero request-owned pages but also zero free pages: four pages remain pinned by transport. Even an attempted reconstruction must fail until the transport is known to have stopped accessing them.
from scheduler_faults import PagedKV, RecomputeRequest, TransferManager
pool = PagedKV(4, 2)
request = RecomputeRequest(pool, "r", [1, 2, 3])
assert request.resume()
manager = TransferManager(pool)
old = manager.begin("r", "room-a")
request.preempt()
assert manager.abort(old, "consumer_failure")
assert len(pool.free) == 0
assert not request.resume()
assert not manager.complete(old)
assert manager.acknowledge_drained(old)
assert len(pool.free) == 4
assert request.resume()
new = manager.begin("r", "room-a")
assert new != old
assert not manager.complete(old)
assert manager.complete(new)
assert manager.read(new) == [1, 2, 3]
manager.release_output(new)
request.cancel()
assert len(pool.free) == 4
Room reuse and stale callbacks
A room name is a routing label, not a lifetime identity. The manager rejects a second live transfer for the same room and allows reuse only after drain or output release. Each attempt receives a monotonically increasing key. A late callback carries that old key, never only the room string, so it cannot publish into or release the newer attempt's pages. The transfer counter advances even when destination reservation fails; skipped generations are harmless.
The model retains terminal records as tombstones, and one manager owns its private owner/pin namespace for a pool. Production systems cannot grow tombstones forever. They need an explicit callback-expiry and garbage-collection policy, an incarnation identifier that survives worker restarts, and fencing at the transport or allocation layer. Merely ignoring a stale Python callback cannot stop an old RDMA write into an address that has already been reused. That is why this model permits reuse only after an externally justified drain acknowledgement.
Fault matrix and liveness assumptions
The executable suite applies the same retention contract to cancellation, timeout, producer failure and consumer failure. It also tests duplicate room names, stale and duplicate callbacks, publication before read, partial-allocation rollback, source mutation during transfer, and recomputation blocked by pinned pages. Thirty seeded randomized runs make 3,000 lifecycle operation choices and compare each published snapshot with a separate token-history oracle. Pins freeze the source view: appending to a shared or pinned partial tail invokes copy-on-write rather than changing the bytes a transfer is expected to read.
Safety is unconditional within the API contract; eventual cleanup is conditional. If no trustworthy drain acknowledgement ever arrives, the model intentionally retains storage. A timeout alone is not proof that a remote writer stopped. A production recovery path might revoke registrations, reset a transport context, destroy a worker incarnation or quarantine pages until hardware completion, depending on the backend. The exercise supplies none of those mechanisms, no actual process crash, and no GPU/network measurement. See P/D failure contracts before applying the toy result to an engine.
Exercises with observable answers
- Remove a drain acknowledgement. Stop immediately after
abort(). Expected: the request is no longer active, but four pages remain allocated. A test expecting all pages free here would encode a use-after-free bug. - Reverse completion and abort. Run completion first, then abort. Expected: the destination is readable, abort returns false, and output release frees destination ownership. Reverse the order and the read remains forbidden until that aborted attempt is drained; it never becomes ready.
- Append during transfer. Transfer
[1, 2, 3]with two-row pages, then append 4 at the source. Expected: completion still publishes[1, 2, 3]; the source's partial tail has been copied. Repeat with no spare page and verify that append fails without changing either snapshot. - Reuse a room. Drain attempt A, start B in the same room, then deliver A's completion again. Expected: B remains in flight and its allocations are unchanged. A room-only lookup would fail this experiment.
- Interpret the built-in JSON. Expected: outputs
[20, 21, 22], received rows[10, 11, 12, 20, 21], four successfully recomputed rows, four pages retained before the first drain acknowledgement, and all eight pages free at the end. These are exact accounting values, not latency estimates.