Skip to content

Commit 61a61e6

Browse files
fastapi-sqlalchemy-pg-catalog: minimal repro sample for keploy/integrations#193
FastAPI + SQLAlchemy 2.x + psycopg2 + Postgres 13 sample built to exercise the v3 dispatcher's simple-query ClassCatalog branch. The sample's init.sql pre-creates the `project` table so SQLAlchemy's Base.metadata.create_all skips CREATE TABLE at record time -- the shape the dispatcher bug requires. Used by the keploy/integrations Woodpecker lane sqlalchemy-pg-catalog-postgres to assert that recorded `type: query` mocks with `class: CATALOG` are consulted by the simple-query path, not just the extended-query path. Signed-off-by: Akash Kumar <meakash7902@gmail.com>
1 parent 5d5ce0f commit 61a61e6

7 files changed

Lines changed: 245 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# fastapi-sqlalchemy-pg-catalog
2+
3+
Minimal FastAPI + SQLAlchemy 2.x + psycopg2 + Postgres 13 sample that
4+
reproduces the Postgres v3 dispatcher's simple-query `ClassCatalog`
5+
asymmetry (keploy/integrations#193).
6+
7+
## What the bug looks like
8+
9+
At app boot, SQLAlchemy's `Base.metadata.create_all(engine)` issues a
10+
`pg_catalog.pg_class` probe per declared table to decide whether to
11+
skip `CREATE TABLE`. With psycopg2 + parameter-less SQL the probe
12+
goes through the **simple-query** protocol path.
13+
14+
In `pkg/postgres/v3/replayer/dispatcher/dispatcher.go`:
15+
16+
* The **extended-query** path (`runEngineForPortal`, `case
17+
match.ClassCatalog`) consults the recorded transactional mock first
18+
and only falls back to the synthetic `Engines.Catalog.Execute` on
19+
miss.
20+
* The **simple-query** path (`dispatchBySQLHash`, `case
21+
match.ClassCatalog`) goes straight to the synthetic engine — even
22+
though a recorded `type: query` mock with `class: CATALOG` and the
23+
correct rows is sitting in `mocks.yaml`.
24+
25+
With no `type: catalog` snapshot present, the synthetic engine
26+
answers `rows: 0, cc: "SELECT 0"`. SQLAlchemy reads zero rows as
27+
"table missing", issues `CREATE TABLE project ...`, and the
28+
transactional engine misses (because the recording never captured a
29+
CREATE TABLE — at record time the table already existed). The app
30+
worker dies with `psycopg2.DatabaseError: keploy-pg-v3: no recorded
31+
invocation matched`, every HTTP testcase that follows fails with
32+
connection-reset.
33+
34+
## Reproducing locally
35+
36+
```bash
37+
cd fastapi-sqlalchemy-pg-catalog
38+
docker compose build
39+
40+
# Baseline (no keploy) — should pass
41+
docker compose up -d
42+
bash flow.sh
43+
docker compose down -v
44+
45+
# Record
46+
( bash flow.sh > flow-record.log 2>&1 ) &
47+
sudo -E keploy record \
48+
-c "docker compose -f docker-compose.yml up" \
49+
--container-name pg-catalog-repro-app \
50+
--cmd-type docker-compose \
51+
--record-timer 60s
52+
53+
# Replay (pre-fix: FAILS with "no recorded invocation matched" on CREATE TABLE)
54+
sudo -E keploy test \
55+
-c "docker compose -f docker-compose.yml up" \
56+
--container-name pg-catalog-repro-app \
57+
--cmd-type docker-compose \
58+
--apiTimeout 120 --delay 15 --disableMockUpload
59+
```
60+
61+
## Layout
62+
63+
| File | Purpose |
64+
|-----------------------------|---------------------------------------------------------------------------|
65+
| `app/main.py` | FastAPI app with one declarative `Project` model + lifespan create_all |
66+
| `app/Dockerfile` | Python 3.12-slim + requirements |
67+
| `app/requirements.txt` | fastapi, uvicorn, sqlalchemy 2.0.36, psycopg2-binary 2.9.10 |
68+
| `docker-compose.yml` | postgres:13.22-alpine + app, app published at host port 8123 |
69+
| `init.sql` | Pre-creates the `project` table so record-time create_all is a no-op |
70+
| `flow.sh` | Drives `GET /health` and `GET /projects` against the app |
71+
72+
## Compose env knobs
73+
74+
Set these to isolate concurrent runs (the CI lane drives a 3-cell
75+
matrix on one Docker daemon and overrides each):
76+
77+
| Env var | Default | Purpose |
78+
|------------------|-------------------------|------------------------------------------|
79+
| `APP_CONTAINER` | `pg-catalog-repro-app` | App container name (keploy `--container-name`) |
80+
| `DB_CONTAINER` | `pg-catalog-repro-db` | Postgres container name |
81+
| `APP_HOST_PORT` | `8123` | Host-side port mapped to app's 8000 |
82+
| `COMPOSE_NET` | `reprnet` | Docker network name |
83+
84+
## Used by
85+
86+
* `keploy/integrations` Woodpecker lane
87+
`.woodpecker/sqlalchemy-pg-catalog-postgres.yml`
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM python:3.12-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY main.py .
9+
10+
EXPOSE 8000
11+
12+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
Minimal FastAPI + SQLAlchemy + psycopg2 app that exercises the Postgres
3+
v3 dispatcher's simple-query ClassCatalog branch via SQLAlchemy's
4+
``Base.metadata.create_all`` table-existence probe.
5+
6+
Boot sequence:
7+
1. SQLAlchemy creates an engine over psycopg2 (simple-query for
8+
parameter-less SQL).
9+
2. ``Base.metadata.create_all(engine)`` issues one
10+
``SELECT pg_catalog.pg_class.relname ...`` probe per declared table
11+
to decide whether each ``CREATE TABLE`` should be skipped.
12+
3. FastAPI starts serving requests.
13+
14+
The probe is what hits the dispatcher's ``case match.ClassCatalog``
15+
branch in ``pkg/postgres/v3/replayer/dispatcher/dispatcher.go``
16+
(simple-query path, ``dispatchBySQLHash``).
17+
"""
18+
19+
import logging
20+
import os
21+
import sys
22+
from contextlib import asynccontextmanager
23+
24+
from fastapi import FastAPI
25+
from sqlalchemy import Column, Integer, String, create_engine, select
26+
from sqlalchemy.orm import Session, declarative_base
27+
28+
logging.basicConfig(
29+
level=logging.INFO,
30+
stream=sys.stdout,
31+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
32+
)
33+
log = logging.getLogger("repro")
34+
35+
DATABASE_URL = os.environ["DATABASE_URL"]
36+
37+
Base = declarative_base()
38+
39+
40+
class Project(Base):
41+
__tablename__ = "project"
42+
43+
id = Column(Integer, primary_key=True)
44+
name = Column(String(100), nullable=False)
45+
46+
47+
engine = create_engine(DATABASE_URL, echo=True, future=True)
48+
49+
50+
@asynccontextmanager
51+
async def lifespan(_: FastAPI):
52+
log.info("startup: running Base.metadata.create_all (pg_class probe expected)")
53+
Base.metadata.create_all(engine)
54+
log.info("startup: create_all complete")
55+
yield
56+
57+
58+
app = FastAPI(lifespan=lifespan)
59+
60+
61+
@app.get("/health")
62+
def health():
63+
return {"ok": True}
64+
65+
66+
@app.get("/projects")
67+
def list_projects():
68+
with Session(engine) as s:
69+
rows = s.execute(select(Project)).scalars().all()
70+
return [{"id": r.id, "name": r.name} for r in rows]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fastapi==0.115.0
2+
uvicorn==0.30.6
3+
sqlalchemy==2.0.36
4+
psycopg2-binary==2.9.10
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
services:
2+
postgres:
3+
image: postgres:13.22-alpine
4+
container_name: ${DB_CONTAINER:-pg-catalog-repro-db}
5+
environment:
6+
POSTGRES_PASSWORD: postgres
7+
POSTGRES_DB: testdb
8+
volumes:
9+
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
10+
networks:
11+
- reprnet
12+
healthcheck:
13+
test: ["CMD-SHELL", "pg_isready -U postgres -d testdb"]
14+
interval: 2s
15+
timeout: 2s
16+
retries: 30
17+
18+
app:
19+
build: ./app
20+
container_name: ${APP_CONTAINER:-pg-catalog-repro-app}
21+
environment:
22+
DATABASE_URL: postgresql+psycopg2://postgres:postgres@postgres:5432/testdb
23+
depends_on:
24+
postgres:
25+
condition: service_healthy
26+
ports:
27+
- "${APP_HOST_PORT:-8123}:8000"
28+
networks:
29+
- reprnet
30+
31+
networks:
32+
reprnet:
33+
name: ${COMPOSE_NET:-reprnet}
34+
driver: bridge
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
# Drives traffic during keploy record. Hits both endpoints.
3+
set -uo pipefail
4+
5+
APP_HOST_PORT="${APP_HOST_PORT:-8123}"
6+
APP_URL="${APP_URL:-http://localhost:${APP_HOST_PORT}}"
7+
8+
echo "[flow] waiting for app at $APP_URL ..."
9+
for i in $(seq 1 60); do
10+
if curl -sf "$APP_URL/health" > /dev/null 2>&1; then
11+
echo "[flow] app ready after ${i}s"
12+
break
13+
fi
14+
sleep 1
15+
done
16+
17+
echo "[flow] GET /health"
18+
curl -sS "$APP_URL/health" && echo
19+
20+
echo "[flow] GET /projects"
21+
curl -sS "$APP_URL/projects" && echo
22+
23+
echo "[flow] done"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Pre-create the `project` table so SQLAlchemy's create_all() sees it
2+
-- exists at record time and skips CREATE TABLE. This is what the bug
3+
-- (keploy/integrations#193) requires: at record time the pg_class
4+
-- probe answers "table exists", so CREATE TABLE is never sent and
5+
-- never recorded. At replay time, if the simple-query dispatcher path
6+
-- skips the recorded mock, the synthetic catalog engine returns zero
7+
-- rows, SQLAlchemy concludes "table missing", and issues an
8+
-- unrecorded CREATE TABLE -- which then misses the transactional
9+
-- engine, raises a DatabaseError, and kills app boot.
10+
CREATE TABLE IF NOT EXISTS project (
11+
id SERIAL PRIMARY KEY,
12+
name VARCHAR(100) NOT NULL
13+
);
14+
15+
INSERT INTO project (name) VALUES ('seed') ON CONFLICT DO NOTHING;

0 commit comments

Comments
 (0)