feat(chat): settle a cancelled streamed generation on the text it posted

A worker that follows the cancel is never penalised for it, and a client cannot work a volunteer for free: the Horde measures the turned-in work through the progress posts, so the cancel pays and charges on it.

- The native cancel route settles each in-flight generation of a streamed chat request through `TextProcessingGeneration.settle_cancelled`: the row is locked and reloaded, the content the worker posted (`read_posted_progress`, joined from the retained stream in `seq` order, reasoning excluded, the lease's byte count when the stream lost the entries) is priced by the output estimate, and `ProcessingGeneration.cancel` pays the worker and charges the requester once without a dropped job, whichever of submit or cancel commits first; a late submit is refused as `AbortedGen`
- `processing_gens.settled_character_count` records the count the settlement priced, written before `cancel` commits, so cancels can be analysed while the posted text is not kept; `get_things_count` reads it after a stored reply, so a stored reply stays authoritative; the status route does not show it
- `cancel_progress_streams` sets `cancelled` on every lease that still exists in one watched transaction and appends each generation's ended marker, best effort, so a cancel succeeds without Redis and the leases stay for the acknowledgement
- `chat_progress.lua` returns the lease's `cancelled` flag with `accepted` and `duplicate` replies; the progress route's 200 body (`ChatProgressAccepted`) carries it beside `accepted_seq`, so a worker stops generating and does not submit
- Requests that do not stream are paid in full on submit, since the Horde does not measure partial work for them; maintenance mode's abort does not flag leases, since it lets in-flight jobs settle
- Tests cover the flag, the ended marker and the kept lease, the ordered join and the byte fallback, a Redis failure, payment and charge on posted content, the floor before any post, the refused late submit, the untouched stale pass, the recorded count and the unchanged non-streaming cancel; the references, explanation and how-to describe the acknowledgement and the settlement
- The explanation shows the progress lease's states and the settlement of a cancel, `docs/how-to/serve_chat_jobs.md` opens with the worker's pop loop as a diagram keyed to its steps, and `docs/explanation/kudos_accounting.md` describes the settlement of a cancelled streamed generation
This commit is contained in:
tazlin committed 2026-10-30 23:46:09 -04:00
1 parent 34ffc4d7bf
commit ffb26ba6b5
15 files changed
+690 -37

No files matched your search

+29 -3
View File
@@ -299,6 +299,8 @@ each tool call's name and arguments) divided by four, floored at 20 tokens and c
([ADR 0027](../decisions/0027-price-chat-generations-by-the-text-output-estimate.md),
[Pricing](../reference/chat_requests.md#pricing)). A short reply to a large `max_tokens` is charged for the short
reply. The basis is computed in one function and stored on every generation as `charge_basis: output_estimate`.
A streamed generation the client cancels is settled on the same basis on the content its worker posted before the
cancel, which the Horde measured, so a volunteer worker is paid for what it turned in.
Pricing on the token counts workers report was the alternative
([ADR 0028](../decisions/0028-price-chat-generations-on-reported-completion-tokens.md)). It is fairer per reply and
@@ -401,9 +403,33 @@ the lowest kudos priority, and per-source caps on the front, which aggregate use
A streamed request without tools whose `response_format` is absent or `text` gets its content as the worker posts
progress, one chunk per post, about one a second. The batches are kept in a retained Redis stream per request, cheap
and lost on failover
([ADR 0024](../decisions/0024-stream-chat-deltas-through-a-retained-redis-stream.md)); the alternative that was not
built is Postgres progress rows, durable and paid for in primary database writes on the connection pool pops and
submits share ([ADR 0025](../decisions/0025-stream-chat-deltas-through-postgres-progress-rows.md)).
([ADR 0024](../decisions/0024-stream-chat-deltas-through-a-retained-redis-stream.md)). Postgres progress rows were the
alternative, durable and paid for in primary database writes on the connection pool pops and submits share
([ADR 0025](../decisions/0025-stream-chat-deltas-through-postgres-progress-rows.md)).
A lease in Redis, written at pop, authenticates a generation's posts, so the progress route does not read Postgres.
The lease's life is the streamed generation's life as the worker sees it
([Streaming progress](../reference/chat_requests.md#streaming-progress)):
```mermaid
stateDiagram-v2
[*] --> Leased: pop of a streamed job
Leased --> Leased: post accepted (200)<br/>or resynced after 409
Leased --> Submitted: worker submits the whole reply
Leased --> Ended: generation faults
Leased --> Cancelled: client or front cancels
Leased --> PostsStopped: 413 cap reached
PostsStopped --> Submitted: worker still submits
Submitted --> [*]: lease deleted, stream left to expire
Ended --> [*]: ended marker, lease deleted
Cancelled --> [*]: settled on the posted text<br/>next ack carries cancelled true<br/>worker stops and does not submit
```
A cancel settles the generation on the content its worker posted before it, so the worker is paid for that text and
the requester charged for it. The settlement does not count a dropped job, so a worker that stops on the flag is
never penalised for a client leaving. The front relays entries as they arrive and reconciles them with the final
reply: a reply equal to or extending the relayed text completes the stream, and one that diverges ends it with an
error event.
A request with tools or a non-text `response_format` waits for the whole reply as a non-streamed request does, then
sends it as
+7
View File
@@ -166,6 +166,13 @@ business transaction commits its job state and emitted facts together. The event
it does not by itself make every producer retry idempotent. Transfers accept a caller idempotency key and validate a
replay. Other producers still depend on their existing job-state transaction to prevent duplicate settlement.
A streamed chat generation that its requester cancels is settled through the same event, on the content its worker
posted before the cancel, which the Horde measured. The worker owner is credited and the requester debited for that
content, and the cancel does not count as a dropped job for the worker. A later submit for the settled generation is
refused, so the generation settles once. The Horde does not measure part of a non-streamed generation, so it settles
on submit
([Chat requests: pricing](chat_requests.md#pricing-by-the-text-output-estimate)).
## Projection: durable work queue
In ledger mode, rows start with `applied = false`. The periodic projector:
+2 -1
View File
@@ -479,7 +479,8 @@ The front differs from OpenAI on purpose in these points:
A client request does not change Horde state beyond the request itself. Point the client's base URL back at its
previous provider to stop using the Horde. A request the client abandons by closing the connection is cancelled on
the Horde.
the Horde. A streamed request cancelled while a worker generates is charged for the content the worker streamed
before the cancel, priced as a reply of that length.
## Related
+43 -6
View File
@@ -37,6 +37,38 @@ For why chat travels this way, see [Chat requests on the Horde](../explanation/c
requests as a client, see [Send chat completions through the OpenAI-compatible front](send_chat_completions.md).
To run this end to end with koboldcpp for the first time, follow [Serve your first chat job with koboldcpp](../tutorial/first_chat_worker.md).
The steps below build one pop loop. A pass through it, with the step that covers each part:
```mermaid
flowchart TD
P[Pop with the chat declaration<br/>step 1] --> K{chat: true?<br/>step 2}
K -->|no| T[Serve the text job<br/>step 8]
K -->|yes| M{messages_url?}
M -->|yes| FM[Fetch the messages<br/>within 120 s, step 5]
M -->|no| IM{Image URLs?}
IM -->|yes| FI[Fetch each image<br/>step 5]
IM -->|no| R
FM --> R[Render and fit the conversation<br/>steps 3 and 4]
FI --> R
R -->|does not fit, cannot render| F[Fault with a reason<br/>step 7]
R --> G[Generate]
G --> S{stream_progress: true?}
S -->|yes| PP[Post progress about once a second<br/>step 6]
PP -->|cancelled true| X[Stop generating<br/>and do not submit]
PP -->|404, 413, repeated 503| G2[Stop posting, keep generating]
PP -->|generation finished| SB
G2 --> SB
S -->|no| SB[Submit the structured reply<br/>step 6]
G -->|backend fails| F
SB -->|400 refusal| F
SB --> P
F --> P
T --> P
X --> P
```
A failed fetch is a fault too, sent without `fault_reason`.
## 1. Declare chat on pop
Add five fields to every pop body, beside the usual `name`, `models`, `max_length` and `max_context_length`
@@ -349,7 +381,10 @@ apikey: <worker owner's key>
- `seq` is 1 for the job's first post and one more for each post after it. Send a post only after the previous one
was answered. A lost answer is retried with the same `seq` and content, which returns the same
`{"accepted_seq": n}` and does not append an entry.
`{"accepted_seq": n, "cancelled": false}` and does not append an entry.
- `"cancelled": true` in a `200` means the client cancelled the request: stop generating and do not submit. The
delta in that post was accepted and is not needed again. The job was settled at the cancel on the content
posted before it, so the worker is paid for that content without a dropped job.
- `409 ChatProgressOutOfSequence` carries `expected_seq`. Changed content under an accepted `seq` is refused the same
way, so never rewrite text already posted.
- `404 ChatProgressNotStreaming`, `413 ChatProgressCapReached` (1000 posts, or 16 bytes per `max_length` token) and
@@ -361,7 +396,7 @@ apikey: <worker owner's key>
- Submit the whole reply as above once the last post was answered. The submit is what the request stores and charges,
and it ends the job's posts.
Verification: each post returns `200 {"accepted_seq": n}`, and the request's stream
Verification: each post returns `200 {"accepted_seq": n, "cancelled": false}`, and the request's stream
`horde:chat:stream:<request-id>` on the chat signal database (Redis logical database 7) holds one entry per accepted
post with `gen`, `seq`, `content`, `reasoning_content` when the post carried reasoning, `worker` and `model`. Covered
by `tests/integration/test_chat_progress.py` and `tests/unit/test_chat_progress.py`; the contract is in
@@ -405,10 +440,12 @@ after `400 ChatSubmitInvalid` or `400 ChatModelMismatch` counts as an aborted jo
<HORDE_URL>/api/v2/workers/name/<worker-name>` shows `nonconformant_replies`; a count that keeps rising means the
backend does not keep a guarantee the worker declares.
A job the requester cancels while the worker holds it is settled at the cancellation, as a text job is: the worker is
rewarded and the requester charged for dispatched work
([ADR 0027](../decisions/0027-price-chat-generations-by-the-text-output-estimate.md)). A submit for a cancelled job,
or for one whose TTL ran out, returns `400 AbortedGen`.
A streamed job the requester cancels while the worker holds it is settled at the cancellation on the content the
worker posted, priced by the output estimate with its floor of 20 tokens: the worker is rewarded and the requester
charged for that content, and the job does not count as dropped
([ADR 0027](../decisions/0027-price-chat-generations-by-the-text-output-estimate.md)). A submit for a settled job,
or for one whose TTL ran out, returns `400 AbortedGen`. A job that does not stream is not settled at the cancel;
submit it as usual and it is rewarded for the reply.
Verification: the faulted submit returns `{"reward": 0}`, and the next pop by any eligible worker receives a new job
for the same request. Covered by `test_a_faulted_chat_submit_needs_neither_generation_nor_message` and
+1 -1
View File
@@ -176,7 +176,7 @@ reasoning, and does not send it otherwise. After exposure:
| Final reply equal to the relayed text | The finish chunk, the usage chunk when asked for, and `[DONE]` |
| Final reply starting with the relayed text | The rest as one content chunk, then the finish chunk |
| Final reply not starting with the relayed text | SSE error event `ChatStreamDiverged`, then `[DONE]` |
| An entry of another `gen`, the pinned `gen`'s `ended` marker, or a `seq` out of order | SSE error event `ChatStreamDiverged`, then `[DONE]`; the front cancels the request |
| An entry of another `gen`, the pinned `gen`'s `ended` marker (also written when the request is cancelled through the native route), or a `seq` out of order | SSE error event `ChatStreamDiverged`, then `[DONE]`; the front cancels the request |
| The request faults | SSE error event `ChatRequestFaulted`, as for a buffered stream |
A restarted attempt starts the reply over, so it is never spliced into a choice the client holds part of
+39 -8
View File
@@ -618,7 +618,10 @@ jobs timed out.
A request is `done` when it is `faulted`, or when no job is waiting or processing and either all requested results
are stored or the remaining jobs were cancelled. Cancellation retains partial results and lets in-flight jobs finish,
but faults after cancellation do not restart jobs. A cancelled request can therefore be `done` without a generation;
the front reports `ChatRequestNoGeneration` when it has no reply to deliver.
the front reports `ChatRequestNoGeneration` when it does not have a reply to deliver. The cancel route also tells a worker
streaming progress for the request to stop (see [Streaming progress](#streaming-progress)). For a streamed
request it settles each in-flight generation on the content its worker posted (see [Pricing](#pricing)); the
generation is faulted as cancelled, does not count toward the worker's aborted-job threshold and is not restarted.
Faulted generations do not count as results. A job's fault and its restart are committed together, as are a pop's
taken job and its first generation. A worker-reported image fault that is not restarted and leaves nothing waiting
@@ -743,6 +746,21 @@ submitted, `native_finish_reason`, `prompt_tokens`, `completion_tokens`, `reason
`queue_wait_seconds`, the seconds from
the request's creation to the generation's assignment. The text status route does not return any of them.
A streamed generation still in flight when the client cancels is settled at the cancel on the content its worker
posted, under the same output estimate: `read_posted_progress` (`horde/chat_progress.py`) joins the generation's
`content` entries from the request's stream in `seq` order, reasoning aside, and
`TextProcessingGeneration.settle_cancelled` prices those characters, pays the worker, charges the requester with
the usual extra burn and faults the generation as cancelled. The posted content is measured by the Horde, so it is
a charge input where a worker-reported count is not. A cancel before any post pays the floor of 20 tokens. When
the stream does not hold the generation's entries, the lease's accepted `bytes` stand in for the characters;
they count a multibyte character more than once and include reasoning, so the rare fallback leans toward the
worker. A Redis failure settles on the floor. The settlement records the character count it priced in the
generation's `settled_character_count`, which is null on every other generation and is not shown on the status
route. The posted content is not stored as the generation's reply, so the
status does not list a generation for it, and `charge_basis` and `finish_reason` stay null on the cancelled
generation. The Horde does not measure partial work on a request that does not stream: its worker submits after the
cancel and is paid for the reply as usual.
## Chat model list
`GET /v2/status/models?type=text&chat=true` lists only the text models that at least one online chat worker serves,
@@ -780,8 +798,9 @@ still submits the whole reply to the submit route.
| `delta.content` | The text since the previous post; empty only when `delta.reasoning_content` is set |
| `delta.reasoning_content` | Optional: the reasoning since the previous post, never part of `delta.content` |
A delta with neither non-empty `content` nor non-empty `reasoning_content` returns `400 BadRequest`. Reasoning counts
toward the byte cap with the content. An accepted post returns `200` with `{"accepted_seq": <seq>}`. A post that
A delta whose `content` and `reasoning_content` are both empty or absent returns `400 BadRequest`. Reasoning counts
toward the byte cap with the content. An accepted post returns `200` with `{"accepted_seq": <seq>, "cancelled":
<bool>}`; `cancelled` is `true` once the request was cancelled. A post that
repeats the last accepted `seq` with the same content and reasoning returns the same body and does not append an entry, so a worker retries a post whose answer it lost by sending it
again unchanged.
@@ -796,7 +815,7 @@ again unchanged.
When a generation carrying the hint is assigned, the pop writes the lease `horde:chat:lease:<generation id>` on the
chat signal database: a hash of `request`, `worker`, `model`, `owner` (the owner's user id), `key` (a SHA-256 of the owner's
stored key hash), `max_bytes` and `fake`. It expires `CHAT_PROGRESS_LEASE_GRACE_SECONDS` (90) after the job's TTL,
stored key hash), `max_bytes`, `fake` and, once the request is cancelled, `cancelled` (`1`). It expires `CHAT_PROGRESS_LEASE_GRACE_SECONDS` (90) after the job's TTL,
which covers the stale-generation thread's 60 second tick. The route reads only the lease and does not read Postgres; a key reset
during the job therefore takes effect for posts only once the lease ends. A fake generation's posts are answered as a
real one's and not appended.
@@ -818,8 +837,19 @@ one. Caps, in `horde/chat_progress.py`:
A successful submit deletes the lease and leaves the stream to expire, so a reader reconciles what it relayed with
the final reply. When a generation with a lease faults, on a faulted submit or in the stale-generation thread, the
stream gets an entry with `gen` and `ended: 1` and the lease is deleted. The route never publishes on the
completion signal channel. Writing and deleting the lease and the `ended` entry are best effort: a Redis failure is
logged and never fails the pop, submit or fault.
completion signal channel.
When the cancel route claims a streamed request's cancellation, it sets `cancelled` on the lease of each generation
still in flight and appends that generation's `gen` and `ended: 1` entry, in one transaction that writes only leases
that still exist. The leases are kept, so the worker's next post is still accepted, and its `200` carries `cancelled:
true`; the delta it carries was generated before the worker read the flag and is harmless after the ended marker.
The worker then stops generating and does not submit. Before flagging the leases, the route settles each of those
generations on the content the stream holds for it (see [Pricing](#pricing)), so a submit that arrives after the
cancel, from a worker that did not post again, returns `400 AbortedGen`. Maintenance mode's abort does not flag the leases, since it
lets in-flight jobs settle. A fake generation's lease is flagged without an `ended` entry.
Writing, flagging and deleting the lease and the `ended` entry are best effort: a Redis failure is logged and never
fails the pop, submit, fault or cancel.
## Completion signal
@@ -859,8 +889,9 @@ false), `accepts_prompt_jobs` (boolean, not null, default true), `chat_features`
`chat_conformance` (varchar, not null, default `strict`), `chat_tools_temperature` (double precision) and
`chat_messages_offloaded` (boolean, not null, default false); and
`workers.chat_tools_max_temperature` (double precision); and `waiting_prompts.chat_fault_reason` (varchar 1000) and
`chat_fault_rc` (varchar 32), `processing_gens.chat_refusal_rc` (varchar 32) and `workers.nonconformant_replies`
(integer, not null, default 0). Every column is nullable or has a constant default, so no table is rewritten. The
`chat_fault_rc` (varchar 32), `processing_gens.chat_refusal_rc` (varchar 32) and `settled_character_count`
(integer), and `workers.nonconformant_replies`
(integer, not null, default 0). Every column is nullable or has a constant default, so the migration does not rewrite a table. The
unique partial index `ix_waiting_prompts_user_operation_key` on `(user_id, operation_key) WHERE operation_key IS NOT
NULL` is built with `CREATE UNIQUE INDEX CONCURRENTLY`, which cannot run inside a transaction block; a failed
build leaves an invalid index. `db.create_all()` does not add columns to existing tables.
+5
View File
@@ -1182,6 +1182,11 @@ class TextModels(v2.Models):
"accepted_seq": fields.Integer(
description="The seq the Horde holds, the posted one; also for an identical retry of the last one.",
),
"cancelled": fields.Boolean(
description=(
"True once the request was cancelled. The delta is still accepted; the worker stops generating and does not submit."
),
),
},
)
self.response_model_chat_progress_out_of_sequence = api.model(
+15 -1
View File
@@ -44,6 +44,8 @@ from horde.chat_progress import (
ProgressOutcome,
api_key_digest,
append_progress,
cancel_progress_streams,
read_posted_progress,
read_stream_lease,
)
from horde.chat_signal import ChatEvent, publish_chat_event
@@ -78,6 +80,7 @@ from horde.classes.kobold.chat_request import (
replace_image_urls,
rewrite_message_texts,
serialize_messages,
streams_progress,
verify_chat_reply,
)
from horde.classes.kobold.genstats import (
@@ -641,6 +644,14 @@ class TextAsyncStatus(Resource):
return (wp_status, 200)
if cancellation_claimed:
record_request_cancellation_forecast(request_id=str(wp.id), cancelled_at=cancelled_at)
if wp.is_chat and streams_progress(wp.chat_extras):
# The status above loaded the generations; the ones still pending may hold a lease whose worker reads
# the cancel from its next progress acknowledgement. Each is settled on what it posted before its lease
# is flagged, so the worker is paid for the content the stream holds and the user is charged for it.
pending_procgens = [procgen for procgen in wp.processing_gens if not procgen.is_completed() and not procgen.is_faulted()]
for procgen in pending_procgens:
procgen.settle_cancelled(read_posted_progress(str(procgen.id), str(wp.id)))
cancel_progress_streams([str(procgen.id) for procgen in pending_procgens], str(wp.id))
if wp.is_chat:
publish_chat_event(str(wp.id), ChatEvent.CANCELLED, wp.count_stored_generations())
return (wp_status, 200)
@@ -1595,6 +1606,9 @@ class TextJobProgress(Resource):
`seq` starts at 1 and goes up by one per post. A retry sends the same `seq` and content and is acknowledged
again without appending it twice.
The acknowledgement carries `cancelled`, true once the request was cancelled. The delta is still accepted;
the worker stops generating and does not submit.
Refused with:
- `ChatProgressNotStreaming` (404): the generation is not streamed, already finished or faulted, expired or
@@ -1631,7 +1645,7 @@ class TextJobProgress(Resource):
raise e.ServiceUnavailable("The progress store is unavailable.", rc=CHAT_PROGRESS_UNAVAILABLE_RC) from None
metrics.chat_progress_outcomes.add(1, {"horde.outcome": appended.outcome.value})
if appended.outcome in (ProgressOutcome.ACCEPTED, ProgressOutcome.DUPLICATE):
return {"accepted_seq": appended.seq}, 200
return {"accepted_seq": appended.seq, "cancelled": appended.cancelled}, 200
if appended.outcome is ProgressOutcome.GAP:
return (
{
+11 -4
View File
@@ -4,8 +4,9 @@
-- ARGV[5]: the stream expiry in seconds; ARGV[6]: the approximate MAXLEN the stream is trimmed to; ARGV[7]: the
-- reasoning content, empty when the delta carries none.
-- Returns {outcome, seq}: the accepted seq for 'accepted' and 'duplicate', the expected seq for 'gap', the refused
-- seq for 'over_cap' and 0 for 'no_lease'. Sequence state (seq, bytes, last, last_reasoning) lives in the lease hash
-- and expires with it.
-- seq for 'over_cap' and 0 for 'no_lease'. 'accepted' and 'duplicate' carry a third element, 1 when the request was
-- cancelled and 0 otherwise. Sequence state (seq, bytes, last, last_reasoning) lives in the lease hash and expires
-- with it.
local lease_key = KEYS[1]
local stream_key = KEYS[2]
local seq = tonumber(ARGV[2])
@@ -14,10 +15,16 @@ local reasoning_content = ARGV[7]
if redis.call('EXISTS', lease_key) == 0 then
return {'no_lease', 0}
end
-- A cancelled lease still takes the delta, since the worker may have generated it before it read the flag; the reply
-- carries the flag so the worker stops.
local cancelled = 0
if redis.call('HGET', lease_key, 'cancelled') == '1' then
cancelled = 1
end
local last_seq = tonumber(redis.call('HGET', lease_key, 'seq') or '0')
if last_seq > 0 and seq == last_seq and redis.call('HGET', lease_key, 'last') == content
and (redis.call('HGET', lease_key, 'last_reasoning') or '') == reasoning_content then
return {'duplicate', last_seq}
return {'duplicate', last_seq, cancelled}
end
if seq ~= last_seq + 1 then
return {'gap', last_seq + 1}
@@ -49,4 +56,4 @@ if redis.call('HGET', lease_key, 'fake') ~= '1' then
redis.call('EXPIRE', stream_key, ARGV[5])
end
redis.call('HSET', lease_key, 'seq', seq, 'bytes', total_bytes, 'last', content, 'last_reasoning', reasoning_content)
return {'accepted', seq}
return {'accepted', seq, cancelled}
+132 -6
View File
@@ -16,11 +16,16 @@ delta; an entry appended under a lease without a model leaves it out. The lease
accepted ``seq``, the bytes accepted so far and the last content and reasoning, so the check does not need a second
key or a read of the stream. When a generation with a lease faults,
[`end_progress_stream`][horde.chat_progress.end_progress_stream] appends ``gen`` and ``ended`` (``1``) and deletes the
lease, so a reader learns at once that the attempt it follows is gone.
lease, so a reader learns at once that the attempt it follows is gone. When the request is cancelled,
[`cancel_progress_streams`][horde.chat_progress.cancel_progress_streams] sets ``cancelled`` on each lease it still
has and appends the same marker for each, and keeps the leases, so the acknowledgement of the worker's next post
tells the worker to stop. Before that, [`read_posted_progress`][horde.chat_progress.read_posted_progress] reads the
content each pending generation posted, which the cancel settles the generation on.
Unlike [`horde.chat_signal`][horde.chat_signal], whose messages never carry text, the stream holds generated text. It
lives on the chat signal database, which the front reads, and expires on its own. Writing the lease, deleting it and
the ended marker are best effort: a Redis failure is logged and never fails the pop, submit or fault that wrote them.
lives on the chat signal database, which the front reads, and expires on its own. Writing the lease, deleting it,
flagging it cancelled and the ended marker are best effort: a Redis failure is logged and never fails the pop, submit,
fault or cancel that wrote them.
"""
from __future__ import annotations
@@ -28,6 +33,7 @@ from __future__ import annotations
import enum
import hashlib
import threading
from collections.abc import Sequence
from dataclasses import dataclass
from importlib.resources import files
@@ -102,6 +108,10 @@ LEASE_FAKE_FIELD = "fake"
"""Lease field that is ``1`` for a fake generation, whose posts are acknowledged and not appended so the worker
cannot tell it apart from a real one."""
LEASE_CANCELLED_FIELD = "cancelled"
"""Lease field that is ``1`` once the request was cancelled; absent before. The append script copies it into the
acknowledgement so the worker stops generating."""
LEASE_SEQ_FIELD = "seq"
"""Lease field the append script keeps the last accepted ``seq`` in; absent before the first post."""
@@ -193,6 +203,25 @@ class ProgressAppend:
seq: int
"""The accepted ``seq`` for an accepted or duplicate post; the ``seq`` the generation expects next otherwise."""
cancelled: bool = False
"""Whether the request was cancelled, for an accepted or duplicate post; False for a refused one."""
@dataclass(frozen=True)
class PostedProgress:
"""Represents the content a generation posted before its request was cancelled, as the server measured it."""
content: str
"""The posted content joined in ``seq`` order; empty when the stream no longer holds the generation's entries."""
chars: int
"""The characters the generation is settled on: ``len(content)`` when the stream holds the entries, else the
lease's accepted bytes."""
EMPTY_POSTED_PROGRESS = PostedProgress(content="", chars=0)
"""The posted progress of a generation that posted nothing, or whose progress could not be read."""
APPEND_PROGRESS_SCRIPT: str = files(__package__).joinpath("chat_progress.lua").read_text(encoding="utf-8")
"""The Lua source of the progress append, read from ``chat_progress.lua`` beside this module so that it is
@@ -202,7 +231,8 @@ written and reviewed as Lua. It appends one delta under the generation's lease.
[`CHAT_PROGRESS_STREAM_TTL_SECONDS`][horde.chat_progress.CHAT_PROGRESS_STREAM_TTL_SECONDS] and
[`CHAT_PROGRESS_STREAM_MAX_ENTRIES`][horde.chat_progress.CHAT_PROGRESS_STREAM_MAX_ENTRIES] and the reasoning content,
empty when the delta carries none. Replies with the outcome
and a ``seq``, as [`ProgressAppend`][horde.chat_progress.ProgressAppend] holds them. The sequence state lives in the
and a ``seq``, and the ``cancelled`` flag for an accepted or duplicate post, as
[`ProgressAppend`][horde.chat_progress.ProgressAppend] holds them. The sequence state lives in the
lease hash, so the script reads one key besides the stream it writes and expires with the lease."""
_client_lock = threading.Lock()
@@ -377,8 +407,12 @@ def append_progress(lease: StreamLease, seq: int, content: str, reasoning_conten
reasoning_content,
],
)
outcome, reply_seq = reply
return ProgressAppend(outcome=ProgressOutcome(outcome), seq=int(reply_seq))
outcome, reply_seq, *reply_flags = reply
return ProgressAppend(
outcome=ProgressOutcome(outcome),
seq=int(reply_seq),
cancelled=bool(reply_flags) and int(reply_flags[0]) == 1,
)
def end_progress_stream(generation_id: str, request_id: str) -> None:
@@ -420,6 +454,98 @@ def end_progress_stream(generation_id: str, request_id: str) -> None:
logger.warning(f"Unable to end the delta stream of chat generation {generation_id}: {redis_error}")
def cancel_progress_streams(generation_ids: Sequence[str], request_id: str) -> None:
"""Flag the leases of a cancelled request's in-flight generations and mark each attempt as ended on its stream.
One call covers every generation of the request, so the flags and markers land in one transaction. A generation
without a lease finished, faulted or was not streamed, and is left as it is; a lease is flagged only while it
exists, since setting a field on a deleted lease would recreate it without its fields or TTL. Each flagged lease
is kept until it is released or expires, so the worker's next post is acknowledged with ``cancelled``. A fake
generation's lease is flagged without a marker, since its posts were never appended. Logs rather than raises on a
Redis failure, since a cancel succeeds without Redis; the worker then generates and submits as it would without
the cancel.
Args:
generation_ids: The request's in-flight generations.
request_id: The cancelled chat request.
Side Effects:
Sets ``cancelled`` on each existing lease, and appends ``gen`` and ``ended`` to the stream and refreshes its
TTL for each that is not fake.
"""
if not generation_ids:
return
lease_keys = [chat_lease_key(generation_id) for generation_id in generation_ids]
stream_key = chat_stream_key(request_id)
def flag_live_leases(pipeline: redis.client.Pipeline) -> None:
# The leases are watched, so a submit or fault that deletes one between this read and the write retries the
# transaction, and a deleted lease is not recreated.
fake_flags = [pipeline.hget(lease_key, LEASE_FAKE_FIELD) for lease_key in lease_keys]
pipeline.multi()
for generation_id, lease_key, fake in zip(generation_ids, lease_keys, fake_flags, strict=True):
if fake is None:
continue
pipeline.hset(lease_key, LEASE_CANCELLED_FIELD, 1)
if fake != "1":
pipeline.xadd(
stream_key,
{
STREAM_GENERATION_FIELD: generation_id,
STREAM_ENDED_FIELD: 1,
},
maxlen=CHAT_PROGRESS_STREAM_MAX_ENTRIES,
approximate=True,
)
pipeline.expire(stream_key, CHAT_PROGRESS_STREAM_TTL_SECONDS)
try:
_get_client().transaction(flag_live_leases, *lease_keys)
except redis.RedisError as redis_error:
logger.warning(f"Unable to cancel the delta stream of chat request {request_id}: {redis_error}")
def read_posted_progress(generation_id: str, request_id: str) -> PostedProgress:
"""Return the content a generation posted, read from the request's stream.
Reasoning is left out, since a generation is paid on its content alone. When the stream holds no entry for the
generation and its lease still exists, the lease's accepted bytes stand in for the characters. The bytes count
multibyte characters more than once and include any reasoning, so the fallback leans toward the worker; the stream
outlives the lease by design, so the fallback applies only when the stream was trimmed or lost.
Args:
generation_id: The pending generation of the cancelled request.
request_id: The chat request whose stream holds the generation's entries.
Returns:
The posted content and its character count, or
[`EMPTY_POSTED_PROGRESS`][horde.chat_progress.EMPTY_POSTED_PROGRESS] when the generation posted nothing or
Redis could not be reached, which a cancel survives by settling on the formula's floor.
"""
pipeline = _get_client().pipeline(transaction=False)
pipeline.xrange(chat_stream_key(request_id))
pipeline.hget(chat_lease_key(generation_id), LEASE_BYTES_FIELD)
try:
stream_entries, lease_bytes = pipeline.execute()
except redis.RedisError as redis_error:
logger.warning(f"Unable to read the posted progress of chat generation {generation_id}: {redis_error}")
return EMPTY_POSTED_PROGRESS
# Ended markers carry no seq and no content; only delta entries are paid.
generation_deltas = sorted(
(
(int(entry_fields[STREAM_SEQ_FIELD]), entry_fields.get(STREAM_CONTENT_FIELD, ""))
for _entry_id, entry_fields in stream_entries
if entry_fields.get(STREAM_GENERATION_FIELD) == generation_id and STREAM_SEQ_FIELD in entry_fields
),
)
if generation_deltas:
content = "".join(delta_content for _seq, delta_content in generation_deltas)
return PostedProgress(content=content, chars=len(content))
if lease_bytes is None:
return EMPTY_POSTED_PROGRESS
return PostedProgress(content="", chars=int(lease_bytes))
def release_stream_lease(generation_id: str) -> None:
"""Delete a finished generation's lease, logging rather than raising on a Redis failure.
+36 -2
View File
@@ -11,7 +11,7 @@ from horde.bridge_reference import (
is_backed_validated,
parse_bridge_agent,
)
from horde.chat_progress import end_progress_stream, release_stream_lease, write_stream_lease
from horde.chat_progress import PostedProgress, end_progress_stream, release_stream_lease, write_stream_lease
from horde.chat_signal import ChatEvent, publish_chat_event
from horde.classes.base.processing_generation import ProcessingGeneration, json_column_type
from horde.classes.kobold.chat_request import (
@@ -128,6 +128,12 @@ class TextProcessingGeneration(ProcessingGeneration):
"""The return code the Horde last refused this generation's reply with, written at the refusal. The worker's
fault submit that follows is counted by it, so a worker cannot claim the exemption a nonconformant reply has from
dropped jobs. NULL while no reply was refused and on text generations."""
settled_character_count = db.Column(db.Integer, nullable=True)
"""The posted character count a cancelled streamed generation was priced on, written by
[`settle_cancelled`][horde.classes.kobold.processing_generation.TextProcessingGeneration.settle_cancelled] and
read by ``get_things_count`` in place of a stored reply. The posted text is not retained and a stored
``generation`` would count the faulted generation as a delivered result, so this count is the record cancels are
analysed by. NULL on every other generation."""
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
@@ -359,6 +365,32 @@ class TextProcessingGeneration(ProcessingGeneration):
if self.wp.is_completed():
publish_chat_event(request_id, ChatEvent.DONE, generation_count)
def settle_cancelled(self, posted: PostedProgress) -> float | None:
"""Settle a pending streamed generation whose request was cancelled on the content its worker posted.
The posted content is measured by the server, so it is priced by the output estimate like a submitted reply:
the worker is paid for what it turned in and the requesting user is charged the same, and the generation is
faulted as cancelled, without a dropped-job penalty. The row is locked and reloaded first, so a submit or fault
that lands while the cancel runs settles the generation once: whichever commits first wins, and the other finds
it finished or faulted.
Args:
posted: The content the generation posted, from
[`read_posted_progress`][horde.chat_progress.read_posted_progress].
Returns:
The kudos the worker was paid, or None when the generation had already finished or faulted.
"""
db.session.refresh(self, with_for_update=True)
self.settled_character_count = posted.chars
kudos = self.cancel()
if kudos is None:
# A generation that a submit or fault settled first does not keep the count. cancel commits only when it
# settles, so this commit releases the row lock the refresh took.
self.settled_character_count = None
db.session.commit()
return kudos
def get_things_count(self, generation=None):
"""Return the output estimate the generation is charged on: its characters divided by 4, rounded up, at least
20 and at most the requested ``max_length``.
@@ -369,7 +401,7 @@ class TextProcessingGeneration(ProcessingGeneration):
Returns:
The estimate. A stored chat reply is counted by
[`chat_reply_characters`][horde.classes.kobold.chat_request.chat_reply_characters], so tool calls are
charged as output.
charged as output. A cancelled streamed generation is counted by the posted characters it was settled on.
"""
if generation is not None:
character_count = len(generation)
@@ -377,6 +409,8 @@ class TextProcessingGeneration(ProcessingGeneration):
character_count = chat_reply_characters(self.message)
elif self.generation is not None:
character_count = len(self.generation)
elif self.settled_character_count is not None:
character_count = self.settled_character_count
else:
return 0
quick_token_count = math.ceil(character_count / 4)
+3 -1
View File
@@ -26,7 +26,8 @@
-- The processing_gens columns hold a chat worker's structured reply (the message with any tool calls,
-- finish reasons and reported token counts), the basis the generation was charged on, the worker's
-- declaration as it stood when the generation was assigned, the transport the request's images
-- were sent to it with, and the return code its last reply was refused with. A nullable column without
-- were sent to it with, the return code its last reply was refused with, and the posted character count
-- a cancelled streamed generation was settled on. A nullable column without
-- a default and a column with a constant default are both metadata-only adds; the lock timeout fails
-- rather than queueing an ACCESS EXCLUSIVE lock behind a long transaction. Rerun on failure.
SET lock_timeout = '5s';
@@ -63,6 +64,7 @@ ALTER TABLE processing_gens ADD COLUMN IF NOT EXISTS assigned_max_context_length
ALTER TABLE processing_gens ADD COLUMN IF NOT EXISTS assigned_max_length INTEGER;
ALTER TABLE processing_gens ADD COLUMN IF NOT EXISTS image_transport VARCHAR(16);
ALTER TABLE processing_gens ADD COLUMN IF NOT EXISTS chat_refusal_rc VARCHAR(32);
ALTER TABLE processing_gens ADD COLUMN IF NOT EXISTS settled_character_count INTEGER;
RESET lock_timeout;
-- One operation key per user. Every existing row has no key, so the partial index starts empty, but the
+1
View File
@@ -85,6 +85,7 @@ def test_chat_migration_adds_the_chat_columns_with_existing_rows_marked_text_and
("processing_gens", "native_finish_reason", "character varying", 32, "YES"),
("processing_gens", "prompt_tokens", "integer", None, "YES"),
("processing_gens", "reasoning_tokens", "integer", None, "YES"),
("processing_gens", "settled_character_count", "integer", None, "YES"),
("waiting_prompts", "chat_conformance", "character varying", None, "NO"),
("waiting_prompts", "chat_extras", "jsonb", None, "YES"),
("waiting_prompts", "chat_fault_rc", "character varying", 32, "YES"),
+229 -4
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import json
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from decimal import Decimal
from typing import TYPE_CHECKING, Any
import fakeredis
@@ -211,9 +213,9 @@ def test_progress_in_sequence_is_appended_to_the_request_stream_without_a_signal
second = _post_progress(client, request_headers["apikey"], pop_result["id"], 2, "ue")
assert [response.get_json() for response in (first, retried, second)] == [
{"accepted_seq": 1},
{"accepted_seq": 1},
{"accepted_seq": 2},
{"accepted_seq": 1, "cancelled": False},
{"accepted_seq": 1, "cancelled": False},
{"accepted_seq": 2, "cancelled": False},
]
entries = [fields for _, fields in lua_redis.xrange(chat_stream_key(request_id))]
assert entries == [
@@ -311,6 +313,226 @@ def test_a_stale_generation_ends_its_stream(
assert not progress_redis.exists(chat_lease_key(pop_result["id"]))
def test_a_cancel_of_a_streamed_request_tells_its_worker_through_the_next_progress_post(
client: FlaskClient,
request_headers: dict[str, str],
chat_worker: FakeChatWorker,
lua_redis: fakeredis.FakeStrictRedis,
) -> None:
"""Requests left queued by earlier tests may be popped first, so the request cancelled is the popped one."""
_submit_chat_request(client, request_headers, stream=True)
pop_result = chat_worker.pop()
request_id = lua_redis.hget(chat_lease_key(pop_result["id"]), "request")
assert request_id is not None
first = _post_progress(client, request_headers["apikey"], pop_result["id"], 1, "Bl")
cancel_response = client.delete(f"/api/v2/generate/chat/status/{request_id}", headers=request_headers)
after_cancel = _post_progress(client, request_headers["apikey"], pop_result["id"], 2, "ue")
assert cancel_response.status_code == 200, cancel_response.get_data(as_text=True)
assert first.get_json() == {"accepted_seq": 1, "cancelled": False}
assert after_cancel.status_code == 200, after_cancel.get_data(as_text=True)
assert after_cancel.get_json() == {"accepted_seq": 2, "cancelled": True}
entries = [fields for _, fields in lua_redis.xrange(chat_stream_key(request_id))]
assert [(entry["gen"], entry.get("ended"), entry.get("content")) for entry in entries] == [
(pop_result["id"], None, "Bl"),
(pop_result["id"], "1", None),
(pop_result["id"], None, "ue"),
]
POSTED_REPLY = "Blue is the colour of a clear sky at noon, of deep water and of the cornflowers in a summer field."
"""More than 80 characters, so the reply is priced above the output estimate's floor of 20 tokens."""
@dataclass(frozen=True)
class Settlement:
"""Represents the kudos postings recorded for one generation and the amounts the text formula gives it."""
credits: list[Decimal]
"""The positive postings: the worker's pay."""
debits: list[Decimal]
"""The negative postings: the requesting user's charge."""
formula_kudos: Decimal
"""The text formula's kudos on the expected text."""
formula_charge: Decimal
"""``formula_kudos`` with the request's extra burn, as the user is charged it on any settlement."""
formula_pay: Decimal
"""``formula_kudos`` with the worker's bridge multiplier, as the worker is paid it."""
floor_kudos: Decimal
"""The text formula's kudos on an empty text, the output estimate's floor."""
def _serve_own_chat_model(client: FlaskClient, request_headers: dict[str, str]) -> FakeChatWorker:
"""Bring a worker online for a model no other test requests, so its pops take only this test's requests."""
own_model = f"custom/settling-{uuid.uuid4().hex[:8]}"
worker = FakeChatWorker(
client=client,
headers=request_headers,
name=f"CICD Settling Scribe {uuid.uuid4().hex[:8]}",
models=[own_model],
)
worker.pop()
return worker
def _pop_own_request(
client: FlaskClient,
request_headers: dict[str, str],
worker: FakeChatWorker,
*,
stream: bool,
) -> tuple[str, str]:
"""Submit a chat request for the worker's model, pop it and return the generation and request ids."""
request_id = _submit_chat_request(client, request_headers, model=worker.models[0], stream=stream, max_tokens=512)
generation_id: str = worker.pop()["id"]
return generation_id, request_id
def _read_settlement(app: Flask, generation_id: str, expected_text: str) -> Settlement:
"""Read the generation's kudos postings and price the expected text by the text formula on the same generation."""
from horde.classes.base.kudos import KudosLedger
from horde.classes.kobold.processing_generation import TextProcessingGeneration
from horde.flask import db
def price(generation: TextProcessingGeneration, text: str) -> Decimal:
generation.generation = text
return Decimal(str(generation.get_gen_kudos())).quantize(Decimal("0.01"))
with app.app_context():
amounts = [
amount for (amount,) in db.session.query(KudosLedger.amount).filter(KudosLedger.job_id == uuid.UUID(generation_id)).all()
]
generation = db.session.get(TextProcessingGeneration, uuid.UUID(generation_id))
assert generation is not None
# The text is set on the loaded instance only to price it; the rollback leaves the stored row as it was.
with db.session.no_autoflush:
bridge_multiplier = Decimal(str(generation.worker.get_bridge_kudos_multiplier()))
formula_kudos = price(generation, expected_text)
formula_charge = Decimal(str(generation.wp.calculate_extra_kudos_burn(float(formula_kudos)))).quantize(Decimal("0.01"))
floor_kudos = price(generation, "")
db.session.rollback()
return Settlement(
credits=[amount for amount in amounts if amount > 0],
debits=[amount for amount in amounts if amount < 0],
formula_kudos=formula_kudos,
formula_charge=formula_charge,
formula_pay=(formula_kudos * bridge_multiplier).quantize(Decimal("0.01")),
floor_kudos=floor_kudos,
)
def _read_generation_state(app: Flask, generation_id: str) -> tuple[bool, bool, str | None, int, int | None]:
"""Return the generation's faulted and cancelled flags, its stored text, its worker's dropped jobs and the
character count it was settled on."""
from horde.classes.kobold.processing_generation import TextProcessingGeneration
from horde.flask import db
with app.app_context():
generation = db.session.get(TextProcessingGeneration, uuid.UUID(generation_id))
assert generation is not None
return (
generation.faulted,
generation.cancelled,
generation.generation,
generation.worker.aborted_jobs,
generation.settled_character_count,
)
def test_a_cancel_settles_a_streamed_generation_on_the_content_its_worker_posted(
app: Flask,
client: FlaskClient,
request_headers: dict[str, str],
lua_redis: fakeredis.FakeStrictRedis,
) -> None:
"""The worker is paid and the user charged the text formula on the posted content, reasoning aside. The
generation is faulted as cancelled without a dropped job and keeps the posted character count it was settled on,
and neither a later submit nor the stale pass pays it again."""
from datetime import datetime, timedelta
from horde.classes.kobold.processing_generation import TextProcessingGeneration
from horde.database import threads
from horde.flask import db
worker = _serve_own_chat_model(client, request_headers)
generation_id, request_id = _pop_own_request(client, request_headers, worker, stream=True)
_post_progress(client, request_headers["apikey"], generation_id, 1, "", reasoning_content="The sky is clear today.")
_post_progress(client, request_headers["apikey"], generation_id, 2, POSTED_REPLY[:40])
_post_progress(client, request_headers["apikey"], generation_id, 3, POSTED_REPLY[40:])
aborted_jobs_before = _read_generation_state(app, generation_id)[3]
cancel_response = client.delete(f"/api/v2/generate/chat/status/{request_id}", headers=request_headers)
assert cancel_response.status_code == 200, cancel_response.get_data(as_text=True)
settlement = _read_settlement(app, generation_id, POSTED_REPLY)
assert settlement.formula_kudos > settlement.floor_kudos
assert settlement.credits == [settlement.formula_pay]
assert settlement.debits == [-settlement.formula_charge]
assert _read_generation_state(app, generation_id) == (True, True, None, aborted_jobs_before, len(POSTED_REPLY))
assert lua_redis.hget(chat_lease_key(generation_id), "cancelled") == "1"
late_submit = worker.submit(build_chat_submit_body(generation_id=generation_id, content=POSTED_REPLY, model_id=worker.models[0]))
assert late_submit.status_code == 400, late_submit.get_data(as_text=True)
assert late_submit.get_json()["rc"] == "AbortedGen"
with app.app_context():
generation = db.session.get(TextProcessingGeneration, uuid.UUID(generation_id))
assert generation is not None
generation.start_time = datetime.utcnow() - timedelta(seconds=generation.job_ttl + 1)
db.session.commit()
threads.check_waiting_prompts()
assert _read_settlement(app, generation_id, POSTED_REPLY).credits == [settlement.formula_pay]
assert _read_generation_state(app, generation_id) == (True, True, None, aborted_jobs_before, len(POSTED_REPLY))
def test_a_cancel_before_any_post_settles_a_streamed_generation_on_the_formula_floor(
app: Flask,
client: FlaskClient,
request_headers: dict[str, str],
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
worker = _serve_own_chat_model(client, request_headers)
generation_id, request_id = _pop_own_request(client, request_headers, worker, stream=True)
cancel_response = client.delete(f"/api/v2/generate/chat/status/{request_id}", headers=request_headers)
assert cancel_response.status_code == 200, cancel_response.get_data(as_text=True)
settlement = _read_settlement(app, generation_id, "")
assert settlement.formula_kudos == settlement.floor_kudos
assert settlement.debits == [-settlement.formula_charge]
assert settlement.credits == [settlement.formula_pay]
generation_state = _read_generation_state(app, generation_id)
assert generation_state[:2] == (True, True)
assert generation_state[4] == 0
def test_a_cancel_of_a_request_that_does_not_stream_leaves_its_generation_pending_and_unpaid(
app: Flask,
client: FlaskClient,
request_headers: dict[str, str],
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
worker = _serve_own_chat_model(client, request_headers)
generation_id, request_id = _pop_own_request(client, request_headers, worker, stream=False)
cancel_response = client.delete(f"/api/v2/generate/chat/status/{request_id}", headers=request_headers)
assert cancel_response.status_code == 200, cancel_response.get_data(as_text=True)
settlement = _read_settlement(app, generation_id, "")
assert (settlement.credits, settlement.debits) == ([], [])
generation_state = _read_generation_state(app, generation_id)
assert generation_state[:3] == (False, False, None)
assert generation_state[4] is None
def test_a_reasoning_delta_is_appended_with_its_reasoning_beside_empty_content(
client: FlaskClient,
request_headers: dict[str, str],
@@ -324,7 +546,10 @@ def test_a_reasoning_delta_is_appended_with_its_reasoning_beside_empty_content(
reasoning = _post_progress(client, request_headers["apikey"], pop_result["id"], 1, "", reasoning_content="Think")
text = _post_progress(client, request_headers["apikey"], pop_result["id"], 2, "Blue")
assert [response.get_json() for response in (reasoning, text)] == [{"accepted_seq": 1}, {"accepted_seq": 2}]
assert [response.get_json() for response in (reasoning, text)] == [
{"accepted_seq": 1, "cancelled": False},
{"accepted_seq": 2, "cancelled": False},
]
entries = [
fields
for stream_key in lua_redis.scan_iter(match=chat_stream_key("*"))
+137
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
from collections.abc import Iterator
from types import SimpleNamespace
import fakeredis
import pytest
@@ -16,14 +17,18 @@ from horde.chat_progress import (
CHAT_PROGRESS_BYTES_PER_TOKEN,
CHAT_PROGRESS_LEASE_GRACE_SECONDS,
CHAT_PROGRESS_STREAM_TTL_SECONDS,
EMPTY_POSTED_PROGRESS,
PostedProgress,
ProgressAppend,
ProgressOutcome,
StreamLease,
api_key_digest,
append_progress,
cancel_progress_streams,
chat_lease_key,
chat_stream_key,
end_progress_stream,
read_posted_progress,
read_stream_lease,
release_stream_lease,
write_stream_lease,
@@ -318,3 +323,135 @@ def test_reasoning_counts_toward_the_byte_cap_with_the_content(lua_redis: fakere
half_cap = CHAT_PROGRESS_BYTES_PER_TOKEN // 2
assert append_progress(_lease(), 1, "x" * half_cap, "y" * (half_cap + 1)) == ProgressAppend(ProgressOutcome.OVER_CAP, 1)
assert not lua_redis.exists(chat_stream_key(REQUEST_ID))
def test_a_cancel_flags_the_live_lease_keeps_it_and_appends_the_ended_marker(
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease()
lease_ttl = progress_redis.ttl(chat_lease_key(GENERATION_ID))
cancel_progress_streams([GENERATION_ID], REQUEST_ID)
assert progress_redis.hget(chat_lease_key(GENERATION_ID), "cancelled") == "1"
assert 0 < progress_redis.ttl(chat_lease_key(GENERATION_ID)) <= lease_ttl
assert _stream_entries(progress_redis) == [{"gen": GENERATION_ID, "ended": "1"}]
assert 0 < progress_redis.ttl(chat_stream_key(REQUEST_ID)) <= CHAT_PROGRESS_STREAM_TTL_SECONDS
def test_a_cancel_of_a_generation_without_a_lease_leaves_redis_as_it_is(
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
cancel_progress_streams([GENERATION_ID], REQUEST_ID)
assert not progress_redis.exists(chat_lease_key(GENERATION_ID))
assert not progress_redis.exists(chat_stream_key(REQUEST_ID))
def test_a_cancel_flags_only_the_generations_that_still_hold_a_lease(
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease()
released_generation_id = "0d8e7c41-93a5-4d0b-a1f2-6c3b9e8d7f20"
cancel_progress_streams([released_generation_id, GENERATION_ID], REQUEST_ID)
assert not progress_redis.exists(chat_lease_key(released_generation_id))
assert _stream_entries(progress_redis) == [{"gen": GENERATION_ID, "ended": "1"}]
def test_a_cancel_of_a_fake_generation_flags_its_lease_without_a_marker(
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease(fake=True)
cancel_progress_streams([GENERATION_ID], REQUEST_ID)
assert progress_redis.hget(chat_lease_key(GENERATION_ID), "cancelled") == "1"
assert not progress_redis.exists(chat_stream_key(REQUEST_ID))
def test_a_cancel_redis_refuses_is_logged_and_does_not_raise(monkeypatch: pytest.MonkeyPatch) -> None:
server = fakeredis.FakeServer()
server.connected = False
monkeypatch.setattr(chat_progress, "_client", fakeredis.FakeStrictRedis(server=server, decode_responses=True))
warnings: list[str] = []
monkeypatch.setattr(chat_progress, "logger", SimpleNamespace(warning=warnings.append))
cancel_progress_streams([GENERATION_ID], REQUEST_ID)
assert len(warnings) == 1
assert REQUEST_ID in warnings[0]
def test_a_cancelled_lease_accepts_the_delta_and_its_accept_and_retry_carry_the_flag(
lua_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease()
append_progress(_lease(), 1, "Hel")
cancel_progress_streams([GENERATION_ID], REQUEST_ID)
accepted = append_progress(_lease(), 2, "lo")
retry = append_progress(_lease(), 2, "lo")
assert accepted == ProgressAppend(ProgressOutcome.ACCEPTED, 2, cancelled=True)
assert retry == ProgressAppend(ProgressOutcome.DUPLICATE, 2, cancelled=True)
assert [entry.get("ended") or entry["content"] for entry in _stream_entries(lua_redis)] == ["Hel", "1", "lo"]
def test_a_refused_post_on_a_cancelled_lease_is_refused_as_before(lua_redis: fakeredis.FakeStrictRedis) -> None:
_write_lease()
cancel_progress_streams([GENERATION_ID], REQUEST_ID)
assert append_progress(_lease(), 3, "lo") == ProgressAppend(ProgressOutcome.GAP, 1)
def test_a_lease_without_the_flag_acknowledges_the_request_as_not_cancelled(
lua_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease()
assert append_progress(_lease(), 1, "Hel").cancelled is False
def test_the_posted_progress_joins_the_generation_content_in_seq_order_without_reasoning(
lua_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease(max_length=64)
other_generation_id = "0d8e7c41-93a5-4d0b-a1f2-6c3b9e8d7f20"
lua_redis.xadd(chat_stream_key(REQUEST_ID), {"gen": other_generation_id, "seq": 1, "content": "Elsewhere"})
append_progress(_lease(), 1, "", "Think")
append_progress(_lease(), 2, "Hel")
append_progress(_lease(), 3, "lo", "more")
cancel_progress_streams([GENERATION_ID], REQUEST_ID)
assert read_posted_progress(GENERATION_ID, REQUEST_ID) == PostedProgress(content="Hello", chars=5)
def test_the_posted_progress_falls_back_to_the_lease_bytes_when_the_stream_is_gone(
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease()
progress_redis.hset(chat_lease_key(GENERATION_ID), "bytes", 42)
assert read_posted_progress(GENERATION_ID, REQUEST_ID) == PostedProgress(content="", chars=42)
def test_the_posted_progress_of_a_generation_that_posted_nothing_is_empty(
progress_redis: fakeredis.FakeStrictRedis,
) -> None:
_write_lease()
assert read_posted_progress(GENERATION_ID, REQUEST_ID) == EMPTY_POSTED_PROGRESS
def test_the_posted_progress_redis_refuses_is_logged_and_empty(monkeypatch: pytest.MonkeyPatch) -> None:
server = fakeredis.FakeServer()
server.connected = False
monkeypatch.setattr(chat_progress, "_client", fakeredis.FakeStrictRedis(server=server, decode_responses=True))
warnings: list[str] = []
monkeypatch.setattr(chat_progress, "logger", SimpleNamespace(warning=warnings.append))
assert read_posted_progress(GENERATION_ID, REQUEST_ID) == EMPTY_POSTED_PROGRESS
assert len(warnings) == 1
assert GENERATION_ID in warnings[0]