Skip to content

fix(proxy): protocol desync on transformation error after a mapped statement (CIP-3678) - #441

Open
freshtonic wants to merge 3 commits into
mainfrom
james/cip-3678-protocol-desync-transformation-error-after-a-mapped
Open

fix(proxy): protocol desync on transformation error after a mapped statement (CIP-3678)#441
freshtonic wants to merge 3 commits into
mainfrom
james/cip-3678-protocol-desync-transformation-error-after-a-mapped

Conversation

@freshtonic

Copy link
Copy Markdown
Contributor

Fixes CIP-3678.

The bug

Once a connection had successfully run a mapped (encrypted) statement, any later statement failing during transformation surfaced to the client as a protocol error — tokio_postgres UnexpectedMessage — instead of the proxy's actual error, and the proxy's useful message was never delivered. With connection pools, mapped-then-failing is the common case.

Diagnosis

A wire trace of the reproduction (protocol-level logging on both sides of the proxy) shows the desync is a response-stream reorder, not a client- or state-machine bug:

  1. After query() returns, tokio_postgres drops its cached prepared statement and pipelines Close(s0) + Sync, immediately followed by the failing statement's Parse(s1), Describe, Sync.
  2. The frontend failed the Parse locally and wrote ErrorResponse + ReadyForQuery straight to the client, while the server's CloseComplete + ReadyForQuery for the Close were still in flight.
  3. The client received E, Z, CloseComplete, Z where the protocol requires CloseComplete, Z, E, Z. It attributed the error to the Close request and the Close's responses to the failed prepare — UnexpectedMessage.

On a fresh connection there is nothing in flight, so the direct reply happened to line up — which is why the bug only appeared after a prior statement. The ticket's stale-session hypothesis was a real (smaller) leak — the failed Parse's session-metrics entry was abandoned — but it was not the cause of the desync.

The fix

The frontend no longer replies to the client out of band. A failed Query/Parse/Bind is replaced with a DO ... RAISE EXCEPTION statement sent to the server, carrying the proxy's message, SQLSTATE, hints and related fields via RAISE ... USING. Because the injected statement is a request like any other, the server emits the ErrorResponse and ReadyForQuery in exactly the slot the client expects — after any in-flight responses, with accurate transaction status. The rest of the failed batch, including its Sync, is discarded (the injected statement already produced the batch's ReadyForQuery).

This also drains the session the failed statement had started, through the backend's normal ErrorResponse completion path, instead of leaking it.

Two deliberate carve-outs:

  • FATAL errors (e.g. SET CIPHERSTASH.KEYSET_ID with a configured default keyset) are still written directly to the client: RAISE cannot produce FATAL severity, and ordering is moot because the client abandons the connection — and everything pipelined on it — on receipt. ErrorState is now an enum making the two delivery modes and their differing Sync handling explicit.
  • The exception body is nested through quote_literal at both levels (no dollar quoting), so error text containing quotes, % or $$ cannot escape the statement. The previous (dormant) to_database_exception helper had both hazards.

Also fixed along the way: parse_handler recorded the statement's session mapping and then immediately wiped it via close_statement (which drops the mapping for the rebound name). Every Bind then hit the "Session lookup failed for prepared statement, using latest session" fallback. The close now happens before the mapping is recorded.

Regression tests

extended_protocol_error_messages::tests:

  • transformation_error_after_mapped_statement — mapped warm-up, then SELECT id FROM encrypted WHERE encrypted_bool = $1 (storage-only column, no equality term). Asserts the error arrives as a proper db error containing the proxy's message, and that the connection remains usable afterwards. Fails with UnexpectedMessage against the unfixed proxy; passes with the fix.
  • transformation_error_after_passthrough_statement — same failing statement on a passthrough-warmed connection (this desynced locally too, timing-dependent; now deterministic either way).

Also adds common::proxy_port() (CS_PROXY__PORT, default 6432) so a local run can target a proxy on a non-default port.

Test evidence

Run locally against a proxy built from this branch (port 6533, CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS=true, shared dev postgres on 5532):

  • mise run check — clean (fmt, clippy, compile).
  • mise run test:unit — all green (258 result lines, 0 failures).
  • Both new regression tests: fail (UnexpectedMessage) against the unfixed frontend, pass with the fix.
  • Integration slice (extended_protocol_error_messages, set_keyset_error, simple_protocol, pipeline, passthrough, map_params, 34 tests): 27 pass. The 7 failures were re-run against an unfixed build of the same branch and fail identically there — pre-existing in this environment, not regressions:
    • multitenant::ore_order::* (3) — require a proxy with no default keyset (CI runs them in a separate phase).
    • passthrough_{invalid_statement,insert_from_select,select_with_cardinality} and encrypted_column_not_defined_in_schema — expect type-check failures to fall through to the database; in this environment (shared dev DB, EQL 3.0.3 installed vs 3.0.4 built) the mapper rejects them before the DB does, on both builds.
  • Not run: the full integration suite and the Python/Go language suites (the standard port 6432 was occupied by an unrelated process on this machine, so everything above ran against a dedicated instance on 6533; the language suites hardcode 6432). CI should exercise these.

