Roadmap¶
Follow-up work is organized in numbered packages, referenced by number in issues, PRs and discussions. Package numbers are stable: delivered packages are removed from this page but their numbers are not reused.
Packages 20, 21 and 22 are open. Everything through package 19 is delivered and merged; what each one settled is recorded in the findings below. Listed rather than given as a range, because packages are delivered out of order and a range silently misstates the set the moment one in the middle lands.
Measured findings¶
A running log of what the benchmark suite (the cluster benchmark harness this repository used to carry, since removed — see Performance testing) has established, newest first. Each entry is a measurement rather than an opinion, so it can be checked and it can go stale — the run that produced it is named.
Like delivered packages, findings whose fixes have shipped and been verified
are removed from this page; git history keeps them, and the runs that
produced them are in the git history, under
benchmarks/egernia-performance/results/.
2026-08-25 — ADQL 2.1, by forking the grammar rather than replacing the parser (package 21, delivered)¶
The parser question settled itself: upstream queryparser has not moved
(0.7.4 is still the latest release) and no maintained Python alternative
parses ADQL 2.1, while the translation fast path (SLL prediction, single
parse) is built on exactly this ANTLR stack — so the ADQL dialect is now a
vendored fork (libs/egernia-core/egernia_core/query/_adql/, grammar and
generated parser both committed, ANTLR pinned to the 4.13.1 runtime already
in uv.lock), and the rest of queryparser stays a PyPI dependency.
The gap was smaller than the package assumed: 0.7.4 already parsed ILIKE,
OFFSET, bitwise operators, hex literals, the optional coordinate system
and 4-argument DISTANCE. What the fork adds is the 2.1 core-grammar change
the sentinel existed for — a geometry-typed column anywhere a geometry
argument goes (INTERSECTS/CONTAINS/AREA/DISTANCE/circle centres) —
plus CAST, COALESCE, LOWER/UPPER over expressions, empty-string
coordinate systems, and unlocked EXCEPT/INTERSECT (upstream refused them
for MySQL's sake; PostgreSQL is fine). Two silent-wrong-answer holes are
closed in translation: ADQL's ^ (XOR) now renders as PostgreSQL's #
rather than passing through as exponentiation, and hex literals render in
decimal so results do not depend on PostgreSQL 16+. The sentinel POLYGON
substitution and its magic coordinates are deleted.
/capabilities now declares 2.0 and 2.1 with a per-construct feature list,
and only what a conformance test in tests/unit/test_adql.py exercises is
declared: IN_UNIT (needs per-column unit metadata), WITH (still refused)
and CENTROID (no pgsphere mapping) are deliberately absent. Bitwise
operators turn out not to be in the final 2.1 REC at all — they were dropped
after the drafts queryparser followed — so they parse as an undeclared
extension. The hot path held, and slightly better than held: geometry
translation 2.33 ms before the fork, 2.21–2.27 ms after across runs on the
same machine (tests/benchmarks/test_hot_paths.py, including the
geometry-slot column recording added for package 22), and package 18's
independent run put the pre-fork translator at 2.37 ms min / 2.57 ms mean
on the same cone-search shape — the fork is marginally faster while
accepting more syntax. The SLL-equals-full-context parity guard is
extended over the new syntax. The budget this package was written against
has since been corrected by package 18's profile: whole-request
translation measures 3.19 ms (30.9% of a request, the largest named
subsystem), not the "fast path's 1.2 ms" the Work paragraph cited — read
next to the numbers above, 2.1 did not move that share, it just accepts
more of the language for the same price. Across
the full 12,000-query benchmark corpus, the fork translates every query
byte-identically to upstream 0.7.4, and the SLL fast path byte-identically
to the fork's full-context parse — zero divergences either way. The
in-cluster translation share published by package 18's profile was
measured against the un-vendored translator; make benchmark-profile
(~40 min, D1 only) re-measures it — the profile's bucket already matches
the vendored module path.
2026-08-26 — the parse removal, deployed: executors stopped being the async bottleneck (package 20, delivered)¶
The autoscaling family (K1–K7), run twice on identical offered load — the
ladder anchored at C1 = 6.83 jobs/s in both — differing only in whether the
executor re-parses each job's query: run 20260826T013544Z-88d8c5e9 with
the parse in the loop, run 20260826T050451Z-0f62a0fa with the table list
carried on the job row (query_tables, package 20's fix; the local profile
behind it — 190 ms of per-job re-parse against ~6 ms for everything else,
by tests/performance/profile_async_job.py — is in the 2026-08-25 entry in
git history). Every validity guard passed in both runs. Completions are
counted server-side: a uws.jobs row is inserted once and updated exactly
four times on its way to COMPLETED, so per-window completions are the
table's n_tup_upd / 4 — the client's own success count is a casualty in
both runs, for two different reasons below.
before (88d8c5e9) |
after (0f62a0fa) |
|
|---|---|---|
| executor CPU per completed job | 385–469 ms | 4.3–5.0 ms |
| per-executor drain, fleet saturated | 2.3–2.5 jobs/s at ~0.9 core/pod (K3, K6) | never saturated — ≥6.2 jobs/s at 2–3% CPU (K6) |
| sustained 6×C1 — K6, ~41 jobs/s offered | 80% done in-window; queue peaks 20,870; oldest job 851 s | 100% done in-window; queue peaks 52; oldest job 1 s |
| KEDA replicas | pegged at 8 | peak 5, mean 3.7 |
The before run is the published ~2 jobs/s figure re-measured and confirmed: a pegged executor drains 2.3–2.5 jobs/s at ~0.9 cores — ~0.4 s of CPU per job — and a fleet of eight cannot hold 6×C1: the queue grows past twenty thousand, jobs wait fourteen minutes, and 94% of K6's clients gave up before their job ran. The after run is the local profile transferring to the deployed environment almost exactly: ~4.6 ms of executor CPU per job against the profile's ~6 ms, ninety times less than before. No scenario in the family can make the executor the bottleneck any more — every job every scenario offered completed inside that scenario's window, KEDA never ran more than five replicas, and the pods that did run sat 97% idle.
What the after run surfaces instead is the async path's next ceiling: the
API. From 3.5×C1 up, 17–57% of client conversations failed — with
connection errors, on jobs the server had accepted and did complete —
against the single API pod at ~85% of its one-core limit. A job is a
submit plus phase polls plus a result fetch, and ~41 arrivals/s of that
control traffic exceeds what one pod that also answers ~99 rps of sync
queries can serve. The executor autoscaling defaults survive unchanged
(queuedJobsPerReplica: 10 — the queue simply never builds); the ~2
jobs/s notes in values.yaml, autoscaling.md and architecture.md are
republished from these runs. What one executor can do now has no measured
ceiling: feeding it one would take an API that scales, which is not this
package.
One boundary before that ceiling is read as a service property. The
executor numbers above are server-side and client-independent; the API
ceiling is a joint property of the service and a spin-polling client. The
generator never sends UWS 1.1 WAIT: it polls each job's phase at 0.25 s
backing off to 2 s, so one job costs the API roughly six to eight
requests, and package 18 put this API at 10.5 ms of CPU per request
(96–100 rps at one worker) — ~100 rps over ~7 requests per job predicts a
~14 jobs/s ceiling. The data cannot test that prediction, only fail to
contradict it: the family is clean at 0.5×C1 ≈ 3.4 jobs/s and failing at
every rung from 3.5×C1 ≈ 24 jobs/s up, a factor-of-seven bracket with no
rung between, and the suite cannot count the requests that would settle it
(the generator records one sample per job however many polls it made, and
no collected Prometheus series counts API requests — so http.rps on an
async artefact is jobs/s, unlabelled; no published page mislabels it, the
docs site's only requests/s column never sees an async measurement, so the
fix is a unit on the field, not a correction to published figures). The service already
carries the lever the generator did not use: uws_api.py implements the
blocking WAIT, which would replace most of those requests with one held
connection per job — so an operator hitting this ceiling may want a client
change before more API replicas. The follow-up is one package with a
prerequisite, not two: teach the harness to count API requests per job,
then measure the async ceiling under a WAIT-ing client.
2026-08-25 — workers against replicas: same price per process, and the wall is the cgroup (package 19, delivered)¶
One run (20260825T180219Z-f8b21fc4), twelve (workers, replicas) points plus
one probe, every point a saturated closed-loop ceiling on the same host,
corpus and seeds — and the w=1 column reproduces the replica ladder
(99.1 / 189.9 / 342.9 / 581.6 rps against ccbcb41a's 98.9 / 190.8 / 347.5 /
577.9) on a rebuilt cluster, which is what makes the rest comparable.
| rps | n=1 | n=2 | n=4 | n=8 |
|---|---|---|---|---|
| w=1 | 99.1 | 189.9 | 342.9 | 581.6 |
| w=2 | 187.6 | 335.8 | 583.0 | 830.7 |
| w=4 | 179.2 | 346.7 | 640.6 | 1005.5 |
- A worker is worth a replica, and it costs no pod. At equal process
count the two axes are interchangeable in throughput: 4 processes measure
335.8 (2x2) against 342.9 (1x4); 8 processes measure 583.0 against 581.6.
What a worker costs instead is ~140–165 MiB of essentially private memory
(pod peaks 138 / 302 / 562 MiB at w=1/2/4 — the interpreter shares almost
nothing) and
dbPoolMaxmore connections in the pod's ceiling. One caveat on the memory: the working set keeps growing after the pod warms — a fresh four-worker pod went 548 → 581 MiB over its 25 minutes of serving — so read the default 1 Gi as hosting four workers with real, not generous, margin. The shape of that growth says warm-up, not leak: the per-request slope differs 37x between w=2 and w=4 at equal throughput (0.03 against 1.05 MiB per thousand requests) where a real per-request cost would be a constant, it decays at constant load, and the two-worker pod measurably flattens at its own steady state. The steps between rungs have a named mechanism: they coincide exactly with the per-worker psycopg pools opening connections as concurrency rises (server sessions 11 → 24 across the probe's rungs, ~2–3 MiB a connection), so the pod's memory floor grows towardworkers x dbPoolMax— the same product its connection ceiling is made of, which gives the workers default its companion: workers follow the CPU limit, andconfig.dbPoolMaxis what makes them fit the memory limit. What rules out a slow residual leak conclusively is a soak — no configuration here was held longer than ten minutes — run it at the w=4 saturation rung, where the bound is tightest and a real residual shows first. And because the pool is bounded by construction atworkers x dbPoolMax= 32 connections (the probe's top rung had opened only 24), the mechanism makes the soak a prediction rather than a watch: the working set should flatten near ~600 MiB (the ~548 MiB warm floor plus the remaining connections at ~2.5 MiB each); past ~650 MiB there is a second mechanism and the bounded-pool explanation is wrong. - The default follows the pod's CPU limit, and the mechanism was
falsifiable in advance. Package 18's profile priced a request at 10.5 ms
of CPU with ~53% GIL-serialised and one worker at 1.05 cores, predicting
w=2 at 2 x 1.05 wanted against 2.00 allowed = 0.952 of doubling; measured,
187.6 / (2 x 99.1) = 0.945, with the pod at 1.94 cores. Four workers on
the same 2 cores lose 4% (179.2). The probe settles what that means:
the same four workers at a 4-core limit reach 338.1 rps pinned at exactly
4.00 cores, still
TAP_CPU_BOUND— the wall is the cgroup, not uvicorn. SotapApi.workers= the pod's CPU limit, stated in Deployment with the table. - On one host the axes stop being interchangeable at the top. At n=8 a w=2 fleet's pods reach only 1.45 cores each (11.6 of 16 allowed) where w=4 pods reach 1.81 — 830.7 against 1005.5 rps from the same eight pods — so extra in-pod workers recovered capacity that adding pods could not, on a 24-core node also running PostgreSQL (1.9 cores at the top point) and the cluster plane. Per process, 32 processes on one host serve 31 rps each against a single process's 99.
- The connection arithmetic is stated, and it was survived, not
validated. The w=4 n=8 shape may open 256 connections against
max_connections: 200; it served 1,005 rps with zero errors because this CPU-bound mix holds few connections at once. The ceiling is what the fleet may open — Autoscaling now says so next to the chart's own refusal arithmetic. - What the sweep broke on the way is fixed and pinned. A ConfigMap-only
change (workers, auth, any
config.*) never restarted the pods — the pod templates now hash ConfigMap and Secret, the harness verifies the deployed worker count and limits from the pods rather than from helm's exit code, and package 18's authenticated rungs were the first beneficiary. Also fixed: dataset generation on a fresh cluster (the service's obscore view pre-empts the suite's table), the restart guard judging pods on lifetimerestartCount, the database port-forward adopted on an accepting socket a dying forwarder still held, andMEMORY_BOUNDjudged on a fleet-summed working set that counts a rollout's terminating pods (now judged per pod; six transition rungs in this run carry the old misverdict, capacity rungs are unaffected). - Known artefact, diagnosable but not yet attributed per worker:
keep-alive connections pin to a pod and to a worker within it, so
low-concurrency rungs are bimodal (two clients on one worker of two
measure 98 rps where spread clients measure 167). It vanishes at every
capacity rung in this run (max repetition spread 2.7%), and per-pod
served_byconcentration lands with package 18 — per-worker identity in the response is proposed follow-up, not tacked onto either package.
2026-08-25 — the API's per-request CPU, named (package 18, delivered)¶
One profile run (20260825T163627Z-034d69ba), one worker, tapApi.replicas: 1
and tapApi.workers: 1 throughout, ten rungs at the knee of a short ladder
(c=4 at 100.6 rps and p95 61 ms; c=8 bought 133 ms of p95 for no more
throughput). py-spy attached to the pod from the host — nothing added to the
image — 24,593 GIL-held samples over ten minutes, each attributed to the
innermost frame that names a subsystem so stdlib time rolls up to the part of
the request that spent it. 99.1% of samples reach a named subsystem, and
75.3% of the request is the application's own work rather than the server,
the router or the event loop:
| subsystem | ms/request | share |
|---|---|---|
| ADQL translation | 3.19 | 30.9% |
| result writers | 2.29 | 22.1% |
| psycopg and row conversion | 1.61 | 15.6% |
| event loop | 0.95 | 9.2% |
| threadpool handoff | 0.81 | 7.8% |
| ASGI routing and dependencies | 0.58 | 5.6% |
| query preparation | 0.32 | 3.1% |
| observability | 0.23 | 2.2% |
| HTTP server | 0.14 | 1.4% |
| everything else named | 0.20 | 2.0% |
| unattributed | 0.10 | 0.9% |
The split is the profile's; the 10.55 ms it is applied to is the pod's own CPU accounting divided by the requests that window served. Neither number can produce the other, and a sampling profiler must never be a source of throughput figures.
- This package's own premise was wrong: translation never stopped being the
largest cost. It opened by noting the fast path took ADQL translation from
41 ms to 1.2 ms and asking where the remaining ~88% went. Translation is
3.19 ms — 30.9% of the request, still the biggest named subsystem — and the
in-process micro-benchmarks agree independently: 2.57 ms for the cone search
that dominates the mix, 2.86 ms for a join. Two methods about 20% apart, both
around 2.5x the published figure. The 35x the fast path bought is real (the
old two-pass shape still measures 256 ms in
tests/benchmarks), but 1.2 ms described something narrower than a whole request's translation and was read as though it described all of it. - The busiest single frame in the service is the CSV writer.
stream_dsvalone is 20.8% of GIL-held time, and the writers together are 22.1% — second only to translation — on a mix whose mean response is around 124 rows. This is the first time the writers have been visible behind an HTTP request rather than measured alone bybenchmark-serialize. - Authentication is the cheap part, and it had never been measured. With every request carrying a token the service verifies against a real in-cluster JWKS — RS256, 2,048-bit, keys cached for the configured five minutes, no principal cache anywhere — throughput fell 3.1% (98.1 to 95.1 rps) for +0.48 ms of CPU per request, and token verification shows up in the authenticated profile at 3.0% of GIL-held time, of which the signature check itself is 0.8%. Enforcing the whole query surface on top costs 6.2% and +0.94 ms. Both rungs ran at 0.00% errors, so these are the costs of verification and of an authorisation decision that succeeds — not of refusing traffic.
- Read those percentages beside the drift, not instead of it. The two unauthenticated rungs bracketing the authenticated ones measured 96.2 and 100.0 rps: a 3.9% spread within one run, the same size as the effect being measured. The honest statement is "a few percent, inside a drift of the same order", and it is only available because the family brackets.
- The end-to-end hot-path benchmark reconciles to 5%.
test_benchmark_sync_request_normal_mixdrives the real ASGI app over the mix and reports 8.20 ms per request against the 10.55 ms measured here — 22% low, just outside the 20% this package asked for. It is outside it for two reasons the profile names and prices: the benchmark has no libpq (psycopg, 1.61 ms) and no uvicorn or h11 (0.14 ms). Adding those back gives 9.95 ms against 10.55 — 5.3%. Both are reported: the raw figure is what CI regresses against, the reconciled one is what says the benchmark measures the right thing. - What the profiler cost. py-spy's default blocking mode took 74% of this
service's throughput (95.5 to 24.9 rps) and then stalled the worker past its
one-second liveness timeout, so the kubelet restarted a process that was busy
rather than broken — the same failure mode the probes were split to prevent,
arriving from a new direction. At 5 Hz it still cost 45%, because the expense
is roughly 100 ms per attach rather than per sample. Sampling is nonblocking
as a result: 0.9% of throughput, paid for in torn stacks — three of 24,593
samples arrived with a frame name that was not valid UTF-8 and 193 arrived
with no stack at all, both counted into the residual rather than dropped from
the denominator. The run where blocking mode fired both guards is kept as
20260825T155319Z-44a69b9c-profile. - One thing the family does not deliver. The all-threads pass was meant to
separate CPU spent with the GIL released. It does not: py-spy counts a thread
blocked in
epollas non-idle, so 84% of that pass is the event loop waiting. It is published as a distribution of thread activity, and the attribution rests on the GIL pass against the cgroup total. - A provenance caveat about this run. Its directory name and
environment.jsonrecord commit034d69bon branchfeat/repoint-references-to-ska-telescope, because a concurrent session checked that branch out in the shared working tree while the run was in flight. The code measured was this package's working tree throughout, whichdirty: truerecords; the branch and SHA describe what git happened to have checked out, not what ran. A run's provenance is only as good as its worktree's exclusivity, which is now a thing to know about this suite.
2026-08-25 — the size sweep is finished, and size almost is not the story (packages 16 and 17, delivered)¶
One db-scaling run (20260825T005436Z-b450b0a9), one corpus, D1 through
D4 grown in place, every measurement guard-valid and none invalid. The
throughput-versus-size curve is republished from this run alone
(results), and it is nearly flat:
| at saturation (c=4) | single client | classified | |
|---|---|---|---|
| D1, 2 GiB | 98.0 rps, p95 63 ms | 85.0 rps | TAP_CPU_BOUND |
| D2, 10 GiB | 94.1 rps, p95 66 ms | 79.9 rps | TAP_CPU_BOUND |
| D3, 25 GiB | 92.4 rps, p95 67 ms | 78.0 rps | TAP_CPU_BOUND |
| D4, 45 GiB | 91.9 rps, p95 67 ms | 75.9 rps | TAP_CPU_BOUND |
Twenty-two times the data costs six percent of the throughput. The tier
that was "where the working set stops fitting" — D3, 68–70% hit ratio, up
to 52 s of read wait per window — became TAP_CPU_BOUND the moment the
cone-search expression index existed (package 16): the cone classes had
been sequentially scanning the whole ObsCore table, and that, not the
mix, was the I/O regime. What remains of size is visible only at a single
client (D4 reads DATABASE_IO_BOUND there, 11% under D1) and in the one
class whose work is proportional by construction.
- The warmup fix holds. Each tier's declared warmup (300 s at D3, 600 s at D4) makes the first repetition statistically indistinct from the rest — within 1% at every size — where it used to be the coldest point in the set.
- Package 16's plan verification. No cone-search class sequentially
scans ObsCore at any size, at last:
Q05/Q06/Q07/Q12all plan throughobscore_spoint_gist, the index the schema had described in a comment and asserted in a test expectation without ever creating. The remainingbad_cardinality_estimateflags concentrate where they should: Q14's unanchoredLIKEis unestimable by design, and a share of the recorded ratios on Q10/Q14 are the LIMIT artefact (a node above aTOPstops early, so "actual" undercounts and the ratio inflates) — a flag-quality observation, not a planner failure. - The deferred admission decision: no
EXPLAINgate. Q13, the full-table aggregate, is the one class that scales with size — p95 292 ms / 1.23 s / 3.04 s / 6.67 s across D1–D4,DATABASE_CPU_BOUNDwith parallel plans throughout (it was 17.8 s and I/O-bound on D3 before the cursor fix). Even at D4 that sits inside a sanesyncTimeoutSeconds, and under four concurrent D3 aggregates the pool shed 23% with 503s — which is the admission mechanism working: bound by actual cost, refuse with an answer when concurrent cost exceeds the pool. A cost-estimate gate would add a planning round trip to every query to refuse queries a timeout already bounds. If a deployment's users hammer full aggregates, the fix is a summary table in the data domain, not a smarter gatekeeper.
2026-08-25 — replica scaling measured: 0.73 at eight, and it is a ceiling (package 14, delivered)¶
Run 20260824T235130Z-ccbcb41a, the closed-loop sweep once per replica
count, same ladder and workload seeds at every count, every capacity a
saturated sweep's plateau rather than the largest rate anybody offered:
| replicas | capacity | scaling |
|---|---|---|
| 1 | 98.9 rps (C1) | 1.00 |
| 2 | 190.8 rps | 0.96 |
| 4 | 347.5 rps | 0.88 |
| 8 | 577.9 rps | 0.73 |
The number that did not survive: the first run of this family published
an eight-replica ceiling of 403 rps (efficiency 0.51) that was the load
generator's own — one asyncio event loop pinned at 100% of a core, read
as "3% of the host" because the self-watch divided by the core count, so
the guard built to catch exactly this stayed green. With the peak judged
against one core and the family's held concurrency sharded over four
processes, the busiest generator process peaked at 47% and the plateau is
the service's (TAP_CPU_BOUND at the top rung). The 27% lost by eight
replicas is the shared substrate — one PostgreSQL, one node — and is now
a number with a measured failure on each side of it.
2026-08-24 — the classifier's four misreadings, corrected (package 15, delivered)¶
None changed what was measured; all changed what a reader concludes, and each is now pinned by a test that fails if it comes back:
- Pool waits above the timeout were an interpolation artefact.
Reclassifying the shedding run (
20260824T210726Z-9b2bfb85) shows the shape exactly: every saturated rung reportspeak_pool_wait_p95_sof 9.7 s against a 5 s timeout — the midpoint of the (5, 10] bucket, where every timed-out acquire landed because no bucket edge sat at the timeout. The service's histogram now derives its edges fromdbPoolTimeoutSeconds(an edge at the timeout and one 20% past it), so runs on the fixed image read within 20% of the truth; the verdict's evidence now carriespool_timeout_sso the artefact is at least legible in old runs. CONNECTION_POOL_BOUNDconfidence grades against the timeout.min(1.0, wait)made any wait over a second a certainty that outranked every other verdict; a 0.5 s wait against a 5 s timeout is now a tenth of a case.- CPU ceilings are time-aligned to the fleet that was ready. The
executor (and API) ceiling was the peak ready count times the per-pod
limit across the whole window, so a ramping fleet was judged mid-ramp
against pods that did not exist yet, and a fleet pinned at 2, then 8
cores — pinned the entire time — read
UNKNOWNagainst an 8-core ceiling it only reached at the end. Each CPU sample is now judged against the replica count ready at that sample's timestamp (stepwise, from the run's own series). The KEDA run the package named is not in this checkout's results, so the correction is pinned by unit tests on exactly that ramp shape rather than by a reclassify. - The alignment covers every gate that compares against a fleet
ceiling — both
hotgates and theSERIALIZATION_BOUNDgate, the most sensitive of the three because its bar is 60% of the ceiling rather than 90%: a ramping fleet formatting bytes the whole window read as a fifth busy against the peak-fleet ceiling, missed the 0.25 threshold, and was filed as something else. And_timed_series()now insists on timestamps rather than quietly judging a window on whichever subset of rows carried one — every row a measurement produces has one, and a path that only mostly had them would have computed a plausible number over a fraction of the window.
2026-08-24 — overload now sheds with answers, and the ceiling is placed (package 13, delivered)¶
The bounded-concurrency shape the package asked for exists (egernia_bench
shedding, make benchmark-shedding): a closed loop is bounded
concurrency, so holding it far past saturation is the sustained-overload
point the open-loop generator could never keep still. Run
20260824T210726Z-9b2bfb85, D1, normal mix, held for 45 s per rung:
| held connections | 1 replica, uncapped | 1 replica, limit 64 | 2 replicas, uncapped | 2 replicas, limit 64 |
|---|---|---|---|---|
| 32 | all served, 93 rps | all served, 92 rps | all served, 181 rps | all served, 179 rps |
| 128 | 389 × 503, 0 resets | 241 × 503, 0 resets | 223 × 503, 0 resets | 210 × 503, 0 resets |
| 512 | 502 × 503, 36 resets | 501 × 503, 32 resets | 511 × 503, 0 resets | 5,358 × 503, 0 resets, 49 rps |
| 2,048 | collapse: 0 × 503, 69 resets, 2,026 timeouts | same collapse | 64 resets, 2,031 timeouts | 7,876 × 503, 0 resets, 74 rps |
Three facts worth keeping:
- The onset is placed. An uncapped worker starts resetting connections between 128 and 512 held (2.4% of requests at 512), and collapses entirely around 2,048 — the accept backlog — where nothing is answered: no 503s, just client timeouts at the 120 s mark. The earlier fixed-scaling resets were this, reached through the open loop's in-flight growth.
- The ceiling works — through the fleet, not the single worker.
tapApi.limitConcurrency: 64is now the chart default: above a worker's concurrent load at capacity (a replica saturates at 4–8 clients), below the onset. Two capped replicas held 2,048 connections with every refusal a 503, zero resets, and 4.5× the uncapped run's goodput — refusing early protects the pool for the requests that are admitted. - Past ~512 connections per worker, no application ceiling helps. The
single-replica collapse is identical with and without the cap: the event
loop drowns in accepted sockets before
limit_concurrencycan answer anything. That regime belongs to whatever bounds connections upstream — replicas behind a Service, or an ingress — and the values file now says so instead of implying a knob would save it.
The verification pass the package defined — the same held load, refused with 503s instead of dropped — is the two-replica column, and it holds at both overload rungs.
2026-08-24 — the aggregate was paying for the cursor, not the aggregate (package 11, delivered)¶
Q13 (GROUP BY over the whole ObsCore table) was never the price of the
scan. Result queries ran on named (DECLARE'd) cursors, and a cursor decides
the plan twice over: PostgreSQL never parallelises a cursor's query, and
cursor_tuple_fraction biases the planner toward fast-start plans on the
assumption the client stops reading early — always false here, since the
service reads every result to MAXREC + 1. On D1 the cursor ran Q13 as a
serial full index scan at 614 ms where the same statement, planned
plainly, runs a parallel seq scan in 195 ms with PostgreSQL's default two
workers (108 ms with four). The bias is also unstable: an earlier record
shows the cursor picking a 400 ms serial seq scan for the identical query —
two different crippled plans on two hosts, neither of them the plain one.
Result queries now run as plain streamed statements (Cursor.stream(),
chunked delivery, TCP backpressure against a slow reader — memory stays
flat). Stress family before (20260824T200849Z-80cf325b, main image) and
after (20260824T201619Z-a5ee5628), same corpus, D1, four clients, CSV:
| before | after | ||
|---|---|---|---|
| Q13, full aggregate | 6.7 rps, p95 655 ms | 21.7 rps, p95 269 ms | now honestly DATABASE_CPU_BOUND |
| Q11, 10,000 rows | 6.2 rps, p95 745 ms | 6.8 rps, p95 681 ms | |
| Q09, deep join | 30.9 rps, p95 161 ms | 32.3 rps, p95 151 ms | |
| Q14, expensive join | 25.0 rps, p95 416 ms | 26.3 rps, p95 409 ms |
The package's three questions, answered:
- Parallel settings. Q13 execution on the 8-CPU pod, best of five:
serial 373 ms, 2 workers (the PostgreSQL default) 144 ms, 4 workers
95 ms, 6 and 8 flat at ~92 ms — the scan floor. The default already
captures most of it;
max_parallel_workers_per_gather: 4is a latency knob for deployments where single-query aggregate latency matters, andmax_parallel_workers(default 8) is what keeps concurrent aggregates from taking the whole pod. The chart's defaults are unchanged: the knob is documented invalues.yaml, not pre-turned. EXPLAIN-based sync rejection. Not built, and not on these numbers: a 269 ms p95 needs no steering, andsyncTimeoutSecondsnow honestly bounds the whole statement (it used to bound each cursor FETCH), which refuses a query by what it actually costs rather than by what an estimate guessed. The open case is D3, where this query held a sync connection for 18 s pre-fix — remeasured post-fix under package 17 before deciding.- Summary tables / materialised views. A data-domain decision, not service infrastructure: the service's job was to stop charging 6× the scan's honest price, and nothing at D1 justifies maintaining precomputed answers. Revisit only if the D3/D4 numbers say the honest price is still too high for the queries users actually run.
Two things travelled with the fix. The abort path used to recognise a job's
backend by the cursor's name in pg_stat_activity; that identity now
rides as a leading SQL comment (uws.job_query_tag), placed first so
statement truncation can never hide it — the component suite's abort tests
dropped from 87 s to 30 s because cancels now land instantly. And the plans
the suite records with EXPLAIN (plan-flags) were always plain-statement
plans; the service now executes what the suite was measuring.
2026-08-24 — the large-result cost was the per-cell type question (package 10, delivered)¶
Package 10 asked where the SERIALIZATION_BOUND time went. Into asking,
110,000 times per Q11 response, what type each cell was — a question the
column's PostgreSQL type OID had already answered before the first row
arrived. _plain() ran once per cell in every format, up to four
isinstance checks deep; VOTable ran a second per-cell function on top and
then saxutils.escape, three unconditional str.replace allocations on
text (ObsCore identifiers, URLs) that contains none of the three characters
it looks for. The fix types the dispatch per column: a kind now names the
Python type psycopg produces as well as the wire type, recognised values
reach the writers untouched, and only numeric, timestamps and the
opaque residual carry a per-cell coercion.
Measured by the new result-formats family — runs
20260824T172808Z-00d532f7 (before) and 20260824T181653Z-a5e3d315
(after), same D1 (2.16 GiB, 700k ObsCore rows), same corpus hash, same
five Q11 query texts in the same order, 4 closed-loop clients, 3
repetitions, zero errors and zero invalid measurements in either run. Q11,
10,000 wide rows per response:
| Q11 | rps | p95 | bytes/response |
|---|---|---|---|
| votable | 4.2 → 7.6 (1.82×) | 1,109 → 615 ms | 3.46 MiB |
| json | 4.8 → 8.7 (1.83×) | 979 → 544 ms | 2.74 MiB |
| csv | 4.4 → 6.2 (1.42×) | 1,060 → 758 ms | 2.52 MiB |
| tsv | 4.4 → 6.2 (1.42×) | 1,051 → 748 ms | 2.52 MiB |
| parquet | 8.4 → 17.9 (2.12×) | 577 → 307 ms | 0.54 MiB |
| arrow | 8.3 → 17.4 (2.09×) | 574 → 313 ms | 2.16 MiB |
Q10 (1,000 rows) moved the same direction, 1.28–1.51× across the six.
In-process (egernia_bench serialize, no cluster in the way) the writers
themselves went arrow 8.01 → 0.93 µs/row, parquet 8.77 → 1.78, json
15.44 → 5.12, votable 17.94 → 7.04, csv 17.05 → 9.19. The text output is
byte-for-byte what it was — a differential test over every kind, its
boundary values and NULL holds it there, and the measured bytes/response
agree to ±0.03%.
Qualifications: these are warm-cache numbers by design — D1 fits inside
PostgreSQL's 12 GiB entirely, and the family holds the database cost
constant to isolate the writer; and the Q11 corpus is five distinct query
texts (its template uses only the collection parameter), identical across
formats and builds, so the comparison is clean but the absolute p95 is not
a cold-archive figure. Two of three after-run parquet repetitions now
classify TAP_CPU_BOUND rather than SERIALIZATION_BOUND — for the
columnar formats the writer has stopped being the dominant cost. What
remains in CSV, still the slowest of the six, is _csv.writer's own
quoting logic, which is CPython's floor rather than the service's — the
recommendation for bulk transfer is Parquet (or Arrow on a fast link), now
documented in Result formats. First run on the new
30-core/120 GiB host and its budget (24-core node, 8-CPU/12 GiB
PostgreSQL): absolute numbers do not compare to the old host's family.
2026-08-24 — the depth signal verified: the fleet the queue implies, in seconds¶
Run 20260824T140134Z-bf7e4b24-keda: the same seven scenarios, first run on
the depth-based trigger (max(tap_jobs{phase="QUEUED"}), threshold 10 jobs
per replica, max 8) with the executor's CPU limit at 1. Same build across the
run; the dataset store had grown to 25.45 GiB under the D1 label, so the
regime is I/O-influenced and absolute numbers are directional against the
old family, not build-to-build. The shape is not directional, and the shape
is the verdict:
| peak pods | mean | max queued | detect | p95 | errors | |
|---|---|---|---|---|---|---|
| K1 idle to 0.5×C1 | 1 | 1.0 | 7 | — | 1.3 s | 0.00% |
| K2 step to 3.5×C1 | 8 | 3.8 | 402 | 12 s | 36.9 s | 0.05% |
| K3 spike to 6×C1 | 8 | 6.2 | 553 | 8 s | 128 s | 0.01% |
| K4 ramp to 6×C1 ⚠ | 8 | 4.8 | 179 | 62 s | 141 s | 0.01% |
| K5 alternating 0.5×/4×C1 | 8 | 6.1 | 297 | 12 s | 21.7 s | 0.02% |
| K6 sustained 6×C1 ⚠ | 8 | 6.6 | 150 | 10 s | 220 s | 0.04% |
| K7 4×C1 to 0.2×C1 | 8 | 4.1 | 320 | 0 s | 17.8 s | 0.05% |
Against the age signal's family: every overload profile now reaches the full permitted fleet where before every one capped at three; detection fell from 98–340 s to 8–12 s (K4's 62 s is the ramp itself — on a gradual ramp the queue takes a minute to reach ten jobs, which is the signal being honest, not slow); queues that grew to 1,700–4,100 jobs are held at 150–553, drained as they form; and the errors that reached 9.8–17.3% on the spike and the sustained overload are at or under 0.05% in every scenario. p95 on the worst profiles: 946 → 128 s (K3), 1,203 → 220 s (K6), 315 → 36.9 s (K2), 327 → 17.8 s (K7). K1, the control, still refuses to scale.
The routing question is closed. On pods whose full lifecycle was captured, Ready-to-serving measured 3.6 s (K4) and 10.5 s (K5) — the old family's 83–243 s "routing" stage was the queue wearing the proxy's clothes, exactly as suspected. Kubernetes provisioning remains 0–1 s. There is nothing to attack.
The residual p95 in K3/K4/K6 is capacity, not signal: eight pods serve
~22 jobs/s against ~41 offered at 6×C1, so the wall is maxReplicas — an
operator's explicit budget, which is where the wall belongs.
Qualifications, in the log's own spirit: K4 abandoned 3.3% and K6 13.4% of
their arrivals at the generator's in-flight cap, so both are qualified as
measurements of their offered rate (a sustained overload is not measurable
open-loop — a property of the scenario, recorded before). The per-scenario
guards were initially dropped because the orchestrator ran a pre-fix
analysis build; egernia_bench reclassify re-derived them from the stored
artefacts, which is what it exists for. And the bottleneck verdicts read
UNKNOWN on the maxed-out scenarios because the executor-CPU rule compares
the fleet's usage against its peak ready count over the whole window — an
autoscaled fleet ramps, so the ceiling is overstated mid-ramp; package 15.
2026-08-24 — the four service-side answers shipped¶
Not a measurement — the response to what the autoscaling and fixed-scaling
families found (runs 20260824T074332Z-b4fa9d64-keda,
20260824T102832Z-29507cbb-keda, 20260824T014320Z-a5058118-fixed-scaling),
so the log says what changed as well as what was found:
- The saturating signal is replaced. The autoscaler read
max(tap_oldest_queued_job_seconds)/60— the age of the queue's head, which tops out near one job's service time as soon as the queue drains at all. The measured worst case: 1,713 jobs queued, the oldest 54 s old, one pod asked for; every overload profile got three pods out of a permitted eight. Retuning cannot fix a signal that saturates, so the chart's ScaledObject and the external-metric path now scale on queue depth,tap_jobs{phase="QUEUED"}, againstqueuedJobsPerReplica(default 10 ≈ 5 s of queue at ~2 jobs/s per executor). The old seconds-denominated values are refused with a message naming the replacements, and an empty queue reports depth 0 rather than letting the series vanish. The age gauge stays exported as a latency figure. - The executor's CPU limit stops lying. One executor is one thread running one job at a time; under sustained load it pins at 0.95–0.97 of a core with zero CFS throttling, so its former 2-core limit was headroom that cannot exist. The benchmark deployment's limit is now 1, and the chart documents the one-core ceiling. Replicas — via the depth-based autoscaler — are the executor's scaling axis.
- Routing becomes measurable. Every API response carries
X-Served-By(the pod name), so which replica served a request is stated rather than inferred. The verification run above then closed the question: the old 83–243 s "routing" stage was the queue; Ready-to-serving is 3.6–10.5 s. - Overload can refuse instead of resetting.
tapApi.backlogsizes the accept queue andtapApi.limitConcurrencymakes uvicorn answer 503 past a per-worker connection ceiling — off by default until the reset onset is established, which is package 13.
2026-08-24 — cross-size comparisons made before the corpus fix are not size effects¶
The corpus used to be rebuilt per dataset tier, so each size was measured with a different set of queries and the provenance hash described only the last one built. It is built once per run now, sized to the smallest tier — but every cross-size throughput comparison taken before the fix, including the published "throughput versus database size" and the earlier "five times the data costs about a fifth of the throughput" claim, compares different workloads and should not be read as a size effect. Everything measured within one dataset stands: saturation points, bottleneck classifications, cache hit ratios, per-class latencies.
2026-08-23 — the API is still the constraint, at both sizes¶
After the translation fix the normal mix remains TAP_CPU_BOUND on D1 (2 GiB)
and D2 (10 GiB) alike: PostgreSQL sits near idle for it. Saturation moved from
four concurrent clients on D1 to eight on D2. So replicas and tapApi.workers
remain the throughput lever.
Corrected 2026-08-25: this finding also said each worker was "now worth
roughly 200 requests/s rather than 20". It is worth roughly 100 — 96 to 100 rps
across the ten rungs of 20260825T163627Z-034d69ba, and 98.9 rps at C1 in
20260824T235130Z-ccbcb41a before that. The 200 was never measured on a
single-replica rung; it is withdrawn, and the rest of the finding stands.
Package 19 — Workers against replicas, on one host¶
The replica curve (1.00 / 0.96 / 0.88 / 0.73 at eight,
20260824T235130Z-ccbcb41a) was measured at one worker per pod against a pod
whose CPU limit is 2, so every point in it was half-idle by construction — and
the chart still defaults tapApi.workers: 1. Two pages carry an explicit note
that the worker figures predate the translation fast path and are being
re-measured (Autoscaling, Deployment); they
have not been. Until they are, an operator has a measured answer for the axis
that costs pods and a stale one for the axis that costs nothing.
Work, benchmark-side: sweep tapApi.workers (1, 2, 4) within one pod at its
CPU limit against the existing replica ladder, same host, same corpus, same
workload seeds — and state the connection arithmetic each choice implies,
since a pod's pool ceiling is workers x dbPoolMax and the two axes are
therefore not interchangeable at the database.
Resolution is shown by a capacity figure per (workers, replicas) point,
each bracketed by a saturated sweep the way the replica ladder now is; the
stale notes in autoscaling.md and deployment.md replaced by those numbers;
and a stated default for tapApi.workers that follows the pod's CPU limit if
the measurement supports one.
Package 20 — What one async job actually costs¶
The chart puts an executor at ~2 jobs/s (values.yaml, tapExecutor.replicas
note) where the API serves ~98 rps of the same mix: a job costs about fifty
times a sync request, and no finding here has ever profiled one. The
autoscaling packages measured how many executors get scheduled and how fast a
queue drains; none of them asked what the ~500 ms goes into. The answer
decides whether "eight executors for ~22 jobs/s" is physics or overhead, and
async is the path a client takes for exactly the queries where cost is real.
One term is visible without measuring anything. worker.py calls
touched_tables(job["query_sql"]) once per job — a full
PostgreSQLQueryProcessor parse of the translated SQL, which is the second
parse the API removed from its own path in 4e520b5. adql.py records that
cost at 39 ms against 14 ms for the translation itself, and the function's
docstring concedes the executor "pays a full parse, once per job". The API
already has the ADQL table list from the parse it does at submit time
(translate() returns tables), so the executor can read it off the job row
instead of deriving it again.
Work: profile one job end to end — claim, parse, execute, write the result file to the ReadWriteMany volume, phase transitions — then remove the terms that turn out to be avoidable, starting with the per-job parse.
Resolution is shown by a per-job cost breakdown published as a finding, the table list carried on the job rather than re-parsed, and a before/after jobs-per-executor figure from the autoscaling family on the same scenarios.
Package 22 — A text column in a geometry predicate¶
s_region is the ObsCore standard's footprint column and it holds STC-S
text. The queryable companion is s_region_geom, a pgsphere spoly the
ingest pipeline derives at ingestion (package 7), and it is non-standard by
construction — std = 0 in TAP_SCHEMA. So the column every ObsCore tutorial,
every VO client and every worked example names is the one that cannot be
queried spatially, and the one that can is the one nobody has heard of.
That would be a documentation problem if the service refused the standard
spelling clearly. It does not. The sentinel substitution in
egernia_core.query.adql puts any bare identifier found in an
INTERSECTS/CONTAINS argument slot behind a geometry literal and swaps the
column name back into the SQL, without consulting the column's type — because
translate() is deliberately pure and has no TAP_SCHEMA to consult. The result
translates happily and then dies in PostgreSQL:
1 = INTERSECTS(s_region, CIRCLE('ICRS', 150.0, -30.0, 0.5))
-> s_region && scircle(spoint(RADIANS(150.0), RADIANS(-30.0)), RADIANS(0.5))
-> ERROR: operator does not exist: text && scircle
Verified against pgsphere. What a client receives is a DALI error document carrying a PostgreSQL operator name — a server-fault shape for what is a usage error, naming neither the column at fault nor the one to use instead. The first spatial query a new user writes is the one that produces it.
Work: the translator half is already done. The ADQL 2.1 work exposes
Translation.geometry_columns — a frozenset of every column reference the
translator accepted in a geometry slot, named as the caller wrote it — so what
is left here is purely the API layer: prepare_query reads that set, checks
each name's type against the TAP_SCHEMA it already has cached, and refuses a
non-geometry column with a usage error naming s_region_geom. This package is
sequenced after that one rather than absorbed into it, so it begins with the
hook in place.
The type lookup belongs in prepare_query rather than in translate(), and
that division is why the hook is a set of names rather than a check: the query
path already reads TAP_SCHEMA for the publication check and for column
metadata, both cached, while the translator is the hot path package 18 measured
at 3.19 ms — 30.9% of a request — and must stay free of I/O.
One decision to take rather than default: do not silently rewrite s_region
to s_region_geom. Answering a different query than the one asked is worse
than refusing clearly, and the two are not equivalent — the companion is
nullable, so a row whose STC-S failed to convert at ingestion is present under
one column and absent under the other. A rewrite would quietly drop those rows
from every result.
Not in scope, and worth stating so the boundary is clear: the ADQL region
algebra — UNION, INTERSECTION, AREA, CENTROID, DISTANCE, COORD1 —
is rejected by the grammar outright (QueryParseError, not a translation
fault), and belongs to the ADQL 2.1 work (package 21). Nor is this work wasted
when the sentinel substitution is deleted, as that work deletes it: a grammar
that accepts a geometry column directly still has to refuse a text one, so
the check survives the sentinel's removal and only changes where it gets its
list of candidate columns from. This section is therefore written to stand
whether package 21 is open or delivered.
Resolution is shown by the standard spelling answered with a usage error
that names s_region_geom, at the right HTTP status for a client error rather
than a server fault; a test per predicate and argument order (INTERSECTS and
CONTAINS, column first and column second); the same refusal for any other
text column reached the same way, not a special case for s_region; and the
translation hot-path benchmark unmoved, since the check costs a set lookup over
a handful of names against an already-cached table.