From b519002b1ec3451783a1e6995ce3f840384a6230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20=C5=A0nuderl?= Date: Mon, 12 Oct 2026 15:14:01 +0200 Subject: [PATCH] Evict cached statements on schema change for tables with 10+ columns (#1752) 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) Signed-off-by: Meemaw Co-authored-by: Claude Opus 5.5 --- .../impl/codec/ExtendedQueryCommandCodec.java | 6 ++-- .../pgclient/PreparedStatementCachedTest.java | 35 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryCommandCodec.java b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryCommandCodec.java index 77321ceca..c7fad4c21 100644 --- a/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryCommandCodec.java +++ b/vertx-pg-client/src/main/java/io/vertx/pgclient/impl/codec/ExtendedQueryCommandCodec.java @@ -22,11 +22,13 @@ import io.vertx.sqlclient.impl.command.CommandResponse; import io.vertx.sqlclient.impl.command.ExtendedQueryCommand; +import java.util.regex.Pattern; + class ExtendedQueryCommandCodec> extends QueryCommandBaseCodec { 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"); ExtendedQueryCommandCodec(C cmd) { super(cmd); @@ -97,6 +99,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/pgclient/PreparedStatementCachedTest.java b/vertx-pg-client/src/test/java/io/vertx/pgclient/PreparedStatementCachedTest.java index 94246da7a..9f6bcbeac 100644 --- a/vertx-pg-client/src/test/java/io/vertx/pgclient/PreparedStatementCachedTest.java +++ b/vertx-pg-client/src/test/java/io/vertx/pgclient/PreparedStatementCachedTest.java @@ -19,7 +19,6 @@ import io.vertx.ext.unit.Async; import io.vertx.ext.unit.TestContext; -import io.vertx.sqlclient.Row; import io.vertx.sqlclient.Tuple; import org.junit.Ignore; import org.junit.Test; @@ -63,6 +62,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);