Hot-path performance: how a chat turn stays fast as load grows¶
A chat turn is where speed is felt, so this page is about the work the platform does before the model is called and after it answers, and the rules that keep that work from growing with users, agents and features. The model call itself (seconds) is covered in LLM latency and model selection. Scaling by replica is in ADR-0014.
What this page does not claim. Nothing here proves the platform stays fast at a million users. That needs a load test on a real cluster (see What is not proven). What it records is what was measured, the rules that make the cost of a turn bounded, and what is still unfinished.
1. The cost model¶
Database work dominates the platform's share of a turn, for one reason that is easy to miss:
- Tenants are Postgres schemas, selected with
SET search_pathon the connection. SchemaMultiTenantConnectionProviderruns thatSETwhen a transaction takes a connection and resets it when the connection goes back. So every transaction costs three round trips, one of them the query.- A turn used to open dozens of small transactions, most of them re-reading the same agent row.
So the cost of a turn is roughly (transactions per turn) x (3 round trips + query), and it scales with the number of lookups a feature adds, not with how heavy each lookup is. That is why the rules below are about counting transactions.
2. Rules¶
- A turn reads each fact once. Anything read by many tool resolvers or wrapped tools in one turn is read through a snapshot, not re-queried.
- Reads that do not need to be fresh to the millisecond are cached per pod, keyed by tenant first, with a short TTL, a hard cap, and eviction on every write on the same pod (so an edit shows up on the next request there). Another pod sees it within the TTL. The TTL is the documented staleness bound.
- Authorization and edits never read a cache.
requireAccessand every setter read the row. - Cache what is immutable and small. Cache a value object built inside the loading transaction, never a detached JPA entity (its lazy state can fail later on another thread).
- A cache is off-able. TTL
0is a pass-through that reads the row every time. - Every new per-turn query has to be paid for in a test.
HotPathQueryBudgetITcounts connection checkouts and statements for one turn on a real Postgres and fails when they exceed the recorded budget.
The patterns are cache-aside, snapshot (value object), and write-through eviction by a JPA listener. The
mechanism already used for tenant plan and permissions (TtlCache, HotpathCaches) is reused, not replaced.
3. What was measured¶
HotPathQueryBudgetIT boots the real application against Postgres 16 in a throwaway container and resolves
tools for one turn of a default agent (no integrations configured), using Hibernate's own statistics.
| Step | Connections | Statements |
|---|---|---|
| Resolve tools, before the agent snapshot cache | 52 | 55 |
| Resolve tools, with the agent snapshot cache | 23 | 26 |
| Resolve tools, with the config lookup cache as well (warm) | 8 | 11 |
| Resolve tools, with the last eight transactions snapshotted too (warm) | 0 | 0 |
| Resolve tools, cold first call on a pod | 21 | 24 |
| Whole turn, pre-flight to the first token, warm | 15 | 14 |
| Whole turn, pre-flight to the first token, cold first turn on a pod | 44 | 46 |
Warm is the steady state of a busy agent and tenant, which is what almost every turn looks like; cold is the first call after a pod starts, an agent is edited, or the TTL runs out. Warm went from 52 to 8 connection checkouts, so about 130 fewer round trips on this one step of a turn. These are counts, not milliseconds: the time saved depends on the round-trip latency between the API pod and Postgres, and was not measured.
The change: the 16 read-only agent getters on AgentService (tool allowlist, autonomy tier, capability flags,
knowledge scope, collaborators, run budget) were each their own transaction. They now read an immutable
AgentProjection through AgentProjectionCache. Setting app.chat.hotpath.agent-projection-ttl=0s restores the
old behaviour exactly. Default is 3 seconds, capped at 30.
The second change covers the tenant-wide "is this configured" checks the tool resolvers make on every turn: is
any MCP server enabled, is the ElevenLabs, Sarvam or E2B credential active, is an image or video provider
configured, does this agent hold an active payment mandate, which specialist tags exist, and the platform's
tool credential rows. They go through HotpathLookups, a small read-through cache keyed by tenant first (or
platform-wide for rows the same for everyone). A write to any of the watched entities on the same pod evicts
the tenant's answers at flush and again after commit (TenantConfigEviction; each entity marks itself
with @TenantConfigEntity, so the kernel depends on no vendor module, and TenantConfigEntitiesMarkedTest
fails if one is missing), and another pod sees it within app.chat.hotpath.config-lookup-ttl (default 3 seconds,
capped at 30, 0s turns it off). Platform credentials are cached still encrypted and decrypted per call.
3a. BYOK budget check: a counter instead of a monthly SUM¶
LlmGatewayBudgetService.assertWithinBudget ran SELECT COALESCE(SUM(estimated_cost_usd), 0) FROM llm_call_log
WHERE started_at >= <start of month> on every BYOK turn. It is now a primary-key read of llm_spend_month
(migration V481, tenant schema, one row per UTC month). LlmCallLogService.record adds each call's cost with
UPDATE ... SET cost_usd = cost_usd + :c in the same transaction as the log row (so a rollback undoes both); when the
month has no row yet it inserts one seeded from the real SUM (or, if a concurrent transaction created it first, adds
to it). The update is atomic in Postgres, so it holds across threads and replicas, and it survives restarts because it
is a table. The migration backfills the current month. Calls with no cost (failed calls with no usage data) skip the
write. If the row is missing the check falls back to the old SUM; if the read throws it still fails open, as before.
The counter write runs behind a JDBC savepoint (LlmSpendCounterWriter). Without it, a Postgres error in the counter
statement (for example a tenant whose V481 has not been applied) aborts the whole transaction, and the swallow in
record does not help: a probe showed record throwing UnexpectedRollbackException and the log row lost. With the
savepoint the failure rolls back only the counter statement, is logged, and the log row commits.
ByokSpendCounterIT (real Postgres 16, Hibernate statistics), run 2026-10-13 on a laptop:
| Measure | Before (SUM) | After (counter) |
|---|---|---|
| Connections / statements for the budget check | 1 / 1 | 1 / 1 |
| Time per check with 300,000 call-log rows this month (30 runs, warm, after a 5-run warm-up) | 12.1 ms | 0.83 ms |
| Statements added per recorded LLM call | 0 | 1 (the UPDATE) |
The statement count of the read does not change; what changes is that its cost no longer grows with the month's
call volume. The write is one extra indexed UPDATE per LLM call, on the same transaction as the log insert, on the thread
that logs the call. Its added latency was not measured. All calls of one tenant update the same row, so concurrent
calls of a tenant serialise on that row lock until the log-insert transaction commits; if record were ever joined
to a caller's longer transaction, the lock would be held that long. No such caller was found on the main paths, but
nothing enforces it. This was not measured under contention beyond the 8-thread by 25-call test below.
Tests (ByokSpendCounterIT, real Postgres 16, run 2026-10-13): counter equals SUM after 8 threads x 25
concurrent calls starting from an empty table (all first calls race to create the row), after the seed path (rows
written by something that does not know the counter), and after the migration's backfill, which also excludes last
month's rows. Failure modes: with the counter table renamed away, record does not throw, in or outside a caller
transaction, and the log rows are written; assertWithinBudget does not throw; a caller transaction that rolls back
leaves neither a log row nor a counter change; a call with no usage data leaves the counter row's tuple version
(xmin) unchanged. The 300,000-row read benchmark in that class runs only with -Dbyok.counter.benchmark=true.
Counter drift and the reconcile job¶
The counter is exact only for writers that run the counter code. A pod on an older image inserts llm_call_log rows
without touching llm_spend_month, so during a rolling deploy (or when images are switched back and forth) the
counter falls below the true SUM, and the budget cap, which reads the counter, lets a tenant overshoot. This was
measured on the kind stage after switching images back and forth: counter 4.050 against a true SUM of 4.269, about
5.1% low (a single stage tenant, one run).
LlmSpendCounterReconciler (module chat, billing job pool, advisory-locked) now recomputes the current and the
previous UTC month for each ACTIVE tenant from llm_call_log and corrects the counter, every
app.llm.spend-counter.reconcile-interval (default 1h; 0 or off disables). Drift is therefore self-correcting
within that interval, instead of lasting until the end of the month. It is safe against concurrent writers: it takes
the counter row lock first, computes the SUM in a separate statement after the lock is held, then updates (a single
UPDATE ... SET cost_usd = (SELECT SUM(...)) can overwrite a concurrent increment). The metric
llm.spend.counter.drift.usd (tag outcome=corrected|ok, no tenant tag) accumulates the absolute drift found, and
each tick that corrected anything logs one WARN with the tenant count and the largest drift.
Not proven: the overshoot window is still up to one interval (a tenant can exceed its cap by whatever old-image pods
log in that time); the correction is tested for lost increments with 8 threads on one tenant row in
LlmSpendCounterReconcilerIT, but the effect of the reconcile's row lock on a single very busy tenant (writers block
for the SUM over the month's rows while it holds the lock) was not measured under production-like volume; and the pass
over many tenants (the per-tick cap, reconcile-max-tenants-per-tick, default 200) was not run against thousands of
tenants.
The third change covers the last eight warm transactions of tool resolution, which return JPA entities and
therefore needed snapshots first (rule 4): the caller's Google connection (a two-field record, keyed by tenant
and subject because the effective credential is per viewer), whether the hosted pool can serve images (a boolean;
the pool chain it is computed from is decrypted on every call and is not cached), the skills metadata and
settings of an agent (records, keyed by agent), and the lists of user scripts, custom code tools and OpenAPI
tools (records of what the model is shown, without source code, spec text or secrets). The callbacks of custom
code and OpenAPI tools re-read their row by id when the model calls the tool, so an edit or a disable applies to
the next call. Writes evict through the same listener (TenantConfigEviction), and a platform twin
(PlatformConfigEviction, marker @PlatformConfigEntity) drops the platform-wide answers when the hosted pool
rows change. app.chat.hotpath.config-lookup-ttl=0s turns all of it off; HotPathQueryBudgetIT runs with it
off and checks the same tools are offered and every read is a transaction again (23 connections instead of 0).
Authorization is not served from these: SkillService#loadForAgent still re-reads the scope.
HotPathQueryBudgetIT also counts the whole turn: it activates a mock Ollama-compatible server (a JDK
HttpServer in the test) for a tenant, sends a turn through AgentChatService#chat and counts connections and
statements from the call to the first token. Warm that is 15 connections and 14 statements; the budget is 17 and
16. What the 14 statements are is in the next section.
As of 2026-10-23 the measured warm turn is 17 connections and 18 statements and the budget is 17 and 18: a message of
at most app.chat.follow-up.short-message-chars characters (40) reads the session's tool_call_log once to keep the tools
the conversation already used on offer (FollowUpTools). A longer message does not.
4. Measured latency of a whole turn¶
Section 3 counts database round trips. This section measures milliseconds, end to end through the ingress, on a single developer laptop. Treat the shape of the numbers as the finding, not the absolute values.
Method. A tenant on the local kind stage uses a mock Ollama-compatible model server
(tests/load/mock-llm) with a fixed 50 ms first-token delay and 20 tokens at 5 ms, so no real model is called.
tests/load/scripts/turn-overhead.js (k6, closed loop, one conversation per virtual user, ten turns each) sends
turns for 60 s after a 20 s warmup. Time to first byte is the time to first token as the client sees it (the
platform sends nothing before the first token), so platform overhead is roughly time to first token minus 50 ms.
The pod's own agentic.chat.ttft timer agrees with the client figure within 3 to 7 ms in every cell, so the
ingress adds almost nothing. tests/load/kind/bench.sh runs the matrix (Helm upgrade per configuration);
raw results are in tests/load/kind/results-2026-10-13.jsonl. Every cell had 0 failed turns.
"Caches on" is the shipped default (agent projection and config lookup TTL 3 s); "caches off" sets both to 0s.
"1 replica" is the shipped single-replica configuration (in-memory stores); "2 replicas" is the shipped
multi-replica configuration (those stores on Valkey, APP_REPLICA_MODE=multi). They differ in more than the pod
count.
| Replicas | Caches | Users | Turns/s | TTFT p50 (ms) | p95 | p99 | Overhead p50 (ms) |
|---|---|---|---|---|---|---|---|
| 2 | on | 1 | 4.4 | 111 | 145 | 156 | 61 |
| 2 | on | 8 | 36.5 | 104 | 167 | 236 | 54 |
| 2 | on | 32 | 61.6 | 394 | 534 | 691 | 344 |
| 2 | off | 1 | 4.0 | 132 | 170 | 212 | 82 |
| 2 | off | 8 | 28.5 | 167 | 228 | 266 | 117 |
| 2 | off | 32 | 58.2 | 412 | 555 | 641 | 362 |
| 1 | on | 1 | 4.7 | 100 | 118 | 129 | 50 |
| 1 | on | 8 | 40.4 | 94 | 114 | 131 | 44 |
| 1 | on | 32 | 72.2 | 322 | 461 | 603 | 272 |
| 1 | off | 1 | 4.1 | 124 | 144 | 157 | 74 |
| 1 | off | 8 | 29.0 | 162 | 202 | 220 | 112 |
| 1 | off | 32 | 61.2 | 408 | 515 | 610 | 358 |
What the numbers say.
- The caches earn their keep. At 1 user they save about 20 ms per turn; at 8 users about 60 to 70 ms and about 40% more turns per second (28.5 to 36.5 with two replicas, 29 to 40 with one). At 32 users the gain mostly vanishes: the pods are out of CPU and the database round trips are no longer the limit.
- Idle platform overhead is 50 to 60 ms per turn with the caches on, about 75 to 80 ms with them off.
- At 32 users the laptop, not the code, is the limit. The pods use 1.5 to 2.9 CPUs and k6 and Postgres share the same cores. Do not read the 32-user rows as platform capacity.
- Two replicas did not beat one here, and could not. Both pods share the same laptop cores, so a second pod adds no CPU and adds the Valkey hops of multi-replica mode. Whether replicas add throughput on separate nodes is untested.
Decision on defaults. The data supports keeping the 3 s defaults for agent-projection-ttl and
config-lookup-ttl. Other TTLs (1 s, 10 s) were not measured, so the staleness bound was not tuned against data.
The plan cache (plan-cache-ttl, default 0) was not part of this run. Section 4b explains why it stays off.
4a. A regression the benchmark caught¶
The first benchmark after merging the once-per-turn lookups (the plan, flags, MCP answer and query embedding)
showed 52 to 95% failed turns and 20 to 30 s waits at 32 concurrent users on one tenant, where the build before
it had none. Unit tests, the integration tests and a three-turn smoke test all passed. The cause was in the
embedding that two memory recalls now share: QueryEmbedding.vector() was synchronized and made the blocking
sidecar call inside it. Turns run on virtual threads, and a virtual thread that blocks inside a monitor pins its
carrier thread; with about as many carriers as cores, a few concurrent turns pin them all. The threads that would
have released database connections could not run, so Hikari showed 12 of 12 active while Postgres sat idle, and the
liveness probes timed out. Found by bisecting images (old build and the build after the first PR: 0% and 99%
failed) and confirmed by the fix: a ReentrantLock instead of the monitor, 0% failed and 44.6 turns/s at 32
users on the same machine state where the old image did 38.6. QueryEmbeddingTest now fails if the embedder runs
while the object's monitor is held.
Lesson, and the rule from now on: any change on the turn path is benchmarked at 32 concurrent users before it is called healthy; a counted-queries test and a smoke test do not see thread pinning or pool starvation.
4b. After the hot-path PRs (main at 7b7f6cce4)¶
The same matrix, two replicas, after the plan/flags/embedding, tool-resolution snapshot, Redis, spend-counter and
other PRs were merged and the pinning fix in 4a was applied (tests/load/kind/results-2026-10-13-after-merges.jsonl,
0 failed turns in every cell). The machine was in a busier state than for the table in section 4 (more rows in the
database after repeated runs), so compare shapes, not single digits.
| Caches | Users | Turns/s (before, after) | TTFT p50 ms (before, after) | p99 ms (before, after) |
|---|---|---|---|---|
| on | 1 | 4.4, 4.3 | 111, 111 | 156, 198 |
| on | 8 | 36.5, 38.3 | 104, 100 | 236, 177 |
| on | 32 | 61.6, 67.7 | 394, 359 | 691, 543 |
| off | 8 | 28.5, 29.7 | 167, 163 | 266, 234 |
| off | 32 | 58.2, 60.4 | 412, 401 | 641, 638 |
At idle nothing changed; the gains are at 8 and 32 users (about 5 to 10% more turns per second, a lower tail). The
whole-turn database cost is the steady 14 connections and 12 statements from HotPathQueryBudgetIT. These are
modest, as expected: the earlier work had already removed most round trips, and the model call is not in this
number. What is not shown: any run with the opt-in features on (stream-end executor, parallel ingestion, job
pools, plan cache), which are off by default and were not benchmarked.
Reproduce: see tests/load/README.md.
4c. Everything on, and the failure drills (main at 5646394e0)¶
Run on the kind stage with every opt-in scaling feature on (tests/load/kind/values-all-features.yaml: the ingest,
webhooks, intake and billing job pools, tenant idle-skip, per-job ingestion claiming) and request-based
autoscaling (values-autoscaling-kind.yaml, Prometheus plus prometheus-adapter from prometheus*.yaml). Two features
are deliberately off: the stream-end executor (app.chat.stream-end.async) and the plan cache.
- Why the stream-end executor is off. It moves the call-log row, the BYOK counter increment, the hosted-spend accounting and the output-guardrail audit into an in-memory queue. That queue is lost on SIGKILL or OOM (the drain on a graceful stop waits 10 s), and a burst can overshoot a spend cap by about the queue depth (found in the security review). The A/B showed no gain: at 32 users, 63.2 turns/s with it on and 54.7 with it off, inside the machine's noise of about 10%. With it on, 150 concurrent turns produced exactly 150 call-log rows (nothing lost on a graceful path).
- Why the plan cache is off. The plan is read once per turn now, so the cache would save one connection per turn. Measured at 32 users: 65.4 turns/s with the cache off, 63.2 with it on. No benefit, and a plan downgrade would take up to the TTL to apply on other pods.
- The pools cost memory, not CPU. The four extra pods used 11 to 14 millicores each when idle and about 790 MiB each (about 3 GB for the four on this node). The log of each shows the role split: the main jobs pod skips exactly the offloaded jobs and each pool skips everything except its own.
- Request-based autoscaling works. Under a step to 40 concurrent long streams the API went from 2 to 4 pods in
about 45 s. The custom metric
http_server_requests_activereached 20.5 open requests per pod against a target of - Scale-in is deliberately slow (one pod per five minutes after ten quiet minutes) and was not waited out.
- Rolling restart under load (12 users, 10 s streams, full restart of both API pods): 1 of about 114 streams was cut (0.9%). Every other stream carried the exact event count. Time to first token rose to a p50 of 1.5 s and about 6 s at worst while the new pods warmed up (normally about 0.1 s), and the restart took about 3 minutes.
- Spend counter drift is real and now self-corrects. After the stage briefly ran images from before the counter existed, the counter sat 5.1% below the true sum (4.050 against 4.269). New traffic was counted exactly (200 concurrent calls: both moved by 0.009). The reconcile job brought the live counter to the true sum.
- A pre-existing bug the load exposed, now fixed. Streamed-reply webhooks (
agent.message.completed) and the judge's call-log writes ran without a tenant context, failed closed withrelation ... does not exist, and logged an error with the reply excerpt for every streamed reply. After the fix the API pods show zero such lines. - Image A/B on a quiet machine (previous image against current
main, fixed 2 replicas, two rounds each). At 8 users the throughput is the same (35.1 and 39.9 turns/s against 35.3 and 40.1). At 32 users the previous image failed 41% of turns (p99 41 s) in one round and 0.15% (p99 20 s) in the other; the current image did 60.4 and 70.9 turns/s with no failures and a p99 under 0.9 s. The cause of the previous image's stalls was not isolated.
Not fixed, found by these runs. - Scale-out under a sudden step is not clean. In one of three runs, 19% of requests got no first byte for about a minute (ingress logged them as 499) while the HPA added pods; the other two runs were clean (0 failed). The cause was guessed to be cold pods; it was not. It was a pinned-thread herd on a pod that was already serving, now fixed: see section 4d. - A core pod was once killed by its liveness probe under CPU saturation; the probe now tolerates about two minutes (period 10 s, timeout 10 s, 12 failures). - Everything here is one laptop with a mock model, and several JVMs share the same cores.
4d. The scale-out stall: not cold pods, a pinned-thread herd (main at af3597f54)¶
Section 4c left one problem open: when load steps from about 0 to 40 long streams on 2 API pods and the HPA adds pods, some requests get no first byte for about a minute. The guess was that freshly started pods report Ready while still cold and receive traffic they cannot serve. That was tested first and is not what dropped the requests.
Reproduced. tests/load/kind/step.sh: 2 warm pods, mock model at 200 ms x 50 tokens (a reply streams for 10 s),
VUS=40 DURATION=90s EXPECT_EVENTS=50, request-based HPA on. Main at 5646394e0, six runs: failed 3.7% (5 with no response
and 3 HTTP 500 of 216), 18.9% (35 of 185 with no response, ingress status 499, the exact figure from 4c), 18.9% (35 of 185
again), 0.6% (2 HTTP 500 of 311, p95 time to first byte 22.8 s), 0%, and 23.6% with pinned-thread tracing on. Five of six
runs lost requests.
Where the failed requests went. The ingress log has the upstream pod of every request. In every failing run the stuck
requests (35, 36, 39 of them) were all on one of the two pods that were already serving, never on a pod the HPA had added
(those served 30 to 38 requests each, all 200). That pod also failed its readiness and liveness probes
(context deadline exceeded), Prometheus could not scrape it for 40 to 110 s, and in one run it stayed unready until its
liveness probe killed it. A pod that is wedged for a minute is not "cold".
Cause. Running the pods with -Djdk.tracePinnedThreads=full (kind/pinned-threads.py reads the output) showed 37 to 86
pinned-virtual-thread traces per pod during the step, over 90% of them at one place:
PlatformBroadcastService.current(), the maintenance-mode check that runs on every /api/** request. Its 10-second
snapshot was reloaded inside synchronized (this), and the reload is a database read over TLS. On JDK 21 a virtual thread
that blocks inside a monitor pins its carrier thread, and a thread that waits to enter a monitor is pinned too. Every
request that arrived while one thread reloaded queued on the monitor and held a carrier. There are about as many carriers
as cores (10 on the stage), so a burst of in-flight requests at the moment the TTL expired took them all; the reloading
thread then needed a pooled connection that was held by a request that could not get a carrier to finish; and the pod
answered nothing, probes included, until the Hikari timeouts cleared it (HikariPool-1 - Connection is not available,
request timed out after 49035ms (total=9, active=9, idle=0, waiting=1)). It is the mechanism of 4a at another site. It
needs a burst of in-flight requests close to the number of carriers, which fits a step (about 20 simultaneous streams
per pod while only 2 pods exist) and not 4 pods or steady load, and explains why a given run is a coin flip.
Fix. PlatformBroadcastService.current() uses a ReentrantLock (it parks the virtual thread and pins nothing), and a
caller that finds a stale snapshot while another thread is reloading serves the stale one at once. Only a caller with no
snapshot at all (first request, or just after a write) waits for the load. PlatformBroadcastServiceTest fails on the old
code for two of its three new cases: no monitor is held during the read, and 20 readers do not queue behind a reload.
Main (5646394e0) |
Fixed |
|---|---|
| step runs failed: 3.7%, 18.9%, 18.9%, 0.6%, 0% (and 23.6% with tracing) | step runs failed: 0%, 0%, 0%, 0%, 0% (320, 280, 320, 360, 360 turns) |
pinned traces at PlatformBroadcastService.current: 42, 84, 37, 36 per pod |
0 (1 to 3 traces per pod remain, below) |
| 8 users: 29.4 and 36.9 turns/s, time to first token p50 139 and 104 ms | 8 users: 38.9 and 41.5 turns/s, p50 98 and 92 ms |
| 32 users: 44.2 and 54.5 turns/s, p50 561 and 435 ms, p99 1.5 and 1.2 s | 32 users: 60.4 and 72.0 turns/s, p50 388 and 333 ms, p99 1.0 and 0.5 s |
The cells are two rounds each (cell.sh, fixed 2 replicas, order main, fixed, fixed, main) with 0 failed turns. The fixed
image was never slower. Do not read the gain as the size of the fix: the machine drifts between rounds by about 10% (main alone
measured 44.2 and 54.5 turns/s at 32 users). The claim is "no regression, and the stall is gone", not a percentage.
What the step still shows. All five fixed runs had 0 failures, but time to first token is not clean: p50 438 to 2059 ms and p99 3.8 to 12.0 s over the 90 s, against 0.3 s at rest. The two serving pods carry about 20 streams each (more than the 12 pooled connections) for the roughly 100 s the HPA's new pods need to boot, and those boots take CPU from the pods that are serving on the same machine. Both are properties of the one-node stage and of the step; they were not changed here. A rolling restart under load (12 users, 10 s streams) is unchanged by the fix, as expected: 0 failed and 0 cut streams in both, 276 and 278 turns before against 288 and 288 after, time to first token p50 669 and 531 ms before against 463 and 521 ms after, p99 4.2 and 2.9 s before against 4.0 and 3.0 s after (stage noise about 10%).
How cold is a new pod, measured. Not enough to justify a readiness gate. - 40 sequential turns sent straight to a replacement pod right after it became Ready: the first took 707 ms, the second 137 ms, the rest about 120 ms; an old pod took 177 ms for its first and 100 to 110 ms after. - 12 long streams started at once on a replacement pod right after Ready (two runs): the first wave had a time to first token of 2.5 s and 2.0 s (p50), against 0.67 s and 0.75 s for the same burst on an old pod. By the second wave (10 s later) they were equal (0.75 and 0.74 s; 0.50 and 0.48 s). - 480 requests to a tenant-free public endpoint before the burst (a stand-in for a generic warm-up) changed nothing measurable (1.65 s against 2.0 s, inside the spread of the old-pod control, which also showed a 1.6 s p90 once).
So a fresh pod costs about 1.5 s on the first wave of a burst and about 0.6 s on its first single turn, then behaves like
the others. A warm-up that removes that would have to run a real turn through AgentChatService, which needs a tenant, an
agent and a provider: a synthetic tenant means a tenant schema migration at boot on every pod, rows in tenant listings,
billing and admin views, and a race between replicas in replicaStrict mode. Exercising only generic code (the experiment
above) does not reach the chat path and showed no effect. Neither is worth shipping for a 1.5 s first-wave cost; a readiness
gate would also delay the capacity the HPA is waiting for. If the cost matters, the safer levers are a higher
hpa.minReplicas or lower autoscaling target (more pods before the step), not a gate.
Second pin site, changed and not yet re-measured. SidecarClient.embed (the query embedding of memory recall) blocked in
BufferingClientHttpResponseWrapper.getBody while holding a monitor, 1 to 3 traces per pod in a 90 s step (all of the
pinned traces in the later 32-user A/B). SidecarClient now uses RequestBufferingClientHttpRequestFactory, which
buffers the request body only (to keep the real Content-Length) and returns the JDK client's response unwrapped.
The raw traces mark the monitor on BufferingClientHttpResponseWrapper.getBody itself: its bytecode has a monitorenter there, held while the virtual thread parks in the JDK body stream (which uses only j.u.c locks). SidecarVirtualThreadPinningTest reads a slow response on a virtual thread and counts jdk.VirtualThreadPinned JFR events; it runs only on JDK 21 to 23 (on 24+ JEP 491 removes monitor pinning, the control does not pin and the test is skipped).
NoSpringResponseBufferingGuardTest and SidecarClientTransportTest guard it. Not yet re-measured under load. To look for sites in a pod: add -Djdk.tracePinnedThreads=full to JAVA_TOOL_OPTIONS, run the step
(PIN_TRACE=1 kind/step.sh), then kubectl logs <pod> | python3 tests/load/kind/pinned-threads.py.
Not verified. One laptop and one node, mock model. The pin traces and the failures coincide in time and place and the
fix removes both, but no complete thread dump of the virtual threads in a wedged pod was taken (JDK 21's kill -3 does
not list them, and the image has no jcmd), so the order of events above (monitor waiters hold all carriers, the holder
waits for a connection) is inferred from the traces and the Hikari log, not observed in one dump. Whether a multi-node
cluster shows the boot-time CPU contention of the step was not tested.
4b. Each per-turn fact is looked up once¶
A read-only trace of chat() found the same facts being asked for several times in one turn. All four
are now resolved once, and ChatTurnLookupCountTest runs a real chat() turn over mocked collaborators and
fails if the counts go up. Every path that does not hold the value (delegated calls, specialist handoffs,
workflows, tests that build the pieces by hand) passes null and looks it up exactly as before.
| Fact | Lookups per turn before | After | Where |
|---|---|---|---|
| The tenant's effective plan | preflight, inbound guardrails, gateway budget and gateway rate limit for each client built, and one per RL dimension on a Playground turn (up to 5): 4 to 10 on a BYOK turn with guardrails | 1 in preflight, plus the outbound guardrail check (see 5) | ChatTurnPreflight hands currentTenantPlan to AgentGuardrailService, LlmGatewayService.resolveClient(tenant, spec, plan), RlPolicyPort.selectArm(.., plan) |
| The tenant's feature flags | 3 (version pin, effective-agent overlay, experiment variants), each a transaction | 1, kept on the turn (Turn.activeFlags) |
AgentVersionResolutionService.pinAndApply, ChatPromptAssembler |
| "Is any MCP tool offered" | the MCP tools resolved twice (resolveToolsFor, then anyMcpToolsOfferedFor) |
resolved once; the tool list carries the answer (ResolvedToolList) |
AgentToolProvider, ChatPromptAssembler |
| The user message embedded for memory recall | 2 sidecar HTTP calls (agent memory, tenant-shared memory) | 1, shared (QueryEmbedding) |
ChatMemoryRecall |
Measured on a real Postgres (HotPathQueryBudgetIT, 2026-10-13): one plan lookup is 1 connection checkout
(and 1 statement, the free-mode setting, in that test's configuration); one flag evaluation is 1 connection;
a version pin handed the turn's flags costs 1 connection and the same pin evaluating them itself costs 2.
Before this change a turn evaluated the flags three times, so it now saves two connections (about six round
trips) there. The counts of plan lookups, flag evaluations, MCP resolutions and embeddings per turn are
asserted by ChatTurnLookupCountTest, DefaultRlPolicyPortKnownPlanTest, LlmGatewayKnownPlanTest,
AgentGuardrailKnownPlanTest, AgentVersionResolutionFlagsOnceTest and ChatMemoryRecallEmbedOnceTest.
A failed embedding is not remembered: the second store tries again, as two separate embeds would have.
The plan cache stays off¶
app.chat.hotpath.plan-cache-ttl (default 0s) was the other candidate for the plan reads. With the plan
resolved once and passed down, the lookups the cache could still save are the one in preflight and the ones
listed in section 5, at 1 connection each. Against that, it adds up to ttl of staleness for a plan change on
another replica, and the plan decides what a tenant is allowed to do. No data here shows a saving large enough
to take that trade, so the default does not change. Milliseconds were not measured; if a measurement on a real
network shows a plan lookup matters, the cache is one setting away and already evicts on a plan write.
5. What remains (not measured end to end)¶
The whole-turn count above is measured. The rest of this table comes from a read-only trace of the code (not a measurement) and is ordered by how many round trips the items are believed to cost.
| Opportunity | Where | Approach |
|---|---|---|
The 14 statements a warm turn still makes before the first token (read from the SQL log of one warm turn: agent_experiment, agent, agent_version, feature_flag three times, platform_setting twice, tenant three times, llm_provider_credential, agent_procedure, llm_gateway_content_policy) |
experiment routing and version resolution, the plan and tenant status, the gateway's content policy and provider key, procedures | agent and the tenant rows are read again by other paths than the ones already cached; each needs a snapshot first, then the same lookup cache |
| The tenant's plan is re-read 8 to 12 times in one turn | guardrails, RL selection, gateway budget and rate limit | Pass the per-turn plan down; turn on the existing plan cache by default |
| Feature flags evaluated three times, MCP availability twice, user message embedded twice | version resolution, prompt assembly, memory recall | Evaluate once per turn; embed once |
~~A monthly SUM over the call log on every BYOK turn~~ done, see section 3a |
gateway budget | An incrementally maintained counter (llm_spend_month, V481) |
| ~~5 to 6 sequential Redis round trips to append one conversation turn, twice per turn~~ done, see section 5a | SessionMemoryService |
One Lua script (SessionAppendScript), on by default |
~~A blocking Redis DEL every 500 ms for the cancel watcher~~ done, see section 5a |
ChatTurnCancelService |
One batched poll per pod for all in-flight turns |
| Work done inline when the stream ends (audit, history append, call log, RL close) | ChatTurnExecutor |
Built, see section 5a: StreamEndExecutor behind app.chat.stream-end.async, off by default; the history append stays inline on purpose |
Two extra SET search_path per transaction |
SchemaMultiTenantConnectionProvider |
Design in ADR-0016 (proposed: transaction-scoped SET LOCAL, behind a flag); do not change it before the isolation test suite it specifies exists |
| The tenant's plan is re-read in the outbound guardrail check and by the clients built for delegated calls, specialist handoffs and workflows | ChatGuardrailGate output path, ChatDelegateCall, ChatSpecialistHandoff, WorkflowNodeExecutor |
Thread the plan through streamFast and verifyThenRespond (ChatTurnExecutor is at the file-size limit, so it needs a split first) |
~~A monthly SUM over the call log on every BYOK turn~~ done (duplicate of the row above), see section 3a |
gateway budget | An incrementally maintained counter |
| Tools that make the whole reply buffered (first token = full reply) | ChatTurnDispatcher |
Analysis in section 7; not implemented |
5a. Redis round trips and stream-end work (done in this change)¶
Three items from the list above. Round trips are counted by SessionAppendScriptRedisTest and
ChatTurnCancelServiceRedisTest, which count template calls that reach the Redis connection (one script call is one command on the shared
connection; the cancel test also checks INFO stats total_connections_received to show no new TCP connection is
opened per poll) against a real Valkey 8. They are skipped unless VEGADUTA_TEST_REDIS=host:port points at one:
docker run --rm -d -p 16379:6379 valkey/valkey:8-alpine
cd core && VEGADUTA_TEST_REDIS=<host>:16379 mvn -pl agents-turn -am test \
-Dtest='SessionAppendScriptRedisTest,ChatTurnCancelServiceRedisTest' -Dsurefire.failIfNoSpecifiedTests=false
Session memory append (SessionMemoryService.appendTurn, twice per turn). RPUSH, the fold claim, the trim,
EXPIRE and the owner record are one Lua call (SessionAppendScript.APPEND_SCRIPT), in the same order, so it is
also atomic per session. Measured round trips per append, before to after:
| Case | Before | After |
|---|---|---|
| No summariser, no tenant context (assistant turn, written from the stream-completion thread) | 3 | 1 |
| No summariser, tenant context (user turn, owner record written) | 5 | 1 |
| Summariser on, tenant context (user turn, may claim a fold batch) | 6 | 1 |
| Summariser on, no tenant context | 3 | 1 |
Per chat turn that is 8 to 2 without the summariser and 9 to 2 with it, assuming the user turn is appended with a
tenant context and the assistant turn without, as the code comments say. The test also runs 450 appends through the
old and the new path on the real server and requires the same stored list, fold marker, owner record, TTLs and the
same batches handed to the summariser. Milliseconds saved were not measured: they depend on the distance to Redis.
app.chat.session-append-single-roundtrip=false restores the old sequence. A Redis that REFUSES the script before
running it (ACL NOPERM, scripting disabled, CROSSSLOT on a cluster) falls back to it automatically; the
refusal is remembered for five minutes and logged once a minute. Any other failure (timeout, dropped connection,
Lua error) is NOT replayed: the script is atomic and may have run, so the append is skipped and logged, as the
old code did. SessionAppendScriptRedisTest exercises the refusal against a real server with an ACL that denies
EVAL/EVALSHA; the timeout case is covered with a mock only. On Redis Cluster the three keys are in different
slots, so the script is always refused and every append uses the multi-call path.
Cancel watcher (ChatTurnCancelService). Each in-flight turn used to run its own blocking DEL every
500 ms on a Reactor parallel-scheduler thread. Now one poller per pod sends one Lua call (a DEL per watched key, returning the counts) per interval on the
shared connection for all in-flight turns, and nothing when none is streaming. (A first version used
executePipelined, which makes Spring Data Redis open a dedicated connection per call; it was replaced.) If Redis
refuses the script the poller falls back to one DEL per key, as before this change; the cancel signal is delivered on the bounded-elastic
scheduler because cancelling a turn writes its partial reply. One poll covering 200 turns measured 1 template call and 0 new TCP
connections over 25 polls (before: 200 DELs per 500 ms). The poll interval is unchanged at 500 ms
(app.chat.cancel-poll-interval), so the worst case is that interval plus one Redis command;
ChatTurnCancelServiceTest#aCancelIsObservedWithinOnePollIntervalPlusTheCheck only asserts a loose upper bound
(twice a short test interval plus 100 ms, with a mocked check), and another test fails if the default stops being
500 ms. Latency against a real network was not measured. Like the other scripts, the batch script is refused on
Redis Cluster (keys differ per turn), where the per-key fallback is used.
Stream-end work (StreamEndExecutor, app.chat.stream-end.async, off by default). The output-guardrail audit,
the agent.message.completed event, the RL episode close and the LLM call-log row can run on a fixed pool with a
bounded queue instead of the thread that delivered the last token. A full queue runs the work on the caller (nothing
is dropped); a graceful stop drains the queue before the database pool closes; the tenant context is cleared after
each task that ran on a pool thread, while work run inline on the caller (queue full, stopping) leaves the caller's own
tenant context untouched (tested). It is off because no end-to-end number supports turning it on yet.
The history append stays inline on purpose: the next turn of the same session reads and appends the history
as soon as the client sees the stream end, so an asynchronous append could land after the next user turn and reorder
the conversation. It is one Redis round trip now. Consequences when the flag is on: those rows can appear a moment
after the reply, the Langfuse score lookup by session may not find the call-log trace id yet and falls back to a
session-level score, and a hard kill (SIGKILL, OOM, node loss) loses what is still queued.
6. Running the budget test¶
cd core
mvn -P it -pl app -am verify -Dtest=NONE -Dsurefire.failIfNoSpecifiedTests=false \
-Dit.test=HotPathQueryBudgetIT -Dvegaduta.test.postgres.image=<image with pgvector and AGE>
It needs Docker. It prints a HOTPATH-BUDGET line with the current numbers. When you cache another lookup,
lower BUDGET_CONNECTIONS and BUDGET_STATEMENTS in the test in the same change.
7. Analysis only: why some replies are buffered whole (bufferForGenerationSafety)¶
Nothing in this section is implemented. It records what the code does today and what a safe narrowing would have to prove.
What the code does. In ChatTurnDispatcher, a FAST-depth turn is wrapped in
ChatTurnExecutor.bufferAndGuardGenerationClaims when
bufferForGenerationSafety = offersGenerationTool(tools) || anyMcpToolsOffered
The wrapper does stream.collectList(), joins the chunks, and emits one element. For such a turn the first token
the client sees is the whole reply, so time to first token equals total generation time (plus the tool loop). The
condition is true when the resolved tool list contains generate_image, generate_video, generate_music or
generate_document, or search_tools (on-demand tool loading is assumed to be able to reach a generation tool
through call_tool), or when any MCP tool is a candidate for the turn (anyMcpToolsOfferedFor checks the
candidate set before relevance trimming, so it can be true when no MCP tool is finally offered).
What the buffer protects. The only check inside the wrapper is containsFabricatedGenerationUrl(fullText)
with no tool evidence for the turn (collectToolEvidenceSince == null). FABRICATED_GENERATION_URL_MARKERS
currently holds one host, oaidalleapiprodscus.blob.core.windows.net (DALL-E's signed storage URLs). If the full
reply contains it and no tool call produced it, the reply is replaced by an apology. The persisted-history guard
cannot un-send tokens already streamed, which is why a live-stream half exists.
What looks narrowable. These are hypotheses to test, not conclusions.
- The MCP half has no marker of its own. The history comment names MCP browsing tools claiming a screenshot,
but the code only matches the DALL-E host. As written, an MCP-only agent is buffered to protect against a URL
that only an image-generation path produces. The condition could become "an image-capable tool is reachable":
generate_imagelisted,search_toolslisted and the agent's catalog containing an image-generation tool, or an MCP tool whose name or description says it creates images. Risk: an MCP server that returns image URLs on the same host, and a model that invents one in a later "try again" turn. Needs an explicit decision to extend the marker set before the MCP half is removed. search_toolsalone. It is treated as offering a generation tool even when the catalog behind it has none. Checking the catalog (already resolved for the turn) for a generation tool name would drop the buffering for agents with on-demand loading and no generation tools.- Stream with a hold-back instead of buffering. Scan the stream as it flows and hold back only a tail long
enough to contain a partial marker (from the last
httpstart, or the marker length). On a match with no tool evidence, stop emitting and send the correction. Cost: text before the URL has already reached the client, so the correction no longer replaces the whole reply; the stored reply must match what the client saw. This is a product decision, not only an engineering one. - Decide per turn after the first tool event. If the model never calls a tool, the answer is plain text and the fabricated-URL risk is the same as for an agent with no tools at all, which streams today. A reply that has already started streaming without a tool call and without a marker prefix could keep streaming. Needs the step-event stream to say "a tool was called" before the first text chunk, which is not guaranteed.
Tests a change would need (none exist beyond AgentChatServiceGenerationBufferingTest, which covers only
offersGenerationTool):
- Table test of the condition: every combination of generation tool,
search_tools(with and without a generation tool in the catalog), MCP candidate set empty or not, MCP tool trimmed by relevance. - Time to first emitted element on a slow upstream
Flux(virtual time): buffered turn emits at completion, narrowed turn emits at the first chunk. - Incident replay: a model reply containing the DALL-E host with no tool call is corrected and the URL never
reaches the subscriber, for each branch left buffered, and for the hold-back variant with the marker split
across chunk boundaries (every split position of the marker, including inside
https://). - Evidence case: the same reply after a real
generate_imagecall is passed through unchanged. - Multi-turn "try again" case: the request was in an earlier turn, the current message has no keyword.
- Error and cancel mid-stream pass through unchanged; the persisted assistant turn equals what the client got.
- A load comparison (
tests/load) of time to first token for an MCP agent before and after, on the kind harness.
What is not proven¶
- The budget counter (3a) on a stack. Measured on a laptop Postgres 16 container with one tenant schema and 300,000
synthetic rows, not on the kind cluster and not as end-to-end turn latency. During a rolling update, calls logged
by a pod without this change are not added to the counter, so the budget undercounts (fails toward allowing) until
the month ends; the same applies to anything that inserts into
llm_call_logwithout going throughLlmCallLogService.record. A transaction that rolls back after the update also rolls back the counter, so the two cannot drift apart. No periodic reconciliation job or drift metric exists, so an undercount is silent. The latency the counter write adds torecord, and row-lock contention on a very busy tenant, were not measured. - Section 5a in milliseconds. The Redis changes are proven by round-trip counts and an old-versus-new equality run against one standalone Valkey, not by a latency measurement, and not on a cluster, a Redis Cluster (the scripts use several keys, as the older ones did) or Sentinel. The stream-end hand-off was not benchmarked, was not run with the flag on in a full application context, and its shutdown drain was tested on the executor alone, not through a real Spring context close with open streams.
- Milliseconds for the per-turn changes. Section 4b counts lookups and connection checkouts. The saved embedding is a sidecar HTTP call whose latency was not measured. The outbound guardrail check still reads the plan, and so do the delegated, specialist and workflow clients.
-
A whole turn on a stack.
ChatTurnLookupCountTestrunschat()over mocks. No turn was run end to end after this change on the kind stage or any other. -
Production latency. Section 4 is milliseconds on one laptop with a mock model, a Postgres one hop away and CPU shared with the load generator. Network distance to Postgres, noisy neighbours and real model behaviour will change the absolute numbers. The remaining database round trips were not converted to milliseconds individually.
- A million users. That depends on Postgres connection capacity, Valkey, the model providers' rate limits
and the per-tenant schema count as much as on this code. It needs a load test with realistic tenants on a
cluster (
tests/load). Two limits are already visible in the code: every background sweep walks every active tenant schema (measured cost and the idle-skip decision), and the database connection budget is fixed by pods times pool size (Deployment shapes; the proposed answer to the schema count is ADR-0015). - Multi-replica scaling. Two API replicas serving traffic were run on one machine, which cannot show scaling. Staleness across pods is bounded by the TTL by design and not observed under load. No rolling update with open streams and no scale-in under load have been run.
- Milliseconds saved by the third step. Section 3 counts connections and statements; the eight removed transactions were not converted to milliseconds and no kind-cluster benchmark (section 4) was re-run after them.
- Which statements fall before the first token. The list of 14 in section 5 was read from the Hibernate SQL log of one warm turn in order, not from instrumentation at the first token; the count (14) is measured at the first token, the attribution of each statement is inferred.
- The whole-turn number is for one shape of turn. A default agent on a BYOK Ollama provider with a mock model, no knowledge base, no tools configured, one user, an empty conversation. Turns with attachments, knowledge retrieval, memory recall that hits, RL routing or guardrails that are switched on will read more.
- Staleness on a second pod is bounded by the TTL by design and was not observed with two pods; only the
same-pod eviction is tested (
HotPathQueryBudgetIT,ToolResolutionSnapshotsTest).