From 942d238113495db0960954085e7b5983b8319212 Mon Sep 17 00:00:00 2001 From: Meemaw Date: Mon, 12 Oct 2026 14:31:09 +0200 Subject: [PATCH] Evict cached statements on schema change for tables with 10+ columns See #1751 Motivation: TABLE_SCHEMA_CHANGE_ERROR_MESSAGE_PATTERN matched the column counts with \d, a single digit, and String.matches requires a full match. So "bind message has 10 result formats but query has 11 columns" never matched, the stale cached statement was never evicted, and every later execution on the connection failed with the same error until the connection was closed. Postgres reports this error when a proxy such as RDS Proxy parses the named statement again after the schema change, before the client binds. Modifications: Match the column counts with \d+ and precompile the pattern. Add a test that uses a 10-column table, adds an 11th column, and re-prepares the named statement with DEALLOCATE / PREPARE to simulate the proxy. Result: The cached statement is evicted after the first failure, and the next execution succeeds. Assisted-by: Anthropic Claude (claude-opus-5-5) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Meemaw --- .../codec/ExtendedQueryPgCommandMessage.java | 6 ++-- .../pgclient/PreparedStatementCachedTest.java | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryPgCommandMessage.java b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryPgCommandMessage.java index 5d5673d54..e66c7b217 100644 --- a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryPgCommandMessage.java +++ b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryPgCommandMessage.java @@ -23,11 +23,13 @@ import io.vertx.sqlclient.codec.CommandResponse; import io.vertx.sqlclient.spi.protocol.ExtendedQueryCommand; +import java.util.regex.Pattern; + public class ExtendedQueryPgCommandMessage> extends QueryBasePgCommandMessage { private PgEncoder encoder; - private static final String TABLE_SCHEMA_CHANGE_ERROR_MESSAGE_PATTERN = "bind message has \\d result formats but query has \\d columns"; + private static final Pattern TABLE_SCHEMA_CHANGE_ERROR_MESSAGE_PATTERN = Pattern.compile("bind message has \\d+ result formats but query has \\d+ columns"); private PgPreparedStatement ps; @@ -100,6 +102,6 @@ public void handleErrorResponse(ErrorResponse errorResponse) { } private boolean isTableSchemaErrorMessage(ErrorResponse errorResponse) { - return errorResponse.getMessage().matches(TABLE_SCHEMA_CHANGE_ERROR_MESSAGE_PATTERN) || errorResponse.getMessage().equals("cached plan must not change result type"); + return TABLE_SCHEMA_CHANGE_ERROR_MESSAGE_PATTERN.matcher(errorResponse.getMessage()).matches() || errorResponse.getMessage().equals("cached plan must not change result type"); } } diff --git a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/PreparedStatementCachedTest.java b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/PreparedStatementCachedTest.java index f1c75b6c7..202d5899b 100644 --- a/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/PreparedStatementCachedTest.java +++ b/vertx-pg-client/src/test/java/io/vertx/tests/pgclient/PreparedStatementCachedTest.java @@ -21,6 +21,7 @@ import io.vertx.ext.unit.TestContext; import io.vertx.pgclient.PgConnectOptions; import io.vertx.pgclient.PgConnection; +import io.vertx.pgclient.PgException; import io.vertx.sqlclient.Tuple; import org.junit.Test; @@ -75,6 +76,40 @@ public void testOneShotPreparedQueryCacheRefreshOnTableSchemaChange(TestContext })); } + @Test + public void testPreparedQueryCacheRefreshOnWideTableSchemaChangeAfterReparse(TestContext ctx) { + // A proxy such as RDS Proxy parses the cached statement again on its backend connection, so after a schema change + // the bind fails with "bind message has 10 result formats but query has 11 columns" instead of + // "cached plan must not change result type". Simulate that by re-preparing the statement under the same name. + Async async = ctx.async(); + PgConnection.connect(vertx, options()).onComplete(ctx.asyncAssertSuccess(conn -> { + String select = "SELECT * FROM unstable_wide WHERE id=$1"; + conn.query("DROP TABLE IF EXISTS unstable_wide").execute() + .compose(v -> conn.query("CREATE TABLE unstable_wide (id integer PRIMARY KEY, c1 integer, c2 integer, c3 integer, " + + "c4 integer, c5 integer, c6 integer, c7 integer, c8 integer, c9 integer)").execute()) + .compose(v -> conn.query("INSERT INTO unstable_wide (id) VALUES (1)").execute()) + .compose(v -> conn.preparedQuery(select).execute(Tuple.of(1))) + .compose(res1 -> { + ctx.assertEquals(10, res1.columnsNames().size()); + return conn.query("ALTER TABLE unstable_wide ADD COLUMN c10 integer").execute(); + }) + .compose(v -> conn.query("SELECT name FROM pg_prepared_statements WHERE statement = '" + select + "'").execute()) + .compose(names -> { + String name = names.iterator().next().getString(0); + return conn.query("DEALLOCATE \"" + name + "\"; PREPARE \"" + name + "\" (int4) AS " + select).execute(); + }) + .compose(v -> conn.preparedQuery(select).execute(Tuple.of(1))) + .onComplete(ctx.asyncAssertFailure(failure -> { + ctx.assertEquals("bind message has 10 result formats but query has 11 columns", ((PgException) failure).getErrorMessage()); + conn.preparedQuery(select).execute(Tuple.of(1)).onComplete(ctx.asyncAssertSuccess(res2 -> { + ctx.assertEquals(11, res2.columnsNames().size()); + conn.close(); + async.complete(); + })); + })); + })); + } + @Test public void testMaxPreparedStatementEviction(TestContext ctx) { testPreparedStatements(ctx, options().setCachePreparedStatements(true).setPreparedStatementCacheMaxSize(16), 128, 16);