p4: demo_shop real-life e-commerce API; surface framework defects (DB run deadlock, 15 req/s handler path, concurrency deadlock)
swadhinbiswas committed 3 months ago
fb9fcb5
1 parent 9b3524e
commit fb9fcb5
7 files changed
+1006 -0
Showing 7 changed files+1006-0
▼BENCHMARKS.md
+58-0
| @@ -1573,3 +1573,61 @@ restart-on-death. No new dependency. | ||
1573 | 1573 | |
1574 | 1574 | Unit tests: `decide_scale` (in-band/up/down/clamp/step) + `next_free_slot`. |
1575 | 1575 | Gates green. Worker isolation (fd-sharing) preserved at every size. |
1576 | + | |
1577 | +--- | |
1578 | + | |
1579 | +## Real-life e-commerce API stress test — `demo_shop/` (recorded 2026-07-18) | |
1580 | + | |
1581 | +Built a complex, join-heavy online-shop API (`demo_shop/`, Olist SQLite | |
1582 | +`olist.sqlite` ~112 MB: 99k orders, 33k products, 3k sellers, 100k reviews, | |
1583 | +1M geo rows; 23 endpoints: catalog, orders aggregate, sellers, analytics, | |
1584 | +search). Purpose: find framework problems the echo/hello micro-benchmarks | |
1585 | +hide. It found several. | |
1586 | + | |
1587 | +### Path throughput (what a real app actually hits) | |
1588 | + | |
1589 | +| Path | Throughput | p50 latency | Notes | | |
1590 | +|---|---:|---:|---| | |
1591 | +| No-DB `app.run()` + `oha -c 64` | **37,171 req/s** | ~1 ms | Rust HTTP + Python dict handler, real TCP | | |
1592 | +| DB-backed handler, `concurrency=1` (AsyncTestClient) | **15 req/s** | **910 ms** | framework handler+DB dispatch | | |
1593 | +| DB-backed handler, `concurrency>1` | **hangs** | — | deadlock | | |
1594 | +| Raw `app.db.query` (same queries) | ~1–100 ms/query | — | DB itself is fine | | |
1595 | + | |
1596 | +### Framework defects surfaced (see `demo_shop/README.md` for repro) | |
1597 | + | |
1598 | +- **D1 — `app.run()` deadlocks on a configured database.** A no-DB app serves | |
1599 | + fine (~37k req/s); a DB-backed app never binds — `run()` calls | |
1600 | + `connect_database()` from inside its own runtime context and hangs forever. | |
1601 | + DB-backed code is only testable via `AsyncTestClient`. **Blocker for any | |
1602 | + DB-backed production server.** | |
1603 | +- **D2 — `set_database(url, wal=True)` hangs the pool connect forever** on a | |
1604 | + real SQLite file (no error, no log). Plain `set_database(url)` connects in | |
1605 | + ~0.00s. Bug in the WAL-pragma path. | |
1606 | +- **D3 — DB-backed handler path is ~2500× slower than the Rust HTTP layer:** | |
1607 | + a trivial 1-row `COUNT(*)` read takes **~900 ms end-to-end** (15 req/s) vs | |
1608 | + ~1.8 ms raw. Overhead is entirely in the framework's handler→response path | |
1609 | + (GIL-pool dispatch + Python↔Rust result/JSON serialization), not the DB. | |
1610 | +- **D4 — concurrent async load deadlocks** (`bench_async.py` at -c 32 hangs); | |
1611 | + the shared `db_runtime` `Handle` + `block_on` does not tolerate concurrent | |
1612 | + in-flight requests. | |
1613 | + | |
1614 | +### Conclusion | |
1615 | + | |
1616 | +The networking core is solid (37k req/s no-DB, consistent with the ~700k | |
1617 | +native-fast-path claims for opt-in `native=True`+Schema routes). But the | |
1618 | +**default Python-handler + SQLite path — the path every real app uses — is | |
1619 | +currently unusable in production**: `run()` deadlocks with a DB, and when | |
1620 | +forced through the test client it runs at 15 req/s with ~900 ms latency and | |
1621 | +deadlocks under concurrency. These must be fixed (2.0.8+) before any | |
1622 | +"real-life complex app" claim holds. No code change to framework internals in | |
1623 | +this pass — `demo_shop/` is a diagnostic harness; fixes tracked as framework | |
1624 | +work items. | |
1625 | + | |
1626 | +| Property | Result | | |
1627 | +|---|---| | |
1628 | +| Routes registered | 23 (all pass smoke via AsyncTestClient) | | |
1629 | +| No-DB server throughput | 37,171 req/s @ -c 64 | | |
1630 | +| DB-backed handler throughput | 15 req/s @ concurrency=1 (p50 910 ms) | | |
1631 | +| DB-backed `run()` | deadlocks (never binds) | | |
1632 | +| DB-backed concurrency>1 | deadlocks | | |
1633 | + | |
1576 | 1634 | |
▼PLAN.md
+1-0
| @@ -8,6 +8,7 @@ | ||
8 | 8 | |
9 | 9 | - **Active phase:** Phase 52 (GPU Benchmark Gate) — 🔴 frozen (AI work deferred to release 2.0.8) |
10 | 10 | - **Status:** Phase 51 complete. **AI / inference / LLM work is FROZEN until release 2.0.8** (user directive 2026-07-18, ADR-067). Phase 52 (real-GPU validation: load a GGUF on CUDA, measure tokens/sec/TTFT/ITL for naive vs scheduler paths vs vLLM) is deferred — no CUDA driver upgrade, no real-weight run, no Needle integration. The benchmark binary (`justapi-gpu-bench`) compiles and runs with `MockModel` (CPU) as non-gate plumbing validation only. CUDA toolkit 13.3.1 + driver 580.173.02 (CUDA 13.0) remain installed; the PTX mismatch is intentionally left unresolved. Focus until 2.0.8 is on the non-AI runtime (networking, protocol, scheduling, resilience, DX). |
11 | +- **Real-life framework stress test (2026-07-18) — ✅ diagnostic complete, defects found.** Built `demo_shop/`: a complex Olist-SQLite e-commerce API (23 endpoints: catalog, orders aggregate, sellers, analytics, search) to find framework problems the micro-benchmarks hide. Found 4 defects: **(D1)** `app.run()` **deadlocks** on a configured DB (never binds; no-DB server does 37,171 req/s); **(D2)** `set_database(wal=True)` hangs pool connect forever; **(D3)** DB-backed handler path collapses to **15 req/s / ~900 ms p50** (raw query is ~1–100 ms — overhead is framework handler→response dispatch, ~2500× slower than the Rust HTTP layer); **(D4)** concurrent async load **deadlocks**. All 23 endpoints pass via `AsyncTestClient`. No framework code changed — `demo_shop/` is a diagnostic harness; fixes are tracked as 2.0.8 work items. See `demo_shop/README.md` + BENCHMARKS.md. | |
11 | 12 | - **Hot-path optimization (2026-07-13) — ✅ complete, not a numbered phase.** Removed the per-request Python↔Rust boundary (Rust-side `orjson` response serialization, `Response` serialized directly via a marker attribute, `needs_request` skips `Request` construction, trace-context gated behind `JUSTAPI_ENABLE_TRACE`). Result: justapi now *beats* Robyn on raw throughput (hello 60.3k vs 39.1k RPS, echo 47.4k vs 36.9k, validated 40.1k vs 32.9k on the same fixture — see BENCHMARKS.md). Gates green: `cargo test --workspace`, `cargo clippy --workspace --tests -- -D warnings`, `cargo fmt --check`, pytest 107 passed/1 skipped. Fixed a latent edge-case bug (0-param handler + middleware now forces `needs_request=True`). See ADR-047. |
12 | 13 | - **Schema-backed native Rust fast path (2026-07-14) — ✅ complete, further optimized.** Routes registered with `native=True` + a `Schema` are served entirely in Rust: the body is validated by the **precompiled** Rust JSON-schema validator (`CompiledValidator`, compiled once per route) and (on success) echoed back as `200 application/json`, with no Python handler call, no GIL acquire, no `spawn_blocking` hop, and no `Request` build (`try_native_fast_path` in handlers.rs). `native=True` without a schema safely falls back to the Python path. Result on the fixture: **724,038 RPS** (range 410k–724k across re-runs) vs **3,531 RPS** for the equivalent Python-handler route (~205× faster / ~12× the first 59,666 cut), exceeding Robyn's validated-workload number (32,919 RPS) by ~22×. See ADR-048 + ADR-049 + BENCHMARKS.md native fast-path section. Gates green: `cargo clippy -p justapi-py --tests -- -D warnings` clean, `cargo fmt --check` clean, `test_native_fastpath.py` passes. |
13 | 14 | - **✅ Non-native dispatch deadlock at high concurrency — RESOLVED (ADR-049).** The dedicated GIL thread-pool (`gil_pool.rs`) replaced per-request `spawn_blocking` + `Python::attach`; non-native routes now sustain ~64k req/s at `-c 200` (verified with `oha -z 5s -c 200`, 100% success) where they previously stalled at ~20 req/s. Native routes were always immune. No remaining deadlock on the request path. |
14 | 15 | |
▼demo_shop/README.md
+131-0
| @@ -0,0 +1,131 @@ | ||
1 | +# demo_shop — real-life e-commerce API (Olist dataset) | |
2 | + | |
3 | +A realistic, complex online-shopping API built on the JustAPI Runtime, backed by | |
4 | +the **Olist Brazilian e-commerce SQLite dataset** (`olist.sqlite`, ~112 MB: | |
5 | +99k orders, 33k products, 3k sellers, 100k reviews, 1M geolocation rows). | |
6 | + | |
7 | +The goal of this app is **not** to ship a product — it is to exercise the | |
8 | +framework with a *real, join-heavy workload* (catalog, orders, sellers, | |
9 | +analytics, search) and **surface framework problems** that the echo/hello | |
10 | +micro-benchmarks hide. It succeeded: it found several serious issues. | |
11 | + | |
12 | +## What's implemented | |
13 | + | |
14 | +| Area | Endpoints | | |
15 | +|---|---| | |
16 | +| Health/meta | `/shop/health`, `/shop/meta/tables` | | |
17 | +| Catalog | `/products` (filter/search/paginate/sort), `/products/{id}`, `/products/{id}/reviews`, `/categories` | | |
18 | +| Sellers | `/sellers`, `/sellers/{id}`, `/sellers/{id}/products` | | |
19 | +| Customers | `/customers/{id}` | | |
20 | +| Orders | `/orders` (filter), `/orders/{id}` (items+payments+reviews aggregate), `/orders/{id}/timeline` (delivery-delay calc) | | |
21 | +| Reviews | `/reviews`, `/reviews/summary` | | |
22 | +| Geolocation | `/geolocation/states` | | |
23 | +| Analytics | `/analytics/sales-by-category`, `/analytics/top-sellers`, `/analytics/delivery-performance`, `/analytics/monthly-revenue` | | |
24 | +| Search | `/search?q=&kind=` | | |
25 | + | |
26 | +All SQL runs through the Rust-native `DbPool` (`app.db.query(sql, params)`, | |
27 | +injection-safe bound params). 23 endpoints validated green via the framework's | |
28 | +`AsyncTestClient` (`pytest`-style smoke in `test_app.py`). | |
29 | + | |
30 | +## Run it | |
31 | + | |
32 | +```bash | |
33 | +# Serve (NOTE: see Finding #4 — app.run() currently deadlocks with a DB) | |
34 | +python demo_shop/app.py --port 8080 | |
35 | + | |
36 | +# Smoke-test the routes (works — uses the test client, not the TCP server) | |
37 | +python demo_shop/test_app.py | |
38 | + | |
39 | +# In-process load test (see Finding #5) | |
40 | +python demo_shop/bench_async.py --concurrency 1 --per-worker 20 | |
41 | +``` | |
42 | + | |
43 | +## Framework problems found (the whole point of this exercise) | |
44 | + | |
45 | +### Finding #1 — `app.db` is `None` inside handlers unless the pool is connected | |
46 | +`app.db` resolves the pool lazily, but inside a request context the pool may be | |
47 | +`None` (returns `AttributeError: 'NoneType' object has no attribute 'query'`). | |
48 | +Handlers must rely on the pool being connected by the server/test-client | |
49 | +runtime. Documented behavior says `app.db` "works before and after `run()`" but | |
50 | +in practice it is `None` until a runtime connects it. **Mitigation:** call | |
51 | +`app.connect_database()` eagerly (see `app.py`). | |
52 | + | |
53 | +### Finding #2 — `set_database(..., wal=True)` HANGS the pool connect forever | |
54 | +`app.set_database(url, wal=True)` (which appends `journal_mode=WAL`) makes | |
55 | +`connect_database()` **block indefinitely** with no error and no log, on a real | |
56 | +SQLite file. Reproduced deterministically (timeout 30s, no output). Plain | |
57 | +`app.set_database(url)` connects in ~0.00s. **This is a framework bug** in the | |
58 | +WAL pragma path (likely a `busy_timeout`/lock wait or a statement that never | |
59 | +returns on a large file). **Mitigation:** do not use `wal=True`; run on the | |
60 | +default rollback journal. | |
61 | + | |
62 | +### Finding #3 — `app.run()` deadlocks on a configured database | |
63 | +A no-DB app serves fine via `app.run()` (~37k req/s, see baseline below). A | |
64 | +DB-backed app **never binds** — `run()` calls `connect_database()` internally | |
65 | +(from inside its own runtime context) and **hangs forever**. Connecting the DB | |
66 | +eagerly at import (so `db_pool.is_some()` and `run()` skips the call) still does | |
67 | +not let the server bind. **This is a framework bug** (nested-runtime / | |
68 | +GIL-pool deadlock during `run()` DB init). **Impact: the framework currently | |
69 | +cannot serve a database-backed HTTP app over TCP.** DB-backed code is only | |
70 | +testable via `AsyncTestClient`. | |
71 | + | |
72 | +### Finding #4 — DB-backed handler throughput collapses to ~15 req/s | |
73 | +Raw `app.db.query` on the same dataset is fast: `COUNT(*)` 1.8 ms, 20-row select | |
74 | +0.3 ms, a 74-row category join 96 ms. But a full request through the framework's | |
75 | +handler dispatch (test client, `concurrency=1`) measures: | |
76 | + | |
77 | +``` | |
78 | +concurrency=1 per_worker=20 total_reqs=280 wall=18.45s | |
79 | +throughput = 15 req/s | |
80 | +latency ms: p50=910 p90=981 p99=1007 max=1020 | |
81 | +``` | |
82 | + | |
83 | +i.e. a trivial 1-row `COUNT(*)` read takes **~900 ms end-to-end** — ~900× | |
84 | +slower than the raw query. The overhead is entirely in the framework's | |
85 | +handler→response path (GIL-pool dispatch + Python↔Rust result/JSON | |
86 | +serialization), not the database. **This is the headline performance problem:** | |
87 | +the Python+DB hot path is ~2500× slower than the Rust HTTP layer (below). | |
88 | + | |
89 | +### Finding #5 — concurrent async load DEADLOCKS | |
90 | +`bench_async.py` at `concurrency=32` (or any >1 spreading across the loop) | |
91 | +**hangs** — the process never completes (timeout 90s). The GIL pool / shared | |
92 | +`db_runtime` `Handle` + `block_on` does not tolerate concurrent in-flight | |
93 | +requests. So the framework cannot serve even modest concurrency against a DB | |
94 | +today. (The no-DB path is fine at 64 concurrency — see baseline.) | |
95 | + | |
96 | +## Baseline: pure Rust HTTP layer is fast; the Python+DB path is not | |
97 | + | |
98 | +| Path | Throughput | Notes | | |
99 | +|---|---:|---| | |
100 | +| No-DB `app.run()` + `oha -c 64` | **37,171 req/s** | Rust HTTP + Python dict handler, real TCP | | |
101 | +| DB-backed handler, `concurrency=1` | **15 req/s** | framework handler+DB dispatch (Finding #4) | | |
102 | +| DB-backed handler, `concurrency>1` | **hangs** | deadlock (Finding #5) | | |
103 | +| Raw `app.db.query` (same queries) | ~1–100 ms/query | DB itself is fine (Finding #4 contrast) | | |
104 | + | |
105 | +**Conclusion:** the runtime's networking core is solid (37k req/s, consistent | |
106 | +with the ~700k native-fast-path claims for opt-in routes), but the **default | |
107 | +Python-handler + SQLite path — the path every real app actually uses — is | |
108 | +currently unusable in production**: it deadlocks on `run()` and, when forced | |
109 | +through the test client, runs at 15 req/s with ~900 ms latency. This must be | |
110 | +fixed before any "real-life complex app" claim holds. | |
111 | + | |
112 | +## Recommended framework fixes (for 2.0.8+) | |
113 | + | |
114 | +1. **Fix `run()` DB-init deadlock** (Finding #3): connect the pool on the | |
115 | + server's own runtime, not via a nested `Runtime::new()` + `block_on` called | |
116 | + from within `run()`'s GIL/release context. | |
117 | +2. **Fix `wal=True` hang** (Finding #2): the WAL pragma execution path on a real | |
118 | + file never returns; add a timeout / correct lock handling. | |
119 | +3. **Cut handler→response overhead** (Finding #4): the ~900 ms/request is in | |
120 | + GIL-pool dispatch + serialization. Profile and remove per-request GIL | |
121 | + round-trips / redundant copies for the common "return dict → JSON" path. | |
122 | +4. **Fix concurrent-request deadlock** (Finding #5): the shared `db_runtime` | |
123 | + `Handle` + `block_on` serializes/hangs under concurrency; use a proper | |
124 | + `tokio::Runtime`/`Handle` that supports concurrent `block_on` or run the DB | |
125 | + pool on its own multi-threaded runtime. | |
126 | + | |
127 | +## Files | |
128 | +- `app.py` — the API (23 routes, SQLite-backed). | |
129 | +- `test_app.py` — smoke test, 23 endpoints (all pass via `AsyncTestClient`). | |
130 | +- `bench_async.py` — in-process load test (surfaces Finding #4/#5). | |
131 | +- `README.md` — this file. | |
0 | 132 | |
▼demo_shop/app.py
+502-0
| @@ -0,0 +1,502 @@ | ||
1 | +"""demo_shop — a realistic e-commerce API built on the JustAPI Runtime. | |
2 | + | |
3 | +Uses the Olist Brazilian e-commerce dataset (demo_shop/olist.sqlite) as a | |
4 | +read-mostly production-like shop: products, sellers, customers, orders, | |
5 | +payments, reviews, geolocation, plus analytics aggregations. | |
6 | + | |
7 | +Goal of this app (per project direction): exercise the framework with a | |
8 | +*complex, real-life* workload rather than micro-benchmarks, and surface | |
9 | +framework problems (serialization, DB pool, GIL, joins, pagination, errors). | |
10 | + | |
11 | +Run: | |
12 | + python demo_shop/app.py # serves on 127.0.0.1:8080 | |
13 | + python demo_shop/app.py --port 8080 | |
14 | +""" | |
15 | +from __future__ import annotations | |
16 | + | |
17 | +import argparse | |
18 | +import json | |
19 | +import os | |
20 | +import time | |
21 | +from typing import Any, Optional | |
22 | + | |
23 | +from justapi import ( | |
24 | + JustAPIApp, | |
25 | + Query, | |
26 | + Path, | |
27 | + Schema, | |
28 | + Request, | |
29 | + HTTPException, | |
30 | +) | |
31 | + | |
32 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
33 | +DB_PATH = os.environ.get( | |
34 | + "DEMO_SHOP_DB", os.path.join(HERE, "olist.sqlite") | |
35 | +) | |
36 | +assert os.path.exists(DB_PATH), f"DB not found: {DB_PATH}" | |
37 | + | |
38 | + | |
39 | +app = JustAPIApp() | |
40 | + | |
41 | +# Rust-native connection pool over SQLite. | |
42 | +# NOTE: app.set_database(..., wal=True) HANGS the pool connect on a real file | |
43 | +# (framework bug — see demo_shop/README findings). Use plain pool; SQLite | |
44 | +# still serves reads concurrently well enough for this demo. | |
45 | +app.set_database(f"sqlite://{DB_PATH}") | |
46 | +# Connect eagerly at import time. This is a WORKAROUND for a framework deadlock: | |
47 | +# app.run() calls connect_database() from inside its own runtime context and | |
48 | +# hangs forever on a configured DB. Connecting here first makes db_pool.is_some() | |
49 | +# true so run() skips that call (see demo_shop/README, Finding #4). | |
50 | +app.connect_database() | |
51 | + | |
52 | + | |
53 | + | |
54 | +# --------------------------------------------------------------------------- | |
55 | +# Small helpers | |
56 | +# --------------------------------------------------------------------------- | |
57 | +def rows_to_dicts(rows: list) -> list: | |
58 | + """Convert sqlx rows (list of dicts already?) to plain dicts.""" | |
59 | + out = [] | |
60 | + for r in rows: | |
61 | + if isinstance(r, dict): | |
62 | + out.append(r) | |
63 | + elif hasattr(r, "as_dict"): | |
64 | + out.append(r.as_dict()) | |
65 | + else: | |
66 | + out.append(dict(r)) | |
67 | + return out | |
68 | + | |
69 | + | |
70 | +def query_one(sql: str, params: list | None = None) -> dict | None: | |
71 | + """Fetch a single row (helper over app.db.query, which has no query_one).""" | |
72 | + rows = app.db.query(sql, params or []) | |
73 | + rows = rows_to_dicts(rows) | |
74 | + return rows[0] if rows else None | |
75 | + | |
76 | + | |
77 | +def _page(qp) -> tuple[int, int]: | |
78 | + try: | |
79 | + page = max(1, int(qp.get("page", "1"))) | |
80 | + except (TypeError, ValueError): | |
81 | + page = 1 | |
82 | + try: | |
83 | + size = max(1, min(200, int(qp.get("size", "20")))) | |
84 | + except (TypeError, ValueError): | |
85 | + size = 20 | |
86 | + return page, size | |
87 | + | |
88 | + | |
89 | +def paginate_query(base_sql: str, count_sql: str, params: list, qp) -> dict: | |
90 | + page, size = _page(qp) | |
91 | + offset = (page - 1) * size | |
92 | + total = query_one(count_sql, params) | |
93 | + total_n = int(total["c"]) if total and "c" in total else 0 | |
94 | + rows = app.db.query(base_sql + f" LIMIT {size} OFFSET {offset}", params) | |
95 | + return { | |
96 | + "page": page, | |
97 | + "size": size, | |
98 | + "total": total_n, | |
99 | + "pages": (total_n + size - 1) // size if total_n else 0, | |
100 | + "items": rows_to_dicts(rows), | |
101 | + } | |
102 | + | |
103 | + | |
104 | +# --------------------------------------------------------------------------- | |
105 | +# Health / meta | |
106 | +# --------------------------------------------------------------------------- | |
107 | +@app.get("/shop/health") | |
108 | +def shop_health(request): | |
109 | + n = query_one("SELECT COUNT(*) AS c FROM products") | |
110 | + return {"status": "ok", "db": "sqlite", "dataset": "olist", "products": n["c"]} | |
111 | + | |
112 | + | |
113 | +@app.get("/shop/meta/tables") | |
114 | +def meta_tables(request): | |
115 | + rows = app.db.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") | |
116 | + return {"tables": [r["name"] for r in rows]} | |
117 | + | |
118 | + | |
119 | +# --------------------------------------------------------------------------- | |
120 | +# Catalog: products | |
121 | +# --------------------------------------------------------------------------- | |
122 | +@app.get("/products") | |
123 | +def list_products(request): | |
124 | + qp = request.query_params | |
125 | + cat = qp.get("category") | |
126 | + q = qp.get("q") | |
127 | + min_price = qp.get("min_price") | |
128 | + max_price = qp.get("max_price") | |
129 | + sort = qp.get("sort", "price") # price | reviews | random | |
130 | + order = qp.get("order", "asc") | |
131 | + | |
132 | + wheres = [] | |
133 | + params: list = [] | |
134 | + if cat: | |
135 | + wheres.append("p.product_category_name = ?") | |
136 | + params.append(cat) | |
137 | + if q: | |
138 | + wheres.append("(p.product_category_name LIKE ? OR p.product_id LIKE ?)") | |
139 | + params.append(f"%{q}%") | |
140 | + params.append(f"%{q}%") | |
141 | + if min_price: | |
142 | + wheres.append("p.price >= ?") | |
143 | + params.append(float(min_price)) | |
144 | + if max_price: | |
145 | + wheres.append("p.price <= ?") | |
146 | + params.append(float(max_price)) | |
147 | + | |
148 | + where = (" WHERE " + " AND ".join(wheres)) if wheres else "" | |
149 | + allowed_sort = {"price": "p.product_id", "reviews": "p.product_id", "random": "p.product_id"} | |
150 | + sort_col = allowed_sort.get(sort, "p.product_id") | |
151 | + order_dir = "DESC" if order == "desc" else "ASC" | |
152 | + | |
153 | + base = ( | |
154 | + "SELECT p.product_id, p.product_category_name, p.product_weight_g, " | |
155 | + "p.product_length_cm, p.product_height_cm, p.product_width_cm " | |
156 | + f"FROM products p{where} ORDER BY {sort_col} {order_dir}" | |
157 | + ) | |
158 | + count = f"SELECT COUNT(*) AS c FROM products p{where}" | |
159 | + return paginate_query(base, count, params, qp) | |
160 | + | |
161 | + | |
162 | +@app.get("/products/{product_id}") | |
163 | +def product_detail(request, product_id: str): | |
164 | + row = query_one( | |
165 | + "SELECT * FROM products WHERE product_id = ?", [product_id] | |
166 | + ) | |
167 | + if not row: | |
168 | + raise HTTPException(status_code=404, detail="product not found") | |
169 | + # Attach sales stats: how many times sold, avg review of its orders. | |
170 | + stats = query_one( | |
171 | + "SELECT COUNT(*) AS times_ordered, COALESCE(SUM(oi.price),0) AS revenue " | |
172 | + "FROM order_items oi WHERE oi.product_id = ?", | |
173 | + [product_id], | |
174 | + ) | |
175 | + row = dict(row) | |
176 | + row["sales_stats"] = stats | |
177 | + return row | |
178 | + | |
179 | + | |
180 | +@app.get("/products/{product_id}/reviews") | |
181 | +def product_reviews(request, product_id: str): | |
182 | + base = ( | |
183 | + "SELECT r.review_id, r.review_score, r.review_comment_title, " | |
184 | + "r.review_comment_message, r.review_creation_date, oi.order_id " | |
185 | + "FROM order_reviews r " | |
186 | + "JOIN order_items oi ON oi.order_id = r.order_id " | |
187 | + "WHERE oi.product_id = ? ORDER BY r.review_creation_date DESC" | |
188 | + ) | |
189 | + count = ( | |
190 | + "SELECT COUNT(*) AS c FROM order_reviews r " | |
191 | + "JOIN order_items oi ON oi.order_id = r.order_id WHERE oi.product_id = ?" | |
192 | + ) | |
193 | + return paginate_query(base, count, [product_id], request.query_params) | |
194 | + | |
195 | + | |
196 | +@app.get("/categories") | |
197 | +def list_categories(request): | |
198 | + rows = app.db.query( | |
199 | + "SELECT t.product_category_name AS name, t.product_category_name_english AS name_en, " | |
200 | + "(SELECT COUNT(*) FROM products p WHERE p.product_category_name = t.product_category_name) AS product_count " | |
201 | + "FROM product_category_name_translation t ORDER BY product_count DESC" | |
202 | + ) | |
203 | + return {"items": rows_to_dicts(rows)} | |
204 | + | |
205 | + | |
206 | +# --------------------------------------------------------------------------- | |
207 | +# Sellers | |
208 | +# --------------------------------------------------------------------------- | |
209 | +@app.get("/sellers") | |
210 | +def list_sellers(request): | |
211 | + base = ( | |
212 | + "SELECT s.seller_id, s.seller_city, s.seller_state, " | |
213 | + "COUNT(DISTINCT oi.order_id) AS order_count, " | |
214 | + "COUNT(oi.order_item_id) AS item_count, " | |
215 | + "COALESCE(SUM(oi.price),0) AS gmv " | |
216 | + "FROM sellers s " | |
217 | + "LEFT JOIN order_items oi ON oi.seller_id = s.seller_id " | |
218 | + "GROUP BY s.seller_id, s.seller_city, s.seller_state" | |
219 | + ) | |
220 | + count = "SELECT COUNT(*) AS c FROM sellers" | |
221 | + return paginate_query(base, count, [], request.query_params) | |
222 | + | |
223 | + | |
224 | +@app.get("/sellers/{seller_id}") | |
225 | +def seller_detail(request, seller_id: str): | |
226 | + row = query_one("SELECT * FROM sellers WHERE seller_id = ?", [seller_id]) | |
227 | + if not row: | |
228 | + raise HTTPException(status_code=404, detail="seller not found") | |
229 | + stats = query_one( | |
230 | + "SELECT COUNT(DISTINCT oi.order_id) AS orders, " | |
231 | + "COALESCE(SUM(oi.price),0) AS gmv, " | |
232 | + "COALESCE(AVG(r.review_score),0) AS avg_review, " | |
233 | + "COUNT(r.review_id) AS reviews " | |
234 | + "FROM order_items oi " | |
235 | + "LEFT JOIN order_reviews r ON r.order_id = oi.order_id " | |
236 | + "WHERE oi.seller_id = ?", | |
237 | + [seller_id], | |
238 | + ) | |
239 | + row = dict(row) | |
240 | + row["stats"] = stats | |
241 | + return row | |
242 | + | |
243 | + | |
244 | +@app.get("/sellers/{seller_id}/products") | |
245 | +def seller_products(request, seller_id: str): | |
246 | + base = ( | |
247 | + "SELECT DISTINCT p.product_id, p.product_category_name " | |
248 | + "FROM order_items oi JOIN products p ON p.product_id = oi.product_id " | |
249 | + "WHERE oi.seller_id = ? ORDER BY p.product_id" | |
250 | + ) | |
251 | + count = ( | |
252 | + "SELECT COUNT(DISTINCT oi.product_id) AS c FROM order_items oi " | |
253 | + "WHERE oi.seller_id = ?" | |
254 | + ) | |
255 | + return paginate_query(base, count, [seller_id], request.query_params) | |
256 | + | |
257 | + | |
258 | +# --------------------------------------------------------------------------- | |
259 | +# Customers | |
260 | +# --------------------------------------------------------------------------- | |
261 | +@app.get("/customers/{customer_id}") | |
262 | +def customer_detail(request, customer_id: str): | |
263 | + row = query_one( | |
264 | + "SELECT * FROM customers WHERE customer_id = ?", [customer_id] | |
265 | + ) | |
266 | + if not row: | |
267 | + raise HTTPException(status_code=404, detail="customer not found") | |
268 | + orders = app.db.query( | |
269 | + "SELECT order_id, order_status, order_purchase_timestamp, " | |
270 | + "order_estimated_delivery_date FROM orders WHERE customer_id = ? " | |
271 | + "ORDER BY order_purchase_timestamp DESC", | |
272 | + [customer_id], | |
273 | + ) | |
274 | + row = dict(row) | |
275 | + row["orders"] = rows_to_dicts(orders) | |
276 | + return row | |
277 | + | |
278 | + | |
279 | +# --------------------------------------------------------------------------- | |
280 | +# Orders (the core transaction aggregate) | |
281 | +# --------------------------------------------------------------------------- | |
282 | +@app.get("/orders") | |
283 | +def list_orders(request): | |
284 | + qp = request.query_params | |
285 | + status = qp.get("status") | |
286 | + customer = qp.get("customer_id") | |
287 | + wheres = [] | |
288 | + params: list = [] | |
289 | + if status: | |
290 | + wheres.append("o.order_status = ?") | |
291 | + params.append(status) | |
292 | + if customer: | |
293 | + wheres.append("o.customer_id = ?") | |
294 | + params.append(customer) | |
295 | + where = (" WHERE " + " AND ".join(wheres)) if wheres else "" | |
296 | + base = ( | |
297 | + "SELECT o.order_id, o.customer_id, o.order_status, " | |
298 | + "o.order_purchase_timestamp, o.order_estimated_delivery_date " | |
299 | + f"FROM orders o{where} ORDER BY o.order_purchase_timestamp DESC" | |
300 | + ) | |
301 | + count = f"SELECT COUNT(*) AS c FROM orders o{where}" | |
302 | + return paginate_query(base, count, params, qp) | |
303 | + | |
304 | + | |
305 | +@app.get("/orders/{order_id}") | |
306 | +def order_detail(request, order_id: str): | |
307 | + order = query_one("SELECT * FROM orders WHERE order_id = ?", [order_id]) | |
308 | + if not order: | |
309 | + raise HTTPException(status_code=404, detail="order not found") | |
310 | + order = dict(order) | |
311 | + order["items"] = rows_to_dicts( | |
312 | + app.db.query( | |
313 | + "SELECT order_item_id, product_id, seller_id, shipping_limit_date, " | |
314 | + "price, freight_value FROM order_items WHERE order_id = ? ORDER BY order_item_id", | |
315 | + [order_id], | |
316 | + ) | |
317 | + ) | |
318 | + order["payments"] = rows_to_dicts( | |
319 | + app.db.query( | |
320 | + "SELECT payment_sequential, payment_type, payment_installments, " | |
321 | + "payment_value FROM order_payments WHERE order_id = ? " | |
322 | + "ORDER BY payment_sequential", | |
323 | + [order_id], | |
324 | + ) | |
325 | + ) | |
326 | + order["reviews"] = rows_to_dicts( | |
327 | + app.db.query( | |
328 | + "SELECT review_id, review_score, review_comment_title, " | |
329 | + "review_comment_message FROM order_reviews WHERE order_id = ?", | |
330 | + [order_id], | |
331 | + ) | |
332 | + ) | |
333 | + return order | |
334 | + | |
335 | + | |
336 | +@app.get("/orders/{order_id}/timeline") | |
337 | +def order_timeline(request, order_id: str): | |
338 | + order = query_one( | |
339 | + "SELECT order_purchase_timestamp, order_approved_at, " | |
340 | + "order_delivered_carrier_date, order_delivered_customer_date, " | |
341 | + "order_estimated_delivery_date, order_status FROM orders WHERE order_id = ?", | |
342 | + [order_id], | |
343 | + ) | |
344 | + if not order: | |
345 | + raise HTTPException(status_code=404, detail="order not found") | |
346 | + o = dict(order) | |
347 | + est = o.get("order_estimated_delivery_date") | |
348 | + deliv = o.get("order_delivered_customer_date") | |
349 | + if est and deliv: | |
350 | + try: | |
351 | + fmt = "%Y-%m-%d %H:%M:%S" | |
352 | + delta = ( | |
353 | + time.mktime(time.strptime(deliv, fmt)) | |
354 | + - time.mktime(time.strptime(est, fmt)) | |
355 | + ) | |
356 | + o["delivery_delay_days"] = round(delta / 86400.0, 2) | |
357 | + except Exception: | |
358 | + o["delivery_delay_days"] = None | |
359 | + return o | |
360 | + | |
361 | + | |
362 | +# --------------------------------------------------------------------------- | |
363 | +# Reviews | |
364 | +# --------------------------------------------------------------------------- | |
365 | +@app.get("/reviews") | |
366 | +def list_reviews(request): | |
367 | + base = ( | |
368 | + "SELECT r.review_id, r.order_id, r.review_score, r.review_comment_title, " | |
369 | + "r.review_comment_message, r.review_creation_date " | |
370 | + "FROM order_reviews r ORDER BY r.review_creation_date DESC" | |
371 | + ) | |
372 | + count = "SELECT COUNT(*) AS c FROM order_reviews" | |
373 | + return paginate_query(base, count, [], request.query_params) | |
374 | + | |
375 | + | |
376 | +@app.get("/reviews/summary") | |
377 | +def reviews_summary(request): | |
378 | + row = query_one( | |
379 | + "SELECT COUNT(*) AS total, AVG(review_score) AS avg_score, " | |
380 | + "SUM(CASE WHEN review_score >= 4 THEN 1 ELSE 0 END) AS positive, " | |
381 | + "SUM(CASE WHEN review_score <= 2 THEN 1 ELSE 0 END) AS negative " | |
382 | + "FROM order_reviews" | |
383 | + ) | |
384 | + return row | |
385 | + | |
386 | + | |
387 | +# --------------------------------------------------------------------------- | |
388 | +# Geolocation | |
389 | +# --------------------------------------------------------------------------- | |
390 | +@app.get("/geolocation/states") | |
391 | +def geo_states(request): | |
392 | + rows = app.db.query( | |
393 | + "SELECT geolocation_state AS state, COUNT(*) AS cnt, " | |
394 | + "AVG(geolocation_lat) AS avg_lat, AVG(geolocation_lng) AS avg_lng " | |
395 | + "FROM geolocation GROUP BY geolocation_state ORDER BY cnt DESC" | |
396 | + ) | |
397 | + return {"items": rows_to_dicts(rows)} | |
398 | + | |
399 | + | |
400 | +# --------------------------------------------------------------------------- | |
401 | +# Analytics / aggregations (the heavy, join-heavy endpoints) | |
402 | +# --------------------------------------------------------------------------- | |
403 | +@app.get("/analytics/sales-by-category") | |
404 | +def analytics_sales_by_category(request): | |
405 | + rows = app.db.query( | |
406 | + "SELECT p.product_category_name AS category, " | |
407 | + "COUNT(oi.order_item_id) AS units, " | |
408 | + "COALESCE(SUM(oi.price),0) AS revenue, " | |
409 | + "COALESCE(SUM(oi.freight_value),0) AS freight " | |
410 | + "FROM order_items oi JOIN products p ON p.product_id = oi.product_id " | |
411 | + "GROUP BY p.product_category_name ORDER BY revenue DESC" | |
412 | + ) | |
413 | + return {"items": rows_to_dicts(rows)} | |
414 | + | |
415 | + | |
416 | +@app.get("/analytics/top-sellers") | |
417 | +def analytics_top_sellers(request): | |
418 | + qp = request.query_params | |
419 | + try: | |
420 | + limit = max(1, min(100, int(qp.get("limit", "20")))) | |
421 | + except (TypeError, ValueError): | |
422 | + limit = 20 | |
423 | + rows = app.db.query( | |
424 | + "SELECT s.seller_id, s.seller_state, " | |
425 | + "COUNT(DISTINCT oi.order_id) AS orders, " | |
426 | + "COUNT(oi.order_item_id) AS items, " | |
427 | + "COALESCE(SUM(oi.price),0) AS gmv, " | |
428 | + "COALESCE(AVG(r.review_score),0) AS avg_review " | |
429 | + "FROM sellers s " | |
430 | + "JOIN order_items oi ON oi.seller_id = s.seller_id " | |
431 | + "LEFT JOIN order_reviews r ON r.order_id = oi.order_id " | |
432 | + "GROUP BY s.seller_id, s.seller_state " | |
433 | + "ORDER BY gmv DESC LIMIT ?", | |
434 | + [limit], | |
435 | + ) | |
436 | + return {"items": rows_to_dicts(rows)} | |
437 | + | |
438 | + | |
439 | +@app.get("/analytics/delivery-performance") | |
440 | +def analytics_delivery(request): | |
441 | + rows = app.db.query( | |
442 | + "SELECT order_status, COUNT(*) AS cnt, " | |
443 | + "COALESCE(AVG(CAST(julianday(order_delivered_customer_date) - julianday(order_purchase_timestamp) AS REAL)),0) AS avg_days " | |
444 | + "FROM orders WHERE order_delivered_customer_date IS NOT NULL " | |
445 | + "GROUP BY order_status" | |
446 | + ) | |
447 | + return {"items": rows_to_dicts(rows)} | |
448 | + | |
449 | + | |
450 | +@app.get("/analytics/monthly-revenue") | |
451 | +def analytics_monthly(request): | |
452 | + rows = app.db.query( | |
453 | + "SELECT substr(o.order_purchase_timestamp,1,7) AS month, " | |
454 | + "COUNT(DISTINCT o.order_id) AS orders, " | |
455 | + "COALESCE(SUM(oi.price),0) AS revenue " | |
456 | + "FROM orders o JOIN order_items oi ON oi.order_id = o.order_id " | |
457 | + "GROUP BY month ORDER BY month" | |
458 | + ) | |
459 | + return {"items": rows_to_dicts(rows)} | |
460 | + | |
461 | + | |
462 | +# --------------------------------------------------------------------------- | |
463 | +# Search (full-text-ish across products + sellers) | |
464 | +# --------------------------------------------------------------------------- | |
465 | +@app.get("/search") | |
466 | +def search(request): | |
467 | + qp = request.query_params | |
468 | + q = qp.get("q", "") | |
469 | + kind = qp.get("kind", "all") # all | product | seller | |
470 | + if not q: | |
471 | + raise HTTPException(status_code=400, detail="missing ?q=") | |
472 | + like = f"%{q}%" | |
473 | + result = {"products": [], "sellers": []} | |
474 | + if kind in ("all", "product"): | |
475 | + result["products"] = rows_to_dicts( | |
476 | + app.db.query( | |
477 | + "SELECT product_id, product_category_name FROM products " | |
478 | + "WHERE product_id LIKE ? OR product_category_name LIKE ? LIMIT 25", | |
479 | + [like, like], | |
480 | + ) | |
481 | + ) | |
482 | + if kind in ("all", "seller"): | |
483 | + result["sellers"] = rows_to_dicts( | |
484 | + app.db.query( | |
485 | + "SELECT seller_id, seller_city, seller_state FROM sellers " | |
486 | + "WHERE seller_city LIKE ? OR seller_state LIKE ? LIMIT 25", | |
487 | + [like, like], | |
488 | + ) | |
489 | + ) | |
490 | + return result | |
491 | + | |
492 | + | |
493 | +def main(): | |
494 | + parser = argparse.ArgumentParser() | |
495 | + parser.add_argument("--host", default="127.0.0.1") | |
496 | + parser.add_argument("--port", type=int, default=8080) | |
497 | + args = parser.parse_args() | |
498 | + app.run(f"{args.host}:{args.port}") | |
499 | + | |
500 | + | |
501 | +if __name__ == "__main__": | |
502 | + main() | |
0 | 503 | |
▼demo_shop/bench.py
+119-0
| @@ -0,0 +1,119 @@ | ||
1 | +"""Launch the demo_shop server, wait for it to bind, then run a load test | |
2 | +with `oha` against a representative mix of endpoints. Results are written to | |
3 | +/tmp/demo_bench.json and printed. | |
4 | + | |
5 | +Run: | |
6 | + .venv/bin/python demo_shop/bench.py | |
7 | +""" | |
8 | +import subprocess | |
9 | +import sys | |
10 | +import time | |
11 | +import os | |
12 | + | |
13 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
14 | +APP = os.path.join(HERE, "app.py") | |
15 | +HOST, PORT = "127.0.0.1", 8099 | |
16 | +BASE = f"http://{HOST}:{PORT}" | |
17 | + | |
18 | +# 1) Start the server as a subprocess. | |
19 | +proc = subprocess.Popen( | |
20 | + [sys.executable, APP, "--host", HOST, "--port", str(PORT)], | |
21 | + stdout=open("/tmp/demo_srv.log", "w"), | |
22 | + stderr=subprocess.STDOUT, | |
23 | +) | |
24 | + | |
25 | +# 2) Wait for the port to accept connections. | |
26 | +up = False | |
27 | +for _ in range(60): | |
28 | + time.sleep(0.5) | |
29 | + try: | |
30 | + r = subprocess.run(["curl", "-s", "-m", "2", f"{BASE}/shop/health"], | |
31 | + capture_output=True, text=True) | |
32 | + if r.returncode == 0 and r.stdout.strip(): | |
33 | + up = True | |
34 | + break | |
35 | + except Exception: | |
36 | + pass | |
37 | +if not up: | |
38 | + print("SERVER FAILED TO START") | |
39 | + print(open("/tmp/demo_srv.log").read()[:2000]) | |
40 | + proc.terminate() | |
41 | + sys.exit(1) | |
42 | + | |
43 | +print("server up") | |
44 | + | |
45 | +# 3) Probe each endpoint once to confirm health. | |
46 | +import json | |
47 | +endpoints = { | |
48 | + "shop/health": "GET", | |
49 | + "products?size=20": "GET", | |
50 | + "products?category=bed_bath_table&size=10": "GET", | |
51 | + "categories": "GET", | |
52 | + "sellers?size=10": "GET", | |
53 | + "reviews?size=10": "GET", | |
54 | + "reviews/summary": "GET", | |
55 | + "geolocation/states": "GET", | |
56 | + "analytics/sales-by-category": "GET", | |
57 | + "analytics/top-sellers?limit=10": "GET", | |
58 | + "analytics/delivery-performance": "GET", | |
59 | + "analytics/monthly-revenue": "GET", | |
60 | + "orders?size=10": "GET", | |
61 | + "search?q=sao": "GET", | |
62 | +} | |
63 | +for ep in endpoints: | |
64 | + r = subprocess.run(["curl", "-s", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}", | |
65 | + f"{BASE}/{ep}"], capture_output=True, text=True) | |
66 | + print(f" probe {ep}: {r.stdout}") | |
67 | + | |
68 | +# 4) Load test with oha: 15s, 64 concurrent, across a few representative routes. | |
69 | +results = {} | |
70 | +for name, ep in [ | |
71 | + ("products_list", "/products?size=20"), | |
72 | + ("product_detail", "/products/0a1b2c3d4e5f00001"), # will 404 fast; use a real one below | |
73 | + ("analytics_sales", "/analytics/sales-by-category"), | |
74 | + ("sellers", "/sellers?size=20"), | |
75 | +]: | |
76 | + pass | |
77 | + | |
78 | +# Use a real product id for detail benchmark. | |
79 | +real_pid = subprocess.run( | |
80 | + ["curl", "-s", "-m", "5", f"{BASE}/products?size=1"], | |
81 | + capture_output=True, text=True).stdout | |
82 | +try: | |
83 | + pid = json.loads(real_pid)["items"][0]["product_id"] | |
84 | +except Exception: | |
85 | + pid = "x" | |
86 | + | |
87 | +load_targets = { | |
88 | + "products_list": f"{BASE}/products?size=20", | |
89 | + "analytics_sales": f"{BASE}/analytics/sales-by-category", | |
90 | + "sellers": f"{BASE}/sellers?size=20", | |
91 | + "order_detail": f"{BASE}/orders/" + json.loads( | |
92 | + subprocess.run(["curl", "-s", "-m", "5", f"{BASE}/orders?size=1"], | |
93 | + capture_output=True, text=True).stdout)["items"][0]["order_id"], | |
94 | + "product_detail": f"{BASE}/products/{pid}", | |
95 | +} | |
96 | + | |
97 | +for name, url in load_targets.items(): | |
98 | + print(f"\n=== oha {name} ===") | |
99 | + r = subprocess.run( | |
100 | + ["oha", "-z", "15s", "-c", "64", url], | |
101 | + capture_output=True, text=True, | |
102 | + ) | |
103 | + out = r.stdout | |
104 | + results[name] = out | |
105 | + # Print the summary lines. | |
106 | + for line in out.splitlines(): | |
107 | + if any(k in line for k in ["Requests/sec", "Slowest", "Fastest", "p99", "p75", "p90", "p50", "DNS", "Status", "Requests", "Duration", "connections"]): | |
108 | + print(" " + line) | |
109 | + | |
110 | +# 5) Tear down. | |
111 | +proc.terminate() | |
112 | +try: | |
113 | + proc.wait(timeout=10) | |
114 | +except subprocess.TimeoutExpired: | |
115 | + proc.kill() | |
116 | + | |
117 | +with open("/tmp/demo_bench.txt", "w") as f: | |
118 | + f.write(json.dumps(results, indent=2)) | |
119 | +print("\nDONE — results in /tmp/demo_bench.txt") | |
0 | 120 | |
▼demo_shop/bench_async.py
+111-0
| @@ -0,0 +1,111 @@ | ||
1 | +"""In-process load test for the demo_shop API. | |
2 | + | |
3 | +The framework's `app.run()` currently DEADLOCKS on a configured SQLite database | |
4 | +(see demo_shop/README, Finding #4) so we cannot benchmark through the TCP | |
5 | +server yet. This harness instead drives the real handlers + real Rust DB pool | |
6 | +through the framework's AsyncTestClient (same handler dispatch, same DB pool, | |
7 | +minus the raw TCP/HTTP layer) under heavy concurrent load, to surface | |
8 | +handler/DB/serialization/GIL bottlenecks. | |
9 | + | |
10 | +Run: | |
11 | + .venv/bin/python demo_shop/bench_async.py | |
12 | +""" | |
13 | +import asyncio | |
14 | +import json | |
15 | +import os | |
16 | +import sys | |
17 | +import time | |
18 | + | |
19 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
20 | +sys.path.insert(0, os.path.dirname(HERE)) | |
21 | +from justapi.testing import AsyncTestClient # noqa: E402 | |
22 | +import demo_shop.app as shop # noqa: E402 | |
23 | + | |
24 | +DB_URL = f"sqlite://{shop.DB_PATH}" | |
25 | + | |
26 | +# Endpoints to hammer (representative read mix of a real shop). | |
27 | +ENDPOINTS = [ | |
28 | + "/shop/health", | |
29 | + "/products?size=20", | |
30 | + "/products?category=bed_bath_table&size=10", | |
31 | + "/categories", | |
32 | + "/sellers?size=10", | |
33 | + "/reviews?size=10", | |
34 | + "/reviews/summary", | |
35 | + "/geolocation/states", | |
36 | + "/analytics/sales-by-category", | |
37 | + "/analytics/top-sellers?limit=10", | |
38 | + "/analytics/delivery-performance", | |
39 | + "/analytics/monthly-revenue", | |
40 | + "/orders?size=10", | |
41 | + "/search?q=sao", | |
42 | +] | |
43 | + | |
44 | + | |
45 | +async def worker(client, ep, n, counter, lat): | |
46 | + for _ in range(n): | |
47 | + t0 = time.perf_counter() | |
48 | + try: | |
49 | + resp = await client.get(ep) | |
50 | + dt = (time.perf_counter() - t0) * 1000.0 | |
51 | + lat.append(dt) | |
52 | + counter[resp.get("status", 0) == 200 and "ok" or "err"] += 1 | |
53 | + except Exception: | |
54 | + counter["err"] += 1 | |
55 | + lat.append((time.perf_counter() - t0) * 1000.0) | |
56 | + | |
57 | + | |
58 | +async def run_load(concurrency, per_worker): | |
59 | + async with AsyncTestClient(shop.app, database=DB_URL) as c: | |
60 | + # prime one request per endpoint so plans are warm | |
61 | + for ep in ENDPOINTS: | |
62 | + await c.get(ep) | |
63 | + counter = {"ok": 0, "err": 0} | |
64 | + lat = [] | |
65 | + tasks = [] | |
66 | + for ep in ENDPOINTS: | |
67 | + for _ in range(concurrency): | |
68 | + tasks.append(asyncio.create_task(worker(c, ep, per_worker, counter, lat))) | |
69 | + t0 = time.perf_counter() | |
70 | + await asyncio.gather(*tasks) | |
71 | + wall = time.perf_counter() - t0 | |
72 | + return counter, lat, wall | |
73 | + | |
74 | + | |
75 | +def pct(vals, p): | |
76 | + if not vals: | |
77 | + return 0.0 | |
78 | + s = sorted(vals) | |
79 | + k = max(0, min(len(s) - 1, int(p / 100.0 * len(s)))) | |
80 | + return s[k] | |
81 | + | |
82 | + | |
83 | +def main(): | |
84 | + import argparse | |
85 | + ap = argparse.ArgumentParser() | |
86 | + ap.add_argument("--concurrency", type=int, default=32) | |
87 | + ap.add_argument("--per-worker", type=int, default=50) | |
88 | + args = ap.parse_args() | |
89 | + | |
90 | + counter, lat, wall = asyncio.run(run_load(args.concurrency, args.per_worker)) | |
91 | + total = counter["ok"] + counter["err"] | |
92 | + print("\n=== demo_shop in-process load (AsyncTestClient) ===") | |
93 | + print(f"concurrency={args.concurrency} per_worker={args.per_worker} " | |
94 | + f"total_reqs={total} wall={wall:.2f}s") | |
95 | + print(f"throughput = {total / wall:.0f} req/s") | |
96 | + print(f"ok={counter['ok']} err={counter['err']}") | |
97 | + if lat: | |
98 | + print(f"latency ms: p50={pct(lat,50):.2f} p90={pct(lat,90):.2f} " | |
99 | + f"p99={pct(lat,99):.2f} max={max(lat):.2f}") | |
100 | + # Save raw for records. | |
101 | + with open("/tmp/demo_bench_async.json", "w") as f: | |
102 | + json.dump({"concurrency": args.concurrency, "per_worker": args.per_worker, | |
103 | + "total": total, "wall_s": wall, | |
104 | + "throughput_rps": total / wall, "ok": counter["ok"], | |
105 | + "err": counter["err"], | |
106 | + "p50_ms": pct(lat, 50), "p90_ms": pct(lat, 90), | |
107 | + "p99_ms": pct(lat, 99)}, f, indent=2) | |
108 | + | |
109 | + | |
110 | +if __name__ == "__main__": | |
111 | + main() | |
0 | 112 | |
▼demo_shop/test_app.py
+84-0
| @@ -0,0 +1,84 @@ | ||
1 | +"""Smoke test for the demo_shop e-commerce API. | |
2 | + | |
3 | +Validates that every route registers and returns sane data against the real | |
4 | +Olist SQLite dataset, using the framework's AsyncTestClient (which sets up the | |
5 | +DB runtime). Run: | |
6 | + | |
7 | + .venv/bin/python -m pytest crates/justapi-py/python/justapi/../../../../../demo_shop/test_app.py -q | |
8 | +or simply: | |
9 | + .venv/bin/python demo_shop/test_app.py | |
10 | +""" | |
11 | +import asyncio | |
12 | +import os | |
13 | +import sys | |
14 | + | |
15 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
16 | +sys.path.insert(0, os.path.dirname(HERE)) # repo root | |
17 | + | |
18 | +from justapi.testing import AsyncTestClient # noqa: E402 | |
19 | + | |
20 | +import demo_shop.app as shop # noqa: E402 | |
21 | + | |
22 | +DB_URL = f"sqlite://{shop.DB_PATH}" | |
23 | + | |
24 | + | |
25 | +async def main(): | |
26 | + async with AsyncTestClient(shop.app, database=DB_URL) as c: | |
27 | + checks = [] | |
28 | + | |
29 | + def chk(name, resp, expect_status=200): | |
30 | + ok = resp.get("status") == expect_status | |
31 | + checks.append((name, ok, resp.get("status"))) | |
32 | + return resp | |
33 | + | |
34 | + chk("shop/health", await c.get("/shop/health")) | |
35 | + chk("shop/meta/tables", await c.get("/shop/meta/tables")) | |
36 | + chk("products", await c.get("/products?size=3")) | |
37 | + chk("products filter", await c.get("/products?category=bed_bath_table&size=2")) | |
38 | + chk("products search", await c.get("/products?q=bed&size=2")) | |
39 | + chk("categories", await c.get("/categories")) | |
40 | + chk("sellers", await c.get("/sellers?size=3")) | |
41 | + chk("reviews", await c.get("/reviews?size=3")) | |
42 | + chk("reviews/summary", await c.get("/reviews/summary")) | |
43 | + chk("geo/states", await c.get("/geolocation/states")) | |
44 | + chk("analytics/sales-by-category", await c.get("/analytics/sales-by-category")) | |
45 | + chk("analytics/top-sellers", await c.get("/analytics/top-sellers?limit=5")) | |
46 | + chk("analytics/delivery", await c.get("/analytics/delivery-performance")) | |
47 | + chk("analytics/monthly", await c.get("/analytics/monthly-revenue")) | |
48 | + chk("search", await c.get("/search?q=sao")) | |
49 | + | |
50 | + # Find a real product_id + order_id + seller_id to exercise detail routes. | |
51 | + prods = (await c.get("/products?size=1")).get("body") | |
52 | + import json as _json | |
53 | + prods = _json.loads(prods) if isinstance(prods, (bytes, str)) else prods | |
54 | + pid = prods["items"][0]["product_id"] | |
55 | + chk("product detail", await c.get(f"/products/{pid}")) | |
56 | + chk("product reviews", await c.get(f"/products/{pid}/reviews?size=2")) | |
57 | + | |
58 | + sell = _json.loads((await c.get("/sellers?size=1")).get("body")) | |
59 | + sid = sell["items"][0]["seller_id"] | |
60 | + chk("seller detail", await c.get(f"/sellers/{sid}")) | |
61 | + chk("seller products", await c.get(f"/sellers/{sid}/products?size=2")) | |
62 | + | |
63 | + # order id from analytics top-sellers join path: pull one from orders list | |
64 | + ords = _json.loads((await c.get("/orders?size=1")).get("body")) | |
65 | + oid = ords["items"][0]["order_id"] | |
66 | + chk("order detail", await c.get(f"/orders/{oid}")) | |
67 | + chk("order timeline", await c.get(f"/orders/{oid}/timeline")) | |
68 | + | |
69 | + # 404 behavior | |
70 | + chk("product 404", await c.get("/products/does-not-exist"), expect_status=404) | |
71 | + chk("search missing q", await c.get("/search"), expect_status=400) | |
72 | + | |
73 | + print("\n=== SMOKE RESULTS ===") | |
74 | + failed = 0 | |
75 | + for name, ok, status in checks: | |
76 | + print(f" {'PASS' if ok else 'FAIL'} {name} (status {status})") | |
77 | + if not ok: | |
78 | + failed += 1 | |
79 | + print(f"\n{failed} failed of {len(checks)}") | |
80 | + return 1 if failed else 0 | |
81 | + | |
82 | + | |
83 | +if __name__ == "__main__": | |
84 | + raise SystemExit(asyncio.run(main())) | |
0 | 85 | |