perf(database): reuse prepared statements on the inference hot path - #1046
PierreLeGuen wants to merge 12 commits into
Conversation
The provider (TEE) and gateway signature paths each stored the ecdsa and ed25519 signatures with two separate INSERT ... ON CONFLICT statements. On the streaming path this runs before the client receives [DONE], so on a replica far from the database every statement is a user-visible round trip (verification p50 is 38 ms on cpu01 and 377 ms on cpu02). AttestationRepository gains add_chat_signatures with a default one-by-one implementation, and the Postgres repository overrides it with a single multi-row upsert using the same conflict clause. Both callers collect the signatures first and write once.
tokio-postgres prepares a raw SQL string on every call, so each query on the chat-completions path was two network round trips: Parse/Describe, then Bind/Execute. The eleven statements that run for every inference request (API-key auth and workspace/organization lookup, per-key spend, staking source, balance, limits, model resolution, model-by-id, the billing transaction and the chat-signature upsert) now go through deadpool's per-connection statement cache, which makes each of them one round trip. On the cpu02 replicas, ~61 ms from the Postgres leader, that is roughly 0.6 s less database waiting per request. A cached statement cannot survive a change to its result shape: after a rolling deploy migrates the schema, a replica still on the old release gets SQLSTATE 0A000 "cached plan must not change result type" on its SELECT * statements. The CachedStatements helpers drop the stale statement from the connection cache on that error and map_db_error classifies it as retryable, so retry_db! re-prepares and re-runs it. An e2e test adds a column to workspaces under warm caches and checks that requests keep succeeding; it fails with a 500 when the recovery is disabled.
Review · Status🟩 CompletedIronLoop completed the review and posted it to GitHub. ResultRun detailsAutomatic trigger · attempt 1 of 3 · completed in 4m 40s |
|
Review — prepared statement reuse on the inference hot path Solid, well-motivated change, and the rolling-deploy failure mode is the right thing to have thought about. Two issues I would want resolved before merge, both about the other ways a cached statement handle goes bad. (No prior review threads on this PR, so nothing to build on.)
PostgreSQL’s prepared-statement catalog is transactional: a named statement Parsed inside a transaction block is deallocated when that transaction rolls back. deadpool’s per-connection Failure scenario:
The same shape applies to any abort of that transaction (e.g. the Suggested fix — prepare before opening the transaction, so the Parse is not rolled back: // prepare_cached takes &client; do it before client.transaction() takes &mut
let insert_usage = client.prepare_cached(INSERT_USAGE_SQL).await.map_err(map_db_error)?;
let upsert_balance = client.prepare_cached(UPSERT_BALANCE_SQL).await.map_err(map_db_error)?;
let transaction = client.transaction().await.map_err(map_db_error)?;
let maybe_row = transaction.query_opt(&insert_usage, &[...]).await.map_err(map_db_error)?;and, defensively, treat
After a migration adds a column to Cheap improvement: on a stale plan, clear that connection’s whole cache rather than one key — one error then repairs all 11 hot statements on the connection instead of one: Minor
Logging looks clean — the warn at |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e06400008e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Review · Summary
Found two actionable issues in the prepared-statement cache change.
Findings: 🟠 Medium 1 · 🟡 Low 1
Code-specific findings are attached to the diff.
Validation
- ✅ Captured CI evidence — The release build, unit tests, and integration tests passed in the captured pull-request evidence.
Review details
- Run:
8b85a34a-167e-4e3c-84a5-9a02593be041 - Attempts: 1
- A multi-row ON CONFLICT DO UPDATE raises 21000 when two rows of the same statement share the arbiter key; signing_algo comes from the backend, so the repository now collapses duplicates and keeps the last one, matching the previous one-at-a-time overwrite. Unit-tested. - The provider path stores whatever was fetched before propagating a later fetch error, so a backend that serves ecdsa but fails ed25519 still leaves the chat verifiable by ecdsa, as it did before.
…tion - A stale statement is evicted from every pooled connection's cache (deadpool StatementCaches::clear via the object's pool handle), so a retry on a different connection cannot trip over another stale copy. - Outside a transaction the statement is re-prepared and re-run once on the same connection, so the recovery does not consume retry_db! attempts. Inside a transaction the block is restarted by retry_db! after a pool-wide eviction (record_usage). - Detection keys on the PostgreSQL routine (RevalidateCachedQuery) with the English message as a fallback, so a non-English lc_messages does not disable the recovery; SQLSTATE 26000 (missing prepared statement) is handled the same way as a defensive measure. - The e2e test warms and probes concurrently so every connection of the test pool holds the stale plan; lint fix for the column-name format.
|
Addressed in the follow-up commit:
Full e2e set rerun locally (203 passed), clippy and fmt clean. |
lloydmak99
left a comment
There was a problem hiding this comment.
The prepared-statement caching and its recovery paths hold up: stale-handle errors (SQLSTATE 0A000/26000) are classified as retryable, evict the cache, and self-heal on both the non-transactional (run_cached! on Client) and transactional (record_usage → map_tx_error → retry_db!) paths. Recovery logic and error classification are consistent across all converted call sites. No merge-blocking issues found.
Optional follow-up:
crates/database/src/repositories/statement_cache.rs:60,:97— a transient per-connection26000triggers a pool-wideStatementCaches::clear(), re-Parsing all cached statements on every connection. It's self-correcting, but a per-connection clear (reserving the pool-wide clear for0A000) would avoid the extra round trips. Worth a comment on the trade-off; not required to merge.
Checks: git diff --check, cargo fmt --check, and GitHub lint/unit/integration/E2E/security all passed; cargo compilation couldn't run locally (no C linker), compensated by verifying the deadpool 0.14.1/0.12.3 API surface against vendored sources.
Reuses prepared statements for inference-path repository queries to avoid repeating the Parse/Describe round trip on warm connections. This branch includes #1045 so the new batched signature write uses the cache too. Merge #1045 first. The PR continues to target
mainso the repository's required CI workflows run.Stale result shapes and missing server-side statement handles invalidate caches throughout the pool. Nontransactional queries re-prepare and retry on the same connection; billing transactions roll back and restart through the existing bounded repository retry.
Validation includes PostgreSQL 16 regressions for:
Validation: 224 relevant PostgreSQL 16 E2E tests and 49 credit/usage unit tests passed with #1044, #1045, and #1046 combined, including all four cache regressions. Focused mutation checks confirm the billing regressions fail without pool-wide eviction, and the batch-reuse test fails with raw
execute. Repository unit tests, formatting, and strict Clippy passed; #1045's included service changes passed 168 attestation/completion tests.Deploy separately after #1044 and #1045, without an accompanying schema migration. Performance gains still need representative warm-traffic validation: PostgreSQL can select generic plans for reused statements, so monitor query/request latency, pool waits, and database load as well as cache-invalidation warnings.