Fix Zero-Row SELECT Producing RowModifying Instead of ResultSet #63
Closed
SeanTAllen
started this conversation in
Research
Replies: 1 comment
-
|
this is addressed via #65 |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Change type: Fixed (bug fix)
Bug Description
When a
SELECTquery matches zero rows, the library delivers aRowModifyingresult to theResultReceiverinstead of aResultSetwith zero rows. This is incorrect:RowModifyingrepresents write commands (INSERT, UPDATE, DELETE) with an impacted-row count, whileResultSetrepresents row-returning queries regardless of how many rows they return.User-visible impact: code that pattern-matches on
Resultsubtypes to distinguish reads from writes gets the wrong branch for zero-row SELECTs. A user writingmatch result | let r: ResultSet => ...would never enter theResultSetbranch for a legitimateSELECT * FROM t WHERE false.Root Cause
In
_SessionLoggedIn.on_command_complete(session.pony, lines 190-195), the decision betweenResultSetandRowModifyinguses the accumulated data row count:When a SELECT returns zero rows,
rows.size()is 0, so theelsebranch executes and deliversRowModifying.PostgreSQL Protocol Context
The PostgreSQL simple query protocol distinguishes row-returning queries from non-row-returning queries via the
RowDescriptionmessage:Row-returning query (e.g., SELECT), zero results:
Row-returning query (e.g., SELECT), with results:
Non-row-returning query (e.g., INSERT):
The key signal is: PostgreSQL always sends
RowDescriptionbefore any row-returning command'sCommandComplete, even when zero rows are returned. Non-row-returning commands never sendRowDescription. The presence or absence ofRowDescriptionis the correct discriminator, not whether anyDataRowmessages followed.Fix
Change the discriminator from row count to row description presence
Replace
rows.size() > 0with a check for whether aRowDescriptionmessage was received for the current command.Type change: Change
_row_descriptionfromArray[(String, U32)] valto(Array[(String, U32)] val | None). This makes the "not received" state explicit rather than overloading an empty array, which could theoretically represent a row description with zero columns._row_descriptiontoNonein_SessionLoggedIn.createon_row_descriptioncontinues to set it tomsg.columns(no change). Add a comment onon_row_descriptiondocumenting the reset contract: any callback that terminates a command (on_command_complete,on_error_response,on_empty_query_response) must reset_row_descriptiontoNoneand consume_data_rows. This is the point of breakage — a future maintainer adding a new command-terminating callback would need to know about this contract.on_command_completematches on_row_description:Array[(String, U32)] valpresent: this is a row-returning query. Build aResultSet(even if theRowsobject contains zero rows). Reset_row_descriptiontoNone.None: this is a non-row-returning query. Build aRowModifying._data_rowsvia destructive read as before (no change to that mechanism)Reset
_row_descriptionbetween commandsCurrently
_row_descriptionis never reset. This is harmless today (the only consumer is_RowsBuilderwhich is only called whenrows.size() > 0), but with the new discriminator it becomes a correctness requirement.A multi-statement simple query like
"SELECT * FROM t; INSERT INTO t2 VALUES (1)"produces:If
_row_descriptionis not reset after the firstCommandComplete, the INSERT'sCommandCompletewould see a non-None_row_descriptionand incorrectly produce aResultSetinstead ofRowModifying.Reset
_row_descriptiontoNoneinon_command_completeimmediately after reading it, alongside the existing destructive read of_data_rows.Add validation for protocol-inconsistent state
The TODO comment at session.pony:179-189 identifies several consistency checks. With the new discriminator, two become straightforward:
_row_description_data_rowsResultSetResultSet(emptyRows)NoneRowModifyingNoneDataErrorThe fourth case (data rows arrived without a preceding row description) indicates a protocol violation. This should be reported to the receiver as
DataError.Note: the existing TODO comment says "we have row description but not rows. that's an error." This is incorrect — that case is precisely the zero-row SELECT, which is legitimate. The fix corrects this understanding.
_RowsBuilderwith zero rows_RowsBuilderalready handles the zero-row case correctly. Whenrows'is empty, the loop body never executes, and it returns aRowswrapping an emptyArray[Row]. TheRows.size()method returns 0. No changes needed in_RowsBuilder.Reset state in
on_error_responseWhen a query fails, PostgreSQL may send
RowDescriptionand/orDataRowmessages before theErrorResponse. For example, a function call in a SELECT could succeed for some rows and then error — the server would sendRowDescription, zero or moreDataRowmessages, thenErrorResponse, thenReadyForQuery. The currenton_error_response(session.pony:225-237) delivers the error to the receiver but does not consume_data_rowsor reset_row_description. This leaves stale state that would corrupt the next query's result classification.Add explicit resets in
on_error_response: consume_data_rowsvia destructive read (discarding the partial rows) and reset_row_descriptiontoNone. This matches the reset pattern inon_command_complete.on_empty_query_responsevalidationThe existing TODO at session.pony:210-211 notes that
_row_descriptionand_data_rowsshould be empty/unset whenEmptyQueryResponsearrives. With_row_descriptionnow being(Array[(String, U32)] val | None), this validation is straightforward: unconditionally consume_data_rowsand reset_row_descriptiontoNonefirst, then check whether the consumed values indicate a protocol violation (row description was notNoneor data rows was non-empty). If so, reportDataError. Otherwise deliverSimpleResultas today. The unconditional reset ensures state is clean regardless of which branch executes.Reset state in
on_ready_for_queryWhen
on_ready_for_queryfires with idle status, the query cycle is fully complete. Reset_row_descriptiontoNoneand_data_rowsto a fresh empty array. This is a safety net — all earlier callbacks (on_command_complete,on_error_response,on_empty_query_response) should have already reset these fields, but theon_ready_for_queryreset ensures no stale state leaks into the next query cycle if a future code change introduces a missed reset path.Test Plan
Unit test: zero-row SELECT via mock server
Add a unit test using the existing mock-server pattern (see
_JunkSendingTestServer,_DoesntAnswerTestServer). The mock server:AuthenticationOkReadyForQuery(idle)to make the session queryableRowDescription(one column, text type) +CommandComplete("SELECT 0")+ReadyForQuery(idle)— noDataRowmessagesThe test client sends a SELECT query and verifies:
ResultSet(notRowModifying)ResultSet.rows().size()is 0"SELECT"This tests the core fix without requiring a live PostgreSQL instance.
Integration test: zero-row SELECT against live PostgreSQL
Add an integration test that:
"SELECT 1 WHERE false"(guaranteed zero rows with known column structure)ResultSetwith zero rowsThis validates the fix against real PostgreSQL protocol behavior.
Integration test: multi-statement query with mixed result types
Add an integration test that issues a multi-statement query combining a zero-row SELECT with a write command, verifying that
_row_descriptionreset works correctly across command boundaries. For example:"SELECT * FROM tmp WHERE false; INSERT INTO tmp (col) VALUES ('x')"as a single queryResultSet(zero rows) and the second isRowModifying(1 impacted)Note: this test depends on the multi-statement query support that already exists in the simple query protocol path. The existing
_AllSuccessQueryRunningClienttest helper sends queries sequentially (one at a time, waiting for completion). This test would need a new test client that sends a single multi-statement query and collects multiple results.The test client receives both results via
pg_query_resultwith the sameSimpleQueryobject. It distinguishes them by call order: the first call corresponds to the SELECT, the second to the INSERT. Pony guarantees causal message ordering within an actor, and_ResponseMessageParserprocesses messages sequentially, so the delivery order matches the statement order. The client tracks a call counter and validates each result by index.Test commands
Files Modified
postgres/session.pony_row_descriptiontype to(Array[(String, U32)] val | None). Updateon_command_completeto use row description presence as discriminator, reset_row_descriptiontoNoneafter use, add protocol validation for data-rows-without-description. Add state resets inon_error_responsefor_row_descriptionand_data_rows. Updateon_empty_query_responseto validate and reset state. Add safety-net reset inon_ready_for_query. Remove resolved TODO comments.postgres/_test.ponypostgres/_test_query.ponyA new file may be needed if the unit test's mock server infrastructure is large enough to warrant separation, but ideally the mock server actors live alongside the existing mock servers in
_test.pony.Beta Was this translation helpful? Give feedback.
All reactions