Skip to content

Commit 1343e40

Browse files
committed
fix: buffer Close messages in extended query protocol
Buffer Close ('C') messages in ExtendedQueryProtocolHandler until a Flush or Sync message is received, matching the behavior of other extended query protocol messages. Previously, Close messages were executed eagerly upon receipt. In pipelined batches (such as Parse + Close + Sync or Execute + Close + Sync), this caused CloseComplete ('3') to be returned to the client before responses to earlier messages in the batch. This broke connection poolers (such as PgBouncer in transaction pooling mode) and client drivers that track in-flight requests in FIFO order. Statements and portals are unregistered from ConnectionHandler when buffering so that subsequent messages in the same pipeline can reuse the name. Statement closure and CloseComplete response emission are deferred to flush. If an earlier message in the pipeline fails, buffered messages are aborted in reverse (LIFO) order, restoring any statement or portal whose Close was skipped.
1 parent 0af9236 commit 1343e40

6 files changed

Lines changed: 886 additions & 15 deletions

File tree

‎src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,15 @@ public void closePortal(String portalName) throws Exception {
715715
portal.close();
716716
}
717717

718+
/**
719+
* Unregisters a portal from the connection map without closing it. Returns the unregistered
720+
* portal, or null if no portal was registered with this name. Unlike {@link
721+
* #closePortal(String)}, this method does not throw an exception if the portal is not found.
722+
*/
723+
public IntermediatePortalStatement unregisterPortal(String portalName) {
724+
return this.portalsMap.remove(portalName);
725+
}
726+
718727
public boolean hasPortal(String portalName) {
719728
return this.portalsMap.containsKey(portalName);
720729
}
@@ -848,6 +857,16 @@ public void closeStatement(String statementName) {
848857
this.statementsMap.remove(statementName);
849858
}
850859

860+
/**
861+
* Unregisters a prepared statement from the connection map without closing it. Returns the
862+
* unregistered statement, or null if no statement was registered with this name. Unlike {@link
863+
* #closeStatement(String)}, this method does not throw an exception if the statement is not
864+
* found.
865+
*/
866+
public IntermediatePreparedStatement unregisterStatement(String statementName) {
867+
return this.statementsMap.remove(statementName);
868+
}
869+
851870
public void closeAllStatements() {
852871
Set<String> names = new HashSet<>(this.statementsMap.keySet());
853872
for (String statementName : names) {

‎src/main/java/com/google/cloud/spanner/pgadapter/statements/ExtendedQueryProtocolHandler.java‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public class ExtendedQueryProtocolHandler {
4848
private static final Logger logger =
4949
Logger.getLogger(ExtendedQueryProtocolHandler.class.getName());
5050

51-
private final LinkedList<AbstractQueryProtocolMessage> messages = new LinkedList<>();
51+
private final List<AbstractQueryProtocolMessage> messages = new LinkedList<>();
5252
private final ConnectionHandler connectionHandler;
5353
private final BackendConnection backendConnection;
5454

@@ -210,7 +210,11 @@ private void flushMessages(boolean includeReadyResponse) throws Exception {
210210
Logging.format(
211211
"Flushing message", Action.Finished, () -> String.format("Message: %s", message)));
212212
if (message.isReturnedErrorResponse()) {
213-
for (int j = i + 1; j < messages.size(); j++) {
213+
// Abort remaining messages in reverse (LIFO) order to correctly unwind state mutations in
214+
// the opposite order that they were registered/buffered (e.g. if a pipeline contains a
215+
// Close followed by a Parse/Bind reusing the same statement or portal name, or both
216+
// creation and closure of a statement in the same aborted pipeline).
217+
for (int j = messages.size() - 1; j > i; j--) {
214218
messages.get(j).abort();
215219
}
216220
break;

‎src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/CloseMessage.java‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@
1717
import com.google.api.core.InternalApi;
1818
import com.google.cloud.spanner.pgadapter.ConnectionHandler;
1919
import com.google.cloud.spanner.pgadapter.error.PGException;
20+
import com.google.cloud.spanner.pgadapter.statements.BackendConnection;
21+
import com.google.cloud.spanner.pgadapter.statements.IntermediatePortalStatement;
22+
import com.google.cloud.spanner.pgadapter.statements.IntermediatePreparedStatement;
2023
import com.google.cloud.spanner.pgadapter.statements.IntermediateStatement;
2124
import com.google.cloud.spanner.pgadapter.wireoutput.CloseCompleteResponse;
2225
import java.text.MessageFormat;
2326
import javax.annotation.Nullable;
2427

2528
/** Close the designated statement. */
2629
@InternalApi
27-
public class CloseMessage extends ControlMessage {
30+
public class CloseMessage extends AbstractQueryProtocolMessage {
2831

2932
protected static final char IDENTIFIER = 'C';
3033

@@ -51,19 +54,53 @@ public CloseMessage(ConnectionHandler connection) throws Exception {
5154
this.statement = statement;
5255
}
5356

54-
/** Close the statement server-side and clean up by deleting their metadata locally. */
5557
@Override
56-
protected void sendPayload() throws Exception {
58+
void buffer(BackendConnection backendConnection) throws Exception {
59+
// Unregister the statement or portal immediately from the connection map so that subsequent
60+
// messages in the same pipeline (e.g. a Parse or Bind reusing the same name) will see the name
61+
// available, or attempts to use this closed statement will fail.
62+
// Resource cleanup (statement.close()) is deferred until flush() so that:
63+
// 1. If an earlier message in the pipeline fails, abort() can restore the statement.
64+
// 2. Preceding messages in the pipeline (e.g. Execute) can finish streaming rows before
65+
// closure.
5766
if (this.statement != null) {
5867
if (this.type == PreparedType.Portal) {
59-
this.statement.close(); // Only portals need to be closed server side, since PS is not bound
60-
this.connection.closePortal(this.name);
68+
this.connection.unregisterPortal(this.name);
6169
} else {
62-
this.connection.closeStatement(this.name);
70+
this.connection.unregisterStatement(this.name);
6371
}
6472
}
65-
CloseCompleteResponse.send(this.outputStream);
66-
this.outputStream.flush();
73+
}
74+
75+
@Override
76+
public void flush() throws Exception {
77+
try {
78+
if (this.statement != null) {
79+
this.statement.close();
80+
}
81+
CloseCompleteResponse.send(this.outputStream);
82+
} catch (Exception exception) {
83+
handleError(exception);
84+
}
85+
}
86+
87+
@Override
88+
public void abort() {
89+
// Only restore the statement or portal if it did not fail during creation (e.g. an
90+
// InvalidStatement created by a failed Parse or Bind message must not be resurrected).
91+
if (this.statement != null && !this.statement.hasException()) {
92+
if (this.type == PreparedType.Portal) {
93+
this.connection.registerPortal(this.name, (IntermediatePortalStatement) this.statement);
94+
} else {
95+
this.connection.registerStatement(
96+
this.name, (IntermediatePreparedStatement) this.statement);
97+
}
98+
}
99+
}
100+
101+
@Override
102+
public String getSql() {
103+
return this.statement == null ? "" : this.statement.getSql();
67104
}
68105

69106
@Override

0 commit comments

Comments
 (0)