Feat/multi statement query support - #1275
Conversation
Previously, batches mixing SET with other statement types raised an error (MultiStatementMixedSet), and batches of non-SET statements were routed using only the first statement. Both cases now route the full batch to the write primary, letting the backend execute all statements correctly. Closes pgdogdev#395
Replace tests that expected MultiStatementMixedSet errors with tests that verify multi-statement batches succeed. Add test_multi_statement_select to confirm both result sets are returned for 'SELECT 1; SELECT 2'.
|
@levkk :) First of thank you for pgcat and pgdog! |
Uses stmt_location/stmt_len from the SQL parser (same fields used by pg_dump.rs) to extract individual statement substrings and wrap each in its own ClientRequest. Returns empty vec for single-statement or non-Query messages so callers fall through unchanged.
Covers a BEGIN/DELETE/INSERT/COMMIT batch sent as a single simple query message, verifying all four statements execute and the inserted row is visible after commit.
sgrif
left a comment
There was a problem hiding this comment.
I don't think this handles the implicit transactional behavior of a multi-statement query. Can you add some integration tests for that?
| pub use super::BufferedQuery; | ||
|
|
||
| #[cfg(feature = "new_parser")] | ||
| fn split_statements(query: &str) -> Vec<(usize, usize)> { |
There was a problem hiding this comment.
Should the signature of this be &str -> Vec<&str>?
| #[cfg(feature = "new_parser")] | ||
| fn split_statements(query: &str) -> Vec<(usize, usize)> { | ||
| let Ok(parsed) = pg_raw_parse::parse(query) else { | ||
| return vec![]; |
There was a problem hiding this comment.
Why are we ignoring errors here? Do you think we should propagate them?
There was a problem hiding this comment.
I updated the PR description for more context and my decision on this. Let me know how u feel
| } | ||
|
|
||
| // | ||
| // Get the root AST node. |
There was a problem hiding this comment.
Now that we are relying on there only being one query per command, should we return an error if we got more than one?
There was a problem hiding this comment.
I addressed this in the PR description...let me know what u think
|
|
||
| let stmts = &statement.parse_result().protobuf.stmts; | ||
|
|
||
| // Handle multi-statement SET commands (e.g. "SET x TO 1; SET y TO 2"). |
There was a problem hiding this comment.
The old parser is considered legacy code, new features don't need to support it.
There was a problem hiding this comment.
ah awesome didn't know that. This makes perfect sense. Will remove this!
| .collect() | ||
| } | ||
|
|
||
| #[cfg(not(feature = "new_parser"))] |
There was a problem hiding this comment.
New features don't need to support the old parser.
@sgrif thanks for your review and the is the biggest point you mentioned. I was hoping to see tests run on CI to see if I was failing anything but managed to finally run them on my machine and yeah saw that this definitely broke things...taking a different approach but will ping you all soon |
Replace the pg_raw_parse-based (feature-gated) split_statements with a pg_query-based version that works in default builds. The default parser exposes the same stmt_location/stmt_len fields, so no behaviour changes. Also simplifies split_statements to return &str slices directly instead of (offset, len) pairs, removing duplicate boundary-calculation logic in spliced_simple.
…ry splice When a multi-statement simple query is split into individual requests, all but the last must hold back the ReadyForQuery(Z) message so the client sees exactly one Z at the end of the batch — matching standard PostgreSQL simple query protocol semantics. - QueryEngineContext gains simple_query_splice flag and requests_left counter set by spliced_simple_query() builder - query.rs: suppress Z forwarding from the backend while requests remain - fake.rs: suppress synthetic Z in fake_command_response (handles SET) - start_transaction.rs: suppress synthetic Z for BEGIN - end_transaction.rs: suppress synthetic Z for COMMIT/ROLLBACK - mod.rs: track is_simple_splice to skip implicit-transaction injection that would force incorrect shard routing for split simple queries
Add tests covering: - SELECT 1; SELECT 2 returns results in order (with dedup for sharded connections that fan out to multiple shards) - BEGIN; DELETE; INSERT; COMMIT batch commits atomically and rows are visible after commit (uses a real table instead of TEMP to survive transaction-mode pool backend switching) - INSERT; INSERT batch with a PK conflict documents per-statement auto-commit behaviour: the first INSERT commits before the second fails Switch test tables from TEMP to persistent (CREATE TABLE IF NOT EXISTS + TRUNCATE) so transaction-mode pooling can route setup and batch queries to different backends without losing visibility.
d766017 to
3bb00ce
Compare
|
OK @sgrif , thank u for catching the implicit transaction behavior! Force me to make sure integration tests pass locally before making further changes. I think I took @levkk 's initial comments in a Discord thread a bit too literally trying to share some code eventhough his comment was more about approach. I gave @levkk a bit more context in the Discord channel but I updated the PR description with what motivated this PR to begin with. @sgrif @levkk would love a review/comments/feedback. FYI if you all feel this is way off base or conflicts with the future decisions that I'm completely unaware please let me know. Totally dogfooding this because we have a big PG upgrade migration that we really want to test out and use pgdog with to introduce it to our toolset not just for this but other purposes too (maybe sharding too but unsure of that currently) |
| /// Split a multi-statement simple query string into individual statement slices. | ||
| /// Returns an empty vec if parsing fails or the query contains only one statement. | ||
| fn split_statements(query: &str) -> Vec<&str> { | ||
| let Ok(parsed) = pg_query::parse(query) else { |
There was a problem hiding this comment.
Sorry, I should have been more clear. This shouldn't use pg_query with the old parser, we can just feature flag the change behind the new parser for now.
There was a problem hiding this comment.
Ok, updated spliced_simple(), split_statements(), and the related unit tests to all be gated behind #[cfg(feature = "new_parser")] using pg_raw_parse. On a default build spliced_simple() returns empty vec and callers fall through to the existing path.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Use pg_raw_parse (new parser) for statement splitting and guard split_statements, spliced_simple, and related unit tests behind #[cfg(feature = "new_parser")]. The no-feature path returns vec![] so callers fall through to the existing single-statement path unchanged.
…r stub Add test_end_not_connected_suppress_rfq to exercise the branch where ReadyForQuery is suppressed for intermediate simple query splice statements. Add test_spliced_simple_returns_empty_without_new_parser to cover the cfg(not(new_parser)) spliced_simple stub.
The `MultiStatementMixedSet` error was removed — mixed batches now route to the write primary rather than error. Update the Sequelize test to assert the query succeeds instead of asserting the old error message.
4569096 to
ead6ed3
Compare
Context and motivation
At my company we're using PgDog as part of a PostgreSQL version upgrade migration strategy. We have a polyglot environment with services across different languages, ORMs, query builders, and driver versions. Some have hard dependencies we can't control (in our case, Kong) that generate raw multi-statement simple queries we couldn't inspect or modify. The goal was safety in the read path without having to audit every query generated across every language and library. We're not using sharding, so the intention was to keep changes minimal there.
What changed
MultiStatementMixedSet— mixed batches no longer errorClientRequest::spliced_simple()behind#[cfg(feature = "new_parser")]that splits aQmessage into per-statement requests usingstmt_location/stmt_lenfrompg_raw_parsespliced_simple()into the client loop afterspliced(), so simple-query batches go through the same splice processing path as extended-protocol pipelines when the new parser is enabledsimple_query_splicecontext flag andsuppress_rfqlogic in four places (query.rs,fake.rs,start_transaction.rs,end_transaction.rs) to hold back intermediateReadyForQuerymessages so the client sees exactly oneZat the end, matching simple query protocol semanticsSETbatches continue to be intercepted by PgDog's parameter tracking; single-statement queries and parse failures fall through unchangedDefault builds (without
new_parser): multi-statement batches route to the write primary as a whole.spliced_simple()returns empty vec and callers fall through.new_parserbuilds: full per-statement splitting withSETinterception and correctReadyForQueryhandling.On the complexity
The
suppress_rfqwork across four handlers is more invasive than I originally wanted. It was necessary becauseSET,BEGIN, andCOMMITall have code paths that generate syntheticReadyForQueryresponses without going through the backend, so each one needed the same fix. The motivation for splitting rather than forwarding verbatim is that libraries we can't control frequently send batches likeSET statement_timeout TO '30s'; SELECT ...as part of connection setup. If PgDog forwards the wholeQverbatim it never sees theSET, and when that backend connection gets returned to the pool the next client inherits stale parameter state silently.If you feel this is too much complexity for what's being solved, or there are implications for the broader codebase I'm not seeing, happy to scale this back to just the routing fix.
On @sgrif's review comments
new_parserfeature flag:spliced_simple(),split_statements(), and the related unit tests are now all gated behind#[cfg(feature = "new_parser")]usingpg_raw_parse. Default builds fall through unchanged.&str -> Vec<&str>signature: done,split_statementsreturns slices directly nowsplit_statements: parse failures fall through to the single-statement path, which forwards to the backend and lets Postgres return the error to the client. Feels safer than surfacing a proxy-level parse error for something the backend might handle finenew_parseris enabled.spliced_simple()handles the split upstream before routing runs so each statement routes individually. Thestmts.len() > 1guard in the router is now just a fallback for parse failures, session mode, and default buildstest_multi_statement_implicit_transaction_behaviordocuments this as a known divergence from standard PostgreSQL behaviorTesting
I don't have permissions to run CI on this repo so I iterated locally. I ran the full Rust integration suite (
cargo nextest run --profile integration) and the Go, Python, and SQL suites directly against a locally built binary. It took a bit of work to get local integration testing set up properly but wanted to make sure I wasn't breaking anything as I iterated. The failures I saw were all pre-existing environment issues (sharded pool config, unique_id node config, bundler version mismatch) and unrelated to these changes. The 9 new multi-statement tests all pass and I didn't see regressions in the suites I was able to run.Note: the splitting behavior and new tests only activate under
new_parser. The default build integration tests exercise the routing-only path.