Skip to content

Commit 79dea70

Browse files
committed
docs: add README with bug reproduction steps and architecture diagram
Signed-off-by: slayerjain <shubhamkjain@outlook.com>
1 parent 0067bed commit 79dea70

1 file changed

Lines changed: 122 additions & 0 deletions

File tree

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) |

0 commit comments

Comments
 (0)