Notes for review

  • Client-visible change on non-FATAL proxy errors: the ErrorResponse now originates from PostgreSQL's RAISE, so where/file/line/routine fields point at the inline_code_block rather than being absent, and a Position field is no longer set (parse errors already embed line/column in the message). Message text, SQLSTATE, severity, hint, table/column fields are preserved via RAISE ... USING.
  • A failed statement inside an open transaction now genuinely aborts the server-side transaction (matching vanilla PostgreSQL), where previously the proxy refused client-side and left the server transaction open while the client believed it had failed.
  • The injected simple-protocol Query destroys the server's unnamed prepared statement/portal (protocol-defined side effect). A client that re-binds a previously parsed unnamed statement across a failed batch would need to re-Parse; this was also the proxy's behaviour before the direct-write error path was introduced (ab60e96, Feb 2025).

…esponse stream ordered

Once a connection had run a mapped (encrypted) statement, a later
statement failing during transformation surfaced to the client as a
protocol error (tokio_postgres UnexpectedMessage) instead of the
proxy's actual error message (CIP-3678).

The frontend wrote its ErrorResponse and ReadyForQuery straight to the
client the moment a Parse/Bind/Query handler failed. Responses to
earlier pipelined requests could still be in flight from the server —
in the reproduction, the CloseComplete + ReadyForQuery for the Close +
Sync that tokio_postgres issues when it drops the previous statement.
The direct reply overtook them, so the client attributed the error to
the Close and the Close's responses to the failed prepare, shifting
every subsequent response onto the wrong request. A wire trace shows
the client receiving E, Z, 3(CloseComplete), Z where it needed
3, Z, E, Z.

Instead of replying out of band, the frontend now replaces the failed
message with a 'DO ... RAISE EXCEPTION' statement carrying the proxy's
message, SQLSTATE and hints via RAISE USING. The injected statement is
a request like any other, so the server emits its ErrorResponse and
ReadyForQuery in exactly the slot the client expects, with accurate
transaction status; the batch's remaining messages, including Sync, are
discarded. This also drains the session started by the failed
statement through the backend's normal ErrorResponse completion path,
instead of leaking it. FATAL errors (for example SET CIPHERSTASH.KEYSET
with a configured default keyset) are still written directly: RAISE
cannot produce FATAL, and ordering is moot once the client abandons the
connection.

Also fixes the ordering of close_statement/set_statement_session in
parse_handler, which wiped the statement-session mapping it had just
recorded and forced every Bind onto the latest-session fallback (the
'Session lookup failed for prepared statement' warnings).

The exception body is nested through quote_literal at both levels — no
dollar quoting — so error text containing quotes, % or $$ cannot
escape the statement.

CIP-3678
… statements

Regression tests for CIP-3678: on a connection that has already run a
mapped (encrypted) statement, a statement that fails during
transformation must surface as a clean db error carrying the proxy's
message — not desync the stream into UnexpectedMessage — and the
connection must remain usable afterwards. The passthrough-warmed
variant keeps the previously-working path covered.

Adds a proxy_port() helper (CS_PROXY__PORT, defaulting to 6432) so a
local run can target a proxy on a non-default port, mirroring
get_database_port().

CIP-3678
@freshtonic
freshtonic requested a review from tobyhede August 4, 2026 05:55
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@freshtonic, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e51cf64b-3720-4c18-ae49-731d812c5bb0

📥 Commits

Reviewing files that changed from the base of the PR and between 15b7f99 and ff903c2.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • packages/cipherstash-proxy-integration/src/common.rs
  • packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs
  • packages/cipherstash-proxy/src/postgresql/frontend.rs
  • packages/cipherstash-proxy/src/postgresql/messages/error_response.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment-only review.\n\nThe ordering strategy—injecting the error through PostgreSQL and distinguishing injected exceptions from directly-sent fatal errors—makes sense, and the close-before-session-recording fix is important.\n\nFinding: the two regression tests added by this PR, and , currently fail in every PostgreSQL matrix job (14–17). Because those tests directly exercise the behavior this PR is intended to fix, this is not a check failure I would treat as incidental; the test/runtime mismatch needs investigation before merge.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my preceding review (shell formatting stripped identifiers): the failing regression tests are transformation_error_after_mapped_statement and transformation_error_after_passthrough_statement.

… not a transformation error

The two regression tests drove their proxy-side failure with an equality
predicate on the storage-only eql_v3_boolean column. That only errors when
CS_DEVELOPMENT__ENABLE_MAPPING_ERRORS is on: with it off the type-check
failure silently falls back to passthrough, PostgreSQL describes $1 as the
raw eql_v3_boolean domain, and tokio-postgres fails client-side with
WrongType before Bind — the injected-exception path is never exercised.

The CI proxy containers run with that flag off: tests/mise.tcp.toml and
tests/mise.tls.toml set it to true, but tests/docker-compose.yml never
forwards the variable, so the containers get the default (false). This is
also the production configuration.

Use a statement the proxy's SQL parser rejects instead (same shape as
invalid_sql_statement). A parse error takes the same failure path
(handle_statement_error) in every configuration. Verified against the
pre-fix frontend: both tests fail with Error { kind: UnexpectedMessage } —
the original CIP-3678 desync — and pass with the fix, with the flag both
on and off.

@tobyhede tobyhede left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have enough context for this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants