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/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) | 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..dceee584 --- /dev/null +++ b/ps-cache-kotlin/src/main/kotlin/com/demo/App.kt @@ -0,0 +1,89 @@ +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 org.springframework.http.ResponseEntity +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") + + @GetMapping("/account") + 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 + + conn.commit() + account + } + } + } 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)) + } + } + + @GetMapping("/evict") + 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(500) + + return ResponseEntity.ok(mapOf( + "evicted" to true, + "active" to mxBean.activeConnections, + "idle" to mxBean.idleConnections + )) + } +} 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..9c4eb75a --- /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 -fSs "$BASE_URL/account?member=19" +echo "" +sleep 1 + +echo " /account?member=23:" +curl -fSs "$BASE_URL/account?member=23" +echo "" +sleep 1 + +echo "" +echo "--- Evict (force new connection) ---" +echo " /evict:" +curl -fSs "$BASE_URL/evict" +echo "" +sleep 1 + +echo "" +echo "--- Window 2: Connection B ---" +echo " /account?member=31:" +curl -fSs "$BASE_URL/account?member=31" +echo "" +sleep 1 + +echo " /account?member=42:" +curl -fSs "$BASE_URL/account?member=42" +echo "" + +echo "" +echo "=== Done ==="