From 7cc3faeafbb8a7506141f9e8dd5a28393ae8f0af Mon Sep 17 00:00:00 2001 From: slayerjain Date: Tue, 18 Aug 2026 11:23:26 +0530 Subject: [PATCH] feat(tidb-stmt-cache): add a streamed-BLOB fixture for SEND_LONG_DATA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keploy/keploy#4262: writing a BLOB through PreparedStatement.setBinaryStream on a server-side prepared statement makes Connector/J 8.x send the value out of band and pipeline three commands per re-execution without reading a response in between: COM_STMT_RESET -> COM_STMT_SEND_LONG_DATA -> COM_STMT_EXECUTE A long-sent parameter is absent from the EXECUTE payload while still being non-NULL in the null bitmap, so a decoder that reads a value for every non-NULL parameter runs off the end and rejects the command. The EXECUTE never reaches the recorder, and replay fails with "Can not read response from server". Nothing in the suite wrote a BLOB through a stream setter, so the sequence was never recorded. This adds the fixture that produces it: - blob_stream table in SchemaInitializer, alongside kv. - GET /api/blob/{size} inserts a payload with setBinaryStream (not setBytes — the stream setter is what triggers SEND_LONG_DATA), then reads LENGTH(payload) back. The endpoint has to be driven more than once: the RESET only appears when the statement is re-executed from Connector/J's cache, which this sample already enables via useServerPrepStmts + cachePrepStmts. Verified against keploy at 05b4eada and against the #4262 fix — the recorded mocks go from 0 COM_STMT_EXECUTE to 6, and replay with TiDB stopped goes from 1 passed / 3 failed (6 "Can not read response from server") to 4 passed. Signed-off-by: slayerjain --- .../tidbstmtcache/QueryController.java | 50 +++++++++++++++++++ .../tidbstmtcache/SchemaInitializer.java | 9 ++++ 2 files changed, 59 insertions(+) diff --git a/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/QueryController.java b/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/QueryController.java index 68852dc8..d00b66fa 100644 --- a/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/QueryController.java +++ b/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/QueryController.java @@ -42,6 +42,56 @@ public QueryController(JdbcTemplate jdbc) { this.jdbc = jdbc; } + /** + * Streamed-BLOB write on a server-side prepared statement — the sequence + * behind keploy/keploy#4262. + * + * PreparedStatement.setBinaryStream makes Connector/J 8.x send the value + * out-of-band and pipeline three commands per re-execution without reading + * a response between them: + * + * COM_STMT_RESET -> COM_STMT_SEND_LONG_DATA -> COM_STMT_EXECUTE + * + * RESET and EXECUTE are each answered with an OK; SEND_LONG_DATA is not + * answered at all. A recorder that assumes one response per command pairs + * EXECUTE's OK with the wrong request and drops the other, so at replay the + * live EXECUTE has no mock and Connector/J fails with + * "Can not read response from server". + * + * Re-executing on a cached statement is what forces the RESET, so this must + * be driven more than once on the same connection to reproduce. + */ + @GetMapping("/api/blob/{size}") + public Map writeBlob(@PathVariable("size") int size) { + byte[] payload = new byte[Math.max(1, Math.min(size, 64 * 1024))]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i % 251); + } + + Integer id = jdbc.execute((java.sql.Connection conn) -> { + try (java.sql.PreparedStatement ps = conn.prepareStatement( + "INSERT INTO blob_stream (payload) VALUES (?)", + java.sql.Statement.RETURN_GENERATED_KEYS)) { + // setBinaryStream, not setBytes: the stream setter is what + // triggers COM_STMT_SEND_LONG_DATA. + ps.setBinaryStream(1, new java.io.ByteArrayInputStream(payload), payload.length); + ps.executeUpdate(); + try (java.sql.ResultSet keys = ps.getGeneratedKeys()) { + return keys.next() ? keys.getInt(1) : -1; + } + } + }); + + Integer stored = jdbc.queryForObject( + "SELECT LENGTH(payload) FROM blob_stream WHERE id = ?", Integer.class, id); + + Map out = new HashMap<>(); + out.put("id", id); + out.put("sent", payload.length); + out.put("stored", stored); + return out; + } + /** * Lightweight liveness probe. Plain query, no prepared statement -- * used by the CI script's wait_for_app loop so app readiness is not diff --git a/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/SchemaInitializer.java b/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/SchemaInitializer.java index 367e28ef..66a6ede6 100644 --- a/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/SchemaInitializer.java +++ b/tidb-stmt-cache/src/main/java/com/example/tidbstmtcache/SchemaInitializer.java @@ -30,5 +30,14 @@ public void run(String... args) { "id INT PRIMARY KEY AUTO_INCREMENT, " + "v INT NOT NULL" + ")"); + + // Streamed-BLOB fixture for keploy/keploy#4262. Writing this column + // through PreparedStatement.setBinaryStream is what makes Connector/J + // pipeline COM_STMT_RESET -> COM_STMT_SEND_LONG_DATA -> + // COM_STMT_EXECUTE without reading a response in between. + jdbc.execute("CREATE TABLE IF NOT EXISTS blob_stream (" + + "id INT PRIMARY KEY AUTO_INCREMENT, " + + "payload BLOB NOT NULL" + + ")"); } }