Skip to content

Commit 5c6aeac

Browse files
authored
feat: add ps-cache-kotlin sample for connection-affinity testing (#123)
* 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 <shubhamkjain@outlook.com> * fix: address Copilot review — use{} for resources, 404 for not found, curl -fSs - 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 <shubhamkjain@outlook.com> * fix: use integer sleeps in test.sh, increase eviction wait Signed-off-by: slayerjain <shubhamkjain@outlook.com> * docs: add README with bug reproduction steps and architecture diagram Signed-off-by: slayerjain <shubhamkjain@outlook.com> --------- Signed-off-by: slayerjain <shubhamkjain@outlook.com>
1 parent be4a658 commit 5c6aeac

8 files changed

Lines changed: 361 additions & 0 deletions

File tree

ps-cache-kotlin/Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM maven:3.9-eclipse-temurin-21 AS builder
2+
WORKDIR /app
3+
COPY pom.xml .
4+
RUN mvn dependency:go-offline -q
5+
COPY src/ src/
6+
RUN mvn package -DskipTests -q
7+
8+
FROM eclipse-temurin:21-jre-alpine
9+
WORKDIR /app
10+
COPY --from=builder /app/target/kotlin-app-1.0.0.jar app.jar
11+
EXPOSE 8080
12+
CMD ["java", "-jar", "app.jar"]

ps-cache-kotlin/README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# PS-Cache Kotlin — JDBC Prepared Statement Cache Mock Mismatch Reproduction
2+
3+
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**.
4+
5+
## The Bug
6+
7+
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:
8+
9+
1. All mocks have the same parameterized SQL: `SELECT ... WHERE member_id = ?`
10+
2. `bindParamMatchLen` mode only checks parameter byte-length (all int4 are 4 bytes)
11+
3. Sort-order prediction starts from 0 on a fresh connection, pointing to the wrong window's mocks
12+
13+
### Real-world impact
14+
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**.
15+
16+
## Architecture
17+
18+
```
19+
┌──────────────────────┐
20+
HTTP requests ──> │ Kotlin + Spring Boot │
21+
│ HikariCP pool=1 │
22+
│ prepareThreshold=1 │
23+
└──────────┬───────────┘
24+
25+
┌──────────────────┼──────────────────┐
26+
│ │ │
27+
/account /evict /account
28+
member=19 (pool evict) member=31
29+
│ │ │
30+
Connection A destroyed Connection B
31+
PS cache: cold→warm PS cache: cold
32+
│ │
33+
1st: Parse+Bind+Desc+Exec Parse+Bind+Desc+Exec
34+
2nd: Bind+Exec (cached PS)
35+
│ │
36+
mocks connID=0 mocks connID=2
37+
(Alice, 1000) (Charlie, 500)
38+
```
39+
40+
## How to Reproduce the Bug
41+
42+
### Prerequisites
43+
```bash
44+
docker run -d --name pg-demo -e POSTGRES_PASSWORD=testpass -e POSTGRES_DB=demodb -p 5433:5432 postgres:16
45+
```
46+
47+
### Pre-create the schema
48+
```bash
49+
docker exec pg-demo psql -U postgres -d demodb -c "
50+
CREATE SCHEMA IF NOT EXISTS travelcard;
51+
CREATE TABLE IF NOT EXISTS travelcard.travel_account (
52+
id SERIAL PRIMARY KEY, member_id INT NOT NULL UNIQUE,
53+
name TEXT NOT NULL, balance INT NOT NULL DEFAULT 0);
54+
INSERT INTO travelcard.travel_account (member_id, name, balance) VALUES
55+
(19, 'Alice', 1000), (23, 'Bob', 2500),
56+
(31, 'Charlie', 500), (42, 'Diana', 7500);"
57+
```
58+
59+
### Build the app
60+
```bash
61+
mvn package -DskipTests -q
62+
```
63+
64+
### With the OLD keploy binary (demonstrates failure)
65+
```bash
66+
# Record
67+
sudo keploy record -c "java -jar target/kotlin-app-1.0.0.jar"
68+
# Hit endpoints:
69+
curl http://localhost:8090/account?member=19
70+
curl http://localhost:8090/account?member=23
71+
curl http://localhost:8090/evict
72+
curl http://localhost:8090/account?member=31
73+
curl http://localhost:8090/account?member=42
74+
# Stop recording (Ctrl+C)
75+
76+
# Reset DB and replay
77+
docker exec pg-demo psql -U postgres -d demodb -c "TRUNCATE travelcard.travel_account; INSERT INTO ..."
78+
sudo keploy test -c "java -jar target/kotlin-app-1.0.0.jar" --skip-coverage
79+
```
80+
81+
**Expected failure (without fix):**
82+
```
83+
test-5 (/account?member=31):
84+
EXPECTED: {"memberId":31, "name":"Charlie", "balance":500}
85+
ACTUAL: {"memberId":19, "name":"Alice", "balance":1000} ← WRONG PERSON
86+
```
87+
88+
**With obfuscation enabled (worse):**
89+
```
90+
test-5: EXPECTED Charlie → ACTUAL Alice
91+
test-6: EXPECTED Diana → ACTUAL Bob ← TWO wrong results
92+
```
93+
94+
### With the FIXED keploy binary
95+
```bash
96+
# Same steps → all tests pass, correct data for each member
97+
```
98+
99+
## What the Fix Does
100+
101+
The fix adds **recording-connection affinity** to the Postgres mock matcher (see [keploy/integrations#121](https://github.com/keploy/integrations/pull/121)):
102+
103+
1. When the first `Bind` mock is consumed on a replay connection, its recording `connID` is stored
104+
2. Subsequent scoring applies +50/-50 bonus/penalty to prefer mocks from the same recording window
105+
3. Only activates when 2+ distinct recording connections exist (zero impact on single-connection apps)
106+
107+
## Configuration
108+
109+
### application.properties
110+
| Property | Value | Purpose |
111+
|----------|-------|---------|
112+
| `spring.datasource.hikari.maximum-pool-size` | `1` | Forces all requests through one connection |
113+
| `prepareThreshold=1` | JDBC URL param | Caches PS after first use |
114+
| `spring.sql.init.mode` | `never` | Schema created externally |
115+
116+
### Endpoints
117+
118+
| Endpoint | Description |
119+
|----------|-------------|
120+
| `GET /health` | Health check |
121+
| `GET /account?member=N` | Query travel_account by member_id (BEGIN → SELECT → COMMIT) |
122+
| `GET /evict` | Soft-evict HikariCP connections (forces new PG connection) |

ps-cache-kotlin/docker-compose.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
services:
2+
db:
3+
image: postgres:16-alpine
4+
environment:
5+
POSTGRES_USER: postgres
6+
POSTGRES_PASSWORD: postgres
7+
POSTGRES_DB: testdb
8+
ports:
9+
- "5433:5432"
10+
volumes:
11+
- pgdata:/var/lib/postgresql/data
12+
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
13+
healthcheck:
14+
test: ["CMD-SHELL", "pg_isready -U postgres"]
15+
interval: 2s
16+
timeout: 5s
17+
retries: 5
18+
19+
api:
20+
build: .
21+
ports:
22+
- "8080:8080"
23+
environment:
24+
DB_HOST: db
25+
DB_PORT: "5432"
26+
DB_USER: postgres
27+
DB_PASSWORD: postgres
28+
DB_NAME: testdb
29+
depends_on:
30+
db:
31+
condition: service_healthy
32+
33+
volumes:
34+
pgdata:

ps-cache-kotlin/init.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
CREATE SCHEMA IF NOT EXISTS travelcard;
2+
3+
CREATE TABLE IF NOT EXISTS travelcard.travel_account (
4+
id SERIAL PRIMARY KEY,
5+
member_id INT NOT NULL UNIQUE,
6+
name TEXT NOT NULL,
7+
balance INT NOT NULL DEFAULT 0
8+
);
9+
10+
INSERT INTO travelcard.travel_account (member_id, name, balance) VALUES
11+
(19, 'Alice', 1000),
12+
(23, 'Bob', 2500),
13+
(31, 'Charlie', 500),
14+
(42, 'Diana', 7500)
15+
ON CONFLICT (member_id) DO NOTHING;

ps-cache-kotlin/pom.xml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
<parent>
7+
<groupId>org.springframework.boot</groupId>
8+
<artifactId>spring-boot-starter-parent</artifactId>
9+
<version>3.4.4</version>
10+
</parent>
11+
<groupId>com.demo</groupId>
12+
<artifactId>kotlin-app</artifactId>
13+
<version>1.0.0</version>
14+
<properties>
15+
<java.version>21</java.version>
16+
<kotlin.version>1.9.25</kotlin.version>
17+
</properties>
18+
<dependencies>
19+
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
20+
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
21+
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
22+
<dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-reflect</artifactId></dependency>
23+
<dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib</artifactId></dependency>
24+
</dependencies>
25+
<build>
26+
<sourceDirectory>src/main/kotlin</sourceDirectory>
27+
<plugins>
28+
<plugin>
29+
<groupId>org.jetbrains.kotlin</groupId>
30+
<artifactId>kotlin-maven-plugin</artifactId>
31+
<version>${kotlin.version}</version>
32+
<configuration>
33+
<compilerPlugins><plugin>spring</plugin></compilerPlugins>
34+
<jvmTarget>${java.version}</jvmTarget>
35+
</configuration>
36+
<dependencies>
37+
<dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-maven-allopen</artifactId><version>${kotlin.version}</version></dependency>
38+
</dependencies>
39+
<executions><execution><id>compile</id><goals><goal>compile</goal></goals></execution></executions>
40+
</plugin>
41+
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
42+
</plugins>
43+
</build>
44+
</project>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.demo
2+
3+
import org.springframework.boot.autoconfigure.SpringBootApplication
4+
import org.springframework.boot.runApplication
5+
import org.springframework.jdbc.core.JdbcTemplate
6+
import org.springframework.web.bind.annotation.GetMapping
7+
import org.springframework.web.bind.annotation.RequestParam
8+
import org.springframework.web.bind.annotation.RestController
9+
import org.springframework.http.ResponseEntity
10+
import javax.sql.DataSource
11+
import com.zaxxer.hikari.HikariDataSource
12+
13+
@SpringBootApplication
14+
class App
15+
16+
fun main(args: Array<String>) {
17+
runApplication<App>(*args)
18+
}
19+
20+
data class Account(
21+
val id: Int,
22+
val memberId: Int,
23+
val name: String,
24+
val balance: Int
25+
)
26+
27+
@RestController
28+
class AccountController(private val jdbc: JdbcTemplate, private val dataSource: DataSource) {
29+
30+
@GetMapping("/health")
31+
fun health() = mapOf("status" to "ok")
32+
33+
@GetMapping("/account")
34+
fun getAccount(@RequestParam("member") memberId: Int): ResponseEntity<Any> {
35+
val result = jdbc.execute(
36+
org.springframework.jdbc.core.ConnectionCallback<Account?> { conn ->
37+
conn.autoCommit = false
38+
try {
39+
conn.prepareStatement(
40+
"""SELECT id, member_id, name, balance
41+
FROM travelcard.travel_account
42+
WHERE member_id = ?"""
43+
).use { ps ->
44+
ps.setInt(1, memberId)
45+
ps.executeQuery().use { rs ->
46+
val account = if (rs.next()) {
47+
Account(
48+
id = rs.getInt("id"),
49+
memberId = rs.getInt("member_id"),
50+
name = rs.getString("name"),
51+
balance = rs.getInt("balance")
52+
)
53+
} else null
54+
55+
conn.commit()
56+
account
57+
}
58+
}
59+
} catch (e: Exception) {
60+
conn.rollback()
61+
throw e
62+
}
63+
})
64+
65+
return if (result != null) {
66+
ResponseEntity.ok(result)
67+
} else {
68+
ResponseEntity.status(404).body(mapOf("error" to "not found", "member_id" to memberId))
69+
}
70+
}
71+
72+
@GetMapping("/evict")
73+
fun evict(): ResponseEntity<Map<String, Any>> {
74+
val hikari = dataSource as? HikariDataSource
75+
?: return ResponseEntity.status(500).body(mapOf("error" to "not a HikariDataSource"))
76+
77+
val mxBean = hikari.hikariPoolMXBean
78+
?: return ResponseEntity.status(500).body(mapOf("error" to "pool MXBean not available"))
79+
80+
mxBean.softEvictConnections()
81+
Thread.sleep(500)
82+
83+
return ResponseEntity.ok(mapOf(
84+
"evicted" to true,
85+
"active" to mxBean.activeConnections,
86+
"idle" to mxBean.idleConnections
87+
))
88+
}
89+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
server.port=8080
2+
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:testdb}?prepareThreshold=1&preparedStatementCacheQueries=256
3+
spring.datasource.username=${DB_USER:postgres}
4+
spring.datasource.password=${DB_PASSWORD:postgres}
5+
spring.datasource.hikari.maximum-pool-size=1
6+
spring.datasource.hikari.minimum-idle=1
7+
spring.sql.init.mode=never

ps-cache-kotlin/test.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
BASE_URL="http://localhost:8080"
5+
6+
echo "=== PS-Cache Mock Mismatch Test (Kotlin/JDBC) ==="
7+
8+
echo "--- Window 1: Connection A ---"
9+
echo " /account?member=19:"
10+
curl -fSs "$BASE_URL/account?member=19"
11+
echo ""
12+
sleep 1
13+
14+
echo " /account?member=23:"
15+
curl -fSs "$BASE_URL/account?member=23"
16+
echo ""
17+
sleep 1
18+
19+
echo ""
20+
echo "--- Evict (force new connection) ---"
21+
echo " /evict:"
22+
curl -fSs "$BASE_URL/evict"
23+
echo ""
24+
sleep 1
25+
26+
echo ""
27+
echo "--- Window 2: Connection B ---"
28+
echo " /account?member=31:"
29+
curl -fSs "$BASE_URL/account?member=31"
30+
echo ""
31+
sleep 1
32+
33+
echo " /account?member=42:"
34+
curl -fSs "$BASE_URL/account?member=42"
35+
echo ""
36+
37+
echo ""
38+
echo "=== Done ==="

0 commit comments

Comments
 (0)