From 8a4717e758d0baef01b142f749dc7cd3d9d28d96 Mon Sep 17 00:00:00 2001 From: slayerjain Date: Thu, 16 Apr 2026 22:30:48 +0530 Subject: [PATCH 1/4] feat: add ps-cache-kotlin sample for connection-affinity mock matching Kotlin + Spring Boot + JDBC sample that demonstrates the PS-cache mock mismatch. Uses HikariCP max-pool-size=1, prepareThreshold=1, and a /evict endpoint to force connection cycling. The test records 4 /account requests across 2 connection windows. Without the affinity fix (keploy/integrations#121), test-5 returns Alice's data for Charlie's request. Signed-off-by: slayerjain --- ps-cache-kotlin/Dockerfile | 12 +++ ps-cache-kotlin/docker-compose.yml | 34 +++++++ ps-cache-kotlin/init.sql | 15 +++ ps-cache-kotlin/pom.xml | 44 +++++++++ .../src/main/kotlin/com/demo/App.kt | 91 +++++++++++++++++++ .../src/main/resources/application.properties | 7 ++ ps-cache-kotlin/test.sh | 38 ++++++++ 7 files changed, 241 insertions(+) create mode 100644 ps-cache-kotlin/Dockerfile create mode 100644 ps-cache-kotlin/docker-compose.yml create mode 100644 ps-cache-kotlin/init.sql create mode 100644 ps-cache-kotlin/pom.xml create mode 100644 ps-cache-kotlin/src/main/kotlin/com/demo/App.kt create mode 100644 ps-cache-kotlin/src/main/resources/application.properties create mode 100755 ps-cache-kotlin/test.sh diff --git a/ps-cache-kotlin/Dockerfile b/ps-cache-kotlin/Dockerfile new file mode 100644 index 00000000..17b63339 --- /dev/null +++ b/ps-cache-kotlin/Dockerfile @@ -0,0 +1,12 @@ +FROM maven:3.9-eclipse-temurin-21 AS builder +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -q +COPY src/ src/ +RUN mvn package -DskipTests -q + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/target/kotlin-app-1.0.0.jar app.jar +EXPOSE 8080 +CMD ["java", "-jar", "app.jar"] diff --git a/ps-cache-kotlin/docker-compose.yml b/ps-cache-kotlin/docker-compose.yml new file mode 100644 index 00000000..ca799f2e --- /dev/null +++ b/ps-cache-kotlin/docker-compose.yml @@ -0,0 +1,34 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testdb + ports: + - "5433:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "8080:8080" + environment: + DB_HOST: db + DB_PORT: "5432" + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: testdb + depends_on: + db: + condition: service_healthy + +volumes: + pgdata: diff --git a/ps-cache-kotlin/init.sql b/ps-cache-kotlin/init.sql new file mode 100644 index 00000000..fb7bb14b --- /dev/null +++ b/ps-cache-kotlin/init.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA IF NOT EXISTS travelcard; + +CREATE TABLE IF NOT EXISTS travelcard.travel_account ( + id SERIAL PRIMARY KEY, + member_id INT NOT NULL UNIQUE, + name TEXT NOT NULL, + balance INT NOT NULL DEFAULT 0 +); + +INSERT INTO travelcard.travel_account (member_id, name, balance) VALUES + (19, 'Alice', 1000), + (23, 'Bob', 2500), + (31, 'Charlie', 500), + (42, 'Diana', 7500) +ON CONFLICT (member_id) DO NOTHING; diff --git a/ps-cache-kotlin/pom.xml b/ps-cache-kotlin/pom.xml new file mode 100644 index 00000000..287f22d5 --- /dev/null +++ b/ps-cache-kotlin/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + com.demo + kotlin-app + 1.0.0 + + 21 + 1.9.25 + + + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-jdbc + org.postgresqlpostgresql + org.jetbrains.kotlinkotlin-reflect + org.jetbrains.kotlinkotlin-stdlib + + + src/main/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + spring + ${java.version} + + + org.jetbrains.kotlinkotlin-maven-allopen${kotlin.version} + + compilecompile + + org.springframework.bootspring-boot-maven-plugin + + + diff --git a/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt b/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt new file mode 100644 index 00000000..c7a39f85 --- /dev/null +++ b/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt @@ -0,0 +1,91 @@ +package com.demo + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import javax.sql.DataSource +import com.zaxxer.hikari.HikariDataSource + +@SpringBootApplication +class App + +fun main(args: Array) { + runApplication(*args) +} + +data class Account( + val id: Int, + val memberId: Int, + val name: String, + val balance: Int +) + +@RestController +class AccountController(private val jdbc: JdbcTemplate, private val dataSource: DataSource) { + + @GetMapping("/health") + fun health() = mapOf("status" to "ok") + + /** + * /account?member=N queries the travel_account table. + * + * JDBC PS caching (prepareThreshold=1): + * - 1st call on a fresh connection: Parse(query="SELECT ...") + Bind + Describe + Execute + * - 2nd+ calls on same connection: Bind(ps="S_1") + Execute only (cached PS) + * + * The /evict endpoint forces HikariCP to evict all connections, so the + * NEXT /account call gets a fresh connection with cold PS cache. + */ + @GetMapping("/account") + fun getAccount(@RequestParam("member") memberId: Int): Any { + return jdbc.execute { conn: java.sql.Connection -> + conn.autoCommit = false + try { + val ps = conn.prepareStatement( + """SELECT id, member_id, name, balance + FROM travelcard.travel_account + WHERE member_id = ?""" + ) + ps.setInt(1, memberId) + val rs = ps.executeQuery() + + val result = if (rs.next()) { + Account( + id = rs.getInt("id"), + memberId = rs.getInt("member_id"), + name = rs.getString("name"), + balance = rs.getInt("balance") + ) + } else { + mapOf("error" to "not found", "member_id" to memberId) + } + + rs.close() + ps.close() + conn.commit() + result + } catch (e: Exception) { + conn.rollback() + throw e + } + }!! + } + + /** + * /evict forces HikariCP to evict all idle connections. + * Next request gets a FRESH PG connection → cold PS cache. + * This simulates what happens in production when connections cycle. + */ + @GetMapping("/evict") + fun evict(): Map { + val hikari = dataSource as HikariDataSource + hikari.hikariPoolMXBean?.softEvictConnections() + // Also wait briefly for eviction + Thread.sleep(200) + return mapOf("evicted" to true, "active" to (hikari.hikariPoolMXBean?.activeConnections ?: 0), + "idle" to (hikari.hikariPoolMXBean?.idleConnections ?: 0)) + } +} diff --git a/ps-cache-kotlin/src/main/resources/application.properties b/ps-cache-kotlin/src/main/resources/application.properties new file mode 100644 index 00000000..29fae651 --- /dev/null +++ b/ps-cache-kotlin/src/main/resources/application.properties @@ -0,0 +1,7 @@ +server.port=8080 +spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:testdb}?prepareThreshold=1&preparedStatementCacheQueries=256 +spring.datasource.username=${DB_USER:postgres} +spring.datasource.password=${DB_PASSWORD:postgres} +spring.datasource.hikari.maximum-pool-size=1 +spring.datasource.hikari.minimum-idle=1 +spring.sql.init.mode=never diff --git a/ps-cache-kotlin/test.sh b/ps-cache-kotlin/test.sh new file mode 100755 index 00000000..055c1880 --- /dev/null +++ b/ps-cache-kotlin/test.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="http://localhost:8080" + +echo "=== PS-Cache Mock Mismatch Test (Kotlin/JDBC) ===" + +echo "--- Window 1: Connection A ---" +echo " /account?member=19:" +curl -s "$BASE_URL/account?member=19" +echo "" +sleep 0.3 + +echo " /account?member=23:" +curl -s "$BASE_URL/account?member=23" +echo "" +sleep 0.3 + +echo "" +echo "--- Evict (force new connection) ---" +echo " /evict:" +curl -s "$BASE_URL/evict" +echo "" +sleep 1 + +echo "" +echo "--- Window 2: Connection B ---" +echo " /account?member=31:" +curl -s "$BASE_URL/account?member=31" +echo "" +sleep 0.3 + +echo " /account?member=42:" +curl -s "$BASE_URL/account?member=42" +echo "" + +echo "" +echo "=== Done ===" From 79bd7964ef61917eba84a444cdf9938a42eca2d7 Mon Sep 17 00:00:00 2001 From: slayerjain Date: Thu, 16 Apr 2026 22:49:03 +0530 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20use{}=20for=20resources,=20404=20for=20not=20found,?= =?UTF-8?q?=20curl=20-fSs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Kotlin use{} blocks for PreparedStatement and ResultSet cleanup - Return 404 ResponseEntity when member not found - Handle null HikariPoolMXBean with proper error response - Remove non-null assertion (!!) — return ResponseEntity directly - Use curl -fSs in test.sh to fail on HTTP errors Signed-off-by: slayerjain --- .../src/main/kotlin/com/demo/App.kt | 98 +++++++++---------- ps-cache-kotlin/test.sh | 10 +- 2 files changed, 53 insertions(+), 55 deletions(-) diff --git a/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt b/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt index c7a39f85..947523b5 100644 --- a/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt +++ b/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt @@ -6,6 +6,7 @@ import org.springframework.jdbc.core.JdbcTemplate import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.http.ResponseEntity import javax.sql.DataSource import com.zaxxer.hikari.HikariDataSource @@ -29,63 +30,60 @@ class AccountController(private val jdbc: JdbcTemplate, private val dataSource: @GetMapping("/health") fun health() = mapOf("status" to "ok") - /** - * /account?member=N queries the travel_account table. - * - * JDBC PS caching (prepareThreshold=1): - * - 1st call on a fresh connection: Parse(query="SELECT ...") + Bind + Describe + Execute - * - 2nd+ calls on same connection: Bind(ps="S_1") + Execute only (cached PS) - * - * The /evict endpoint forces HikariCP to evict all connections, so the - * NEXT /account call gets a fresh connection with cold PS cache. - */ @GetMapping("/account") - fun getAccount(@RequestParam("member") memberId: Int): Any { - return jdbc.execute { conn: java.sql.Connection -> - conn.autoCommit = false - try { - val ps = conn.prepareStatement( - """SELECT id, member_id, name, balance - FROM travelcard.travel_account - WHERE member_id = ?""" - ) - ps.setInt(1, memberId) - val rs = ps.executeQuery() + fun getAccount(@RequestParam("member") memberId: Int): ResponseEntity { + val result = jdbc.execute( + org.springframework.jdbc.core.ConnectionCallback { conn -> + conn.autoCommit = false + try { + conn.prepareStatement( + """SELECT id, member_id, name, balance + FROM travelcard.travel_account + WHERE member_id = ?""" + ).use { ps -> + ps.setInt(1, memberId) + ps.executeQuery().use { rs -> + val account = if (rs.next()) { + Account( + id = rs.getInt("id"), + memberId = rs.getInt("member_id"), + name = rs.getString("name"), + balance = rs.getInt("balance") + ) + } else null - val result = if (rs.next()) { - Account( - id = rs.getInt("id"), - memberId = rs.getInt("member_id"), - name = rs.getString("name"), - balance = rs.getInt("balance") - ) - } else { - mapOf("error" to "not found", "member_id" to memberId) + conn.commit() + account + } + } + } catch (e: Exception) { + conn.rollback() + throw e } + }) - rs.close() - ps.close() - conn.commit() - result - } catch (e: Exception) { - conn.rollback() - throw e - } - }!! + return if (result != null) { + ResponseEntity.ok(result) + } else { + ResponseEntity.status(404).body(mapOf("error" to "not found", "member_id" to memberId)) + } } - /** - * /evict forces HikariCP to evict all idle connections. - * Next request gets a FRESH PG connection → cold PS cache. - * This simulates what happens in production when connections cycle. - */ @GetMapping("/evict") - fun evict(): Map { - val hikari = dataSource as HikariDataSource - hikari.hikariPoolMXBean?.softEvictConnections() - // Also wait briefly for eviction + fun evict(): ResponseEntity> { + val hikari = dataSource as? HikariDataSource + ?: return ResponseEntity.status(500).body(mapOf("error" to "not a HikariDataSource")) + + val mxBean = hikari.hikariPoolMXBean + ?: return ResponseEntity.status(500).body(mapOf("error" to "pool MXBean not available")) + + mxBean.softEvictConnections() Thread.sleep(200) - return mapOf("evicted" to true, "active" to (hikari.hikariPoolMXBean?.activeConnections ?: 0), - "idle" to (hikari.hikariPoolMXBean?.idleConnections ?: 0)) + + return ResponseEntity.ok(mapOf( + "evicted" to true, + "active" to mxBean.activeConnections, + "idle" to mxBean.idleConnections + )) } } diff --git a/ps-cache-kotlin/test.sh b/ps-cache-kotlin/test.sh index 055c1880..ff5a101d 100755 --- a/ps-cache-kotlin/test.sh +++ b/ps-cache-kotlin/test.sh @@ -7,31 +7,31 @@ echo "=== PS-Cache Mock Mismatch Test (Kotlin/JDBC) ===" echo "--- Window 1: Connection A ---" echo " /account?member=19:" -curl -s "$BASE_URL/account?member=19" +curl -fSs "$BASE_URL/account?member=19" echo "" sleep 0.3 echo " /account?member=23:" -curl -s "$BASE_URL/account?member=23" +curl -fSs "$BASE_URL/account?member=23" echo "" sleep 0.3 echo "" echo "--- Evict (force new connection) ---" echo " /evict:" -curl -s "$BASE_URL/evict" +curl -fSs "$BASE_URL/evict" echo "" sleep 1 echo "" echo "--- Window 2: Connection B ---" echo " /account?member=31:" -curl -s "$BASE_URL/account?member=31" +curl -fSs "$BASE_URL/account?member=31" echo "" sleep 0.3 echo " /account?member=42:" -curl -s "$BASE_URL/account?member=42" +curl -fSs "$BASE_URL/account?member=42" echo "" echo "" From 0067bedb20ea914ece1d5fd8d0b1e164ed6fb810 Mon Sep 17 00:00:00 2001 From: slayerjain Date: Thu, 16 Apr 2026 22:56:17 +0530 Subject: [PATCH 3/4] fix: use integer sleeps in test.sh, increase eviction wait Signed-off-by: slayerjain --- ps-cache-kotlin/src/main/kotlin/com/demo/App.kt | 2 +- ps-cache-kotlin/test.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt b/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt index 947523b5..dceee584 100644 --- a/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt +++ b/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt @@ -78,7 +78,7 @@ class AccountController(private val jdbc: JdbcTemplate, private val dataSource: ?: return ResponseEntity.status(500).body(mapOf("error" to "pool MXBean not available")) mxBean.softEvictConnections() - Thread.sleep(200) + Thread.sleep(500) return ResponseEntity.ok(mapOf( "evicted" to true, diff --git a/ps-cache-kotlin/test.sh b/ps-cache-kotlin/test.sh index ff5a101d..9c4eb75a 100755 --- a/ps-cache-kotlin/test.sh +++ b/ps-cache-kotlin/test.sh @@ -9,12 +9,12 @@ echo "--- Window 1: Connection A ---" echo " /account?member=19:" curl -fSs "$BASE_URL/account?member=19" echo "" -sleep 0.3 +sleep 1 echo " /account?member=23:" curl -fSs "$BASE_URL/account?member=23" echo "" -sleep 0.3 +sleep 1 echo "" echo "--- Evict (force new connection) ---" @@ -28,7 +28,7 @@ echo "--- Window 2: Connection B ---" echo " /account?member=31:" curl -fSs "$BASE_URL/account?member=31" echo "" -sleep 0.3 +sleep 1 echo " /account?member=42:" curl -fSs "$BASE_URL/account?member=42" From 79dea705c541b8b313b6c5d9ba13cd3fd53ade75 Mon Sep 17 00:00:00 2001 From: slayerjain Date: Fri, 17 Apr 2026 00:44:46 +0530 Subject: [PATCH 4/4] docs: add README with bug reproduction steps and architecture diagram Signed-off-by: slayerjain --- ps-cache-kotlin/README.md | 122 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 ps-cache-kotlin/README.md diff --git a/ps-cache-kotlin/README.md b/ps-cache-kotlin/README.md new file mode 100644 index 00000000..7a4e521a --- /dev/null +++ b/ps-cache-kotlin/README.md @@ -0,0 +1,122 @@ +# PS-Cache Kotlin — JDBC Prepared Statement Cache Mock Mismatch Reproduction + +This sample demonstrates a bug in Keploy's Postgres mock matcher where **JDBC prepared statement caching combined with connection pool eviction causes the replay to return the wrong person's data**. + +## The Bug + +The JDBC driver (PostgreSQL JDBC + HikariCP) caches prepared statements per connection. When the connection pool evicts and creates a new connection, the PS cache is cold — but the recorded mocks from the evicted connection had warm-cache structure (Bind-only, no Parse). During replay, the matcher can't distinguish between mocks from different connection windows because: + +1. All mocks have the same parameterized SQL: `SELECT ... WHERE member_id = ?` +2. `bindParamMatchLen` mode only checks parameter byte-length (all int4 are 4 bytes) +3. Sort-order prediction starts from 0 on a fresh connection, pointing to the wrong window's mocks + +### Real-world impact +This was reported by a customer running a Kotlin/Spring Boot app with Agoda's travel account service. Test-6 (member_id=31) returned Alice's data (member_id=19) instead of Charlie's — **silently returning the wrong customer's financial data**. + +## Architecture + +``` + ┌──────────────────────┐ + HTTP requests ──> │ Kotlin + Spring Boot │ + │ HikariCP pool=1 │ + │ prepareThreshold=1 │ + └──────────┬───────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + /account /evict /account + member=19 (pool evict) member=31 + │ │ │ + Connection A destroyed Connection B + PS cache: cold→warm PS cache: cold + │ │ + 1st: Parse+Bind+Desc+Exec Parse+Bind+Desc+Exec + 2nd: Bind+Exec (cached PS) + │ │ + mocks connID=0 mocks connID=2 + (Alice, 1000) (Charlie, 500) +``` + +## How to Reproduce the Bug + +### Prerequisites +```bash +docker run -d --name pg-demo -e POSTGRES_PASSWORD=testpass -e POSTGRES_DB=demodb -p 5433:5432 postgres:16 +``` + +### Pre-create the schema +```bash +docker exec pg-demo psql -U postgres -d demodb -c " + CREATE SCHEMA IF NOT EXISTS travelcard; + CREATE TABLE IF NOT EXISTS travelcard.travel_account ( + id SERIAL PRIMARY KEY, member_id INT NOT NULL UNIQUE, + name TEXT NOT NULL, balance INT NOT NULL DEFAULT 0); + INSERT INTO travelcard.travel_account (member_id, name, balance) VALUES + (19, 'Alice', 1000), (23, 'Bob', 2500), + (31, 'Charlie', 500), (42, 'Diana', 7500);" +``` + +### Build the app +```bash +mvn package -DskipTests -q +``` + +### With the OLD keploy binary (demonstrates failure) +```bash +# Record +sudo keploy record -c "java -jar target/kotlin-app-1.0.0.jar" +# Hit endpoints: +curl http://localhost:8090/account?member=19 +curl http://localhost:8090/account?member=23 +curl http://localhost:8090/evict +curl http://localhost:8090/account?member=31 +curl http://localhost:8090/account?member=42 +# Stop recording (Ctrl+C) + +# Reset DB and replay +docker exec pg-demo psql -U postgres -d demodb -c "TRUNCATE travelcard.travel_account; INSERT INTO ..." +sudo keploy test -c "java -jar target/kotlin-app-1.0.0.jar" --skip-coverage +``` + +**Expected failure (without fix):** +``` +test-5 (/account?member=31): + EXPECTED: {"memberId":31, "name":"Charlie", "balance":500} + ACTUAL: {"memberId":19, "name":"Alice", "balance":1000} ← WRONG PERSON +``` + +**With obfuscation enabled (worse):** +``` +test-5: EXPECTED Charlie → ACTUAL Alice +test-6: EXPECTED Diana → ACTUAL Bob ← TWO wrong results +``` + +### With the FIXED keploy binary +```bash +# Same steps → all tests pass, correct data for each member +``` + +## What the Fix Does + +The fix adds **recording-connection affinity** to the Postgres mock matcher (see [keploy/integrations#121](https://github.com/keploy/integrations/pull/121)): + +1. When the first `Bind` mock is consumed on a replay connection, its recording `connID` is stored +2. Subsequent scoring applies +50/-50 bonus/penalty to prefer mocks from the same recording window +3. Only activates when 2+ distinct recording connections exist (zero impact on single-connection apps) + +## Configuration + +### application.properties +| Property | Value | Purpose | +|----------|-------|---------| +| `spring.datasource.hikari.maximum-pool-size` | `1` | Forces all requests through one connection | +| `prepareThreshold=1` | JDBC URL param | Caches PS after first use | +| `spring.sql.init.mode` | `never` | Schema created externally | + +### Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Health check | +| `GET /account?member=N` | Query travel_account by member_id (BEGIN → SELECT → COMMIT) | +| `GET /evict` | Soft-evict HikariCP connections (forces new PG connection) |