Skip to content

Commit c083499

Browse files
committed
final: refactor structure, add cache benchmark, update README
1 parent 56e71d8 commit c083499

14 files changed

Lines changed: 143 additions & 23 deletions

README.md

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
# URL Shortener
22

3-
A production-style distributed URL shortener with a FastAPI backend and a lightweight React frontend, built using PostgreSQL, Redis, Docker, pytest, and GitHub Actions CI.
4-
5-
This project emphasizes backend engineering beyond basic CRUD, focusing on scalability, performance optimization, fault tolerance, and production-ready system design.
3+
A production-style distributed URL shortener demonstrating caching, rate limiting, load balancing, and horizontal scalability. Built with a FastAPI backend, React frontend, PostgreSQL, Redis, Docker, and CI/CD (GitHub Actions).
64

75
## Live Demo
86

97
- Frontend: https://url-shortener-frontend-av1x.onrender.com
10-
- Backend API: https://url-shortener-gfp0.onrender.com
8+
- Backend API: https://url-shortener-gfp0.onrender.com/docs
119

1210
## Frontend
1311

@@ -30,7 +28,7 @@ The frontend communicates with the deployed FastAPI backend via REST APIs, enabl
3028
## Quick Test
3129

3230
- Frontend:
33-
Open the web UI: https://your-frontend-url.onrender.com
31+
- Open the web UI: https://url-shortener-frontend-av1x.onrender.com
3432

3533
- Backend API:
3634
```bash
@@ -45,6 +43,7 @@ curl -X POST "https://url-shortener-gfp0.onrender.com/shorten" \
4543
This project was built to simulate a production-style distributed system incorporating real-world backend and system design principles, rather than a simple CRUD application.
4644

4745
Key goals:
46+
4847
- Design a scalable API with clear request/response contracts
4948
- Introduce caching and rate limiting as core system-level concerns
5049
- Support multiple runtime environments (local, Docker, cloud)
@@ -69,6 +68,24 @@ Key goals:
6968
- Run the full stack locally using Docker Compose
7069
- Validate system behavior with pytest and GitHub Actions CI
7170
- Support horizontal scaling via stateless application instances behind a load balancer
71+
- Benchmark cache performance (miss vs. hit latency)
72+
73+
## Cache Performance Benchmark
74+
75+
To validate the effectiveness of Redis caching, redirect latency was measured for cache misses (first request) and cache hits (subsequent requests).
76+
77+
Run locally:
78+
```bash
79+
python scripts/benchmark_cache.py
80+
```
81+
82+
Example results:
83+
84+
- Cache miss latency: ~30–45 ms
85+
- Average cache hit latency: ~6 ms
86+
- Approximate speedup: ~5–7×
87+
88+
This demonstrates that Redis caching significantly reduces redirect latency and minimizes repeated database queries in read-heavy workloads.
7289

7390
## Backend Highlights
7491

@@ -79,6 +96,7 @@ Key goals:
7996
- Built automated test coverage with pytest to validate core workflows
8097
- Configured GitHub Actions CI to run tests on every push and pull request
8198
- Introduced Nginx as a load balancer to distribute traffic across multiple FastAPI instances
99+
- Validated Redis caching effectiveness using benchmark measurements (cache miss vs. hit latency)
82100

83101
## Tech Stack
84102

@@ -96,11 +114,30 @@ Key goals:
96114
## Project Structure
97115

98116
```text
99-
app/ # FastAPI application code
117+
app/ # FastAPI backend application
118+
__init__.py
119+
main.py # application entrypoint
120+
core/ # application configuration and environment setup
121+
__init__.py
122+
config.py
123+
services/ # external services and infrastructure logic
124+
__init__.py
125+
cache.py
126+
rate_limiter.py
127+
crud.py # database operations
128+
database.py # database connection and setup
129+
models.py # SQLAlchemy models
130+
schemas.py # Pydantic schemas
131+
utils.py # helper utilities
132+
100133
frontend/ # React frontend (Vite, API integration)
101134
nginx/ # Nginx configuration for load balancing
102135
tests/ # automated tests
103-
scripts/ # helper scripts (e.g., seed data)
136+
137+
scripts/
138+
seed.py # sample data loader
139+
benchmark_cache.py # measures cache miss vs. hit latency
140+
104141
docker-compose.yml # service orchestration
105142
Dockerfile # app container definition
106143
requirements.txt # backend dependencies
@@ -127,6 +164,7 @@ curl http://127.0.0.1:8000/health
127164
## How to Run Locally
128165

129166
### Run Full Stack with Docker
167+
130168
```bash
131169
docker compose up --build
132170
```
@@ -135,6 +173,7 @@ Open:
135173
- Backend health: http://127.0.0.1:8000/health
136174

137175
### Run Backend Locally (Services in Docker)
176+
138177
- Start required services:
139178
```bash
140179
docker compose up -d db redis
@@ -143,12 +182,17 @@ docker compose up -d db redis
143182
```bash
144183
source venv/bin/activate
145184
```
185+
- Install dependencies:
186+
```bash
187+
pip install -r requirements.txt
188+
```
146189
- Run backend:
147190
```bash
148191
uvicorn app.main:app --reload
149192
```
150193

151194
### Run Frontend Locally
195+
152196
```bash
153197
cd frontend
154198
npm install
@@ -180,7 +224,8 @@ docker compose up --build
180224
- Open the returned short URL in your browser to verify redirection
181225
- Check click statistics using the UI or via: `GET /stats/{short_code}`
182226

183-
### Optional (API-level testing):
227+
### Optional (API-level testing)
228+
184229
- Open API docs: http://127.0.0.1:8000/docs
185230
- Create a short URL using `POST /shorten`
186231
```json
@@ -196,12 +241,14 @@ This project demonstrates a horizontally scalable, distributed system with a Rea
196241
The architecture is designed for scalability, fault tolerance, and consistent behavior across multiple application instances.
197242

198243
### Components
244+
199245
- Nginx load balancer
200246
- 3 FastAPI application replicas
201247
- PostgreSQL as the durable, shared source of truth
202248
- Redis for shared caching and distributed rate limiting
203249

204250
### System Request Flow
251+
205252
1. User interacts with the React frontend or sends a request directly to Nginx
206253
2. Nginx routes the request to one FastAPI replica
207254
3. The selected instance processes the request and interacts with shared PostgreSQL and Redis
@@ -247,12 +294,14 @@ Nginx Load Balancer
247294
## Request Flow
248295

249296
### Create Short URL (`POST /shorten`)
297+
250298
- Request enters the FastAPI service
251299
- Redis-backed rate limiter validates request frequency
252300
- A short code is generated and checked for uniqueness
253301
- Mapping is stored in PostgreSQL
254302

255303
### Redirect (`GET /{short_code}`)
304+
256305
- FastAPI checks Redis cache for the short code
257306
- On cache hit → return redirect immediately (low latency)
258307
- On cache miss:
@@ -262,12 +311,14 @@ Nginx Load Balancer
262311
- Return redirect response
263312

264313
### Stats (`GET /stats/{short_code}`)
314+
265315
- Retrieve URL metadata and click count from PostgreSQL
266316

267317
## Design Notes
268318

269319
- PostgreSQL serves as the single source of truth for URL mappings and analytics, ensuring consistency across all application instances
270320
- Redis is used as a shared cache layer to optimize read-heavy redirect traffic and reduce database load
321+
- Cache effectiveness is validated through latency benchmarking, demonstrating faster response times for repeated requests
271322
- Rate limiting is enforced using Redis to ensure global limits across all replicas, preventing per-instance bypass
272323
- The application is stateless, allowing any FastAPI instance to handle any request
273324
- Click counts are updated even on cache hits to maintain consistency between cache and persistent storage

app/__init__.py

Whitespace-only changes.

app/core/__init__.py

Whitespace-only changes.
File renamed without changes.

app/database.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from sqlalchemy import create_engine
22
from sqlalchemy.orm import sessionmaker, declarative_base
3-
from app.config import DATABASE_URL
3+
from app.core.config import DATABASE_URL
44

55
connect_args = {}
66

app/main.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import time
23
from contextlib import asynccontextmanager
34

45
from fastapi import FastAPI, Depends, HTTPException, Request
@@ -8,9 +9,11 @@
89
from sqlalchemy import text
910

1011
from app.database import engine, Base, get_db
11-
from app import schemas, crud, cache
12-
from app.rate_limiter import check_rate_limit
13-
from app.config import BASE_URL, INSTANCE_NAME
12+
from app import schemas, crud
13+
from app.services import cache
14+
from app.services.rate_limiter import check_rate_limit
15+
from app.core.config import BASE_URL, INSTANCE_NAME
16+
1417

1518
logging.basicConfig(level=logging.INFO)
1619
logger = logging.getLogger(__name__)
@@ -26,6 +29,7 @@ async def lifespan(app: FastAPI):
2629

2730
app = FastAPI(title="URL Shortener API", lifespan=lifespan)
2831

32+
2933
app.add_middleware(
3034
CORSMiddleware,
3135
allow_origins=[
@@ -40,8 +44,17 @@ async def lifespan(app: FastAPI):
4044

4145
@app.middleware("http")
4246
async def log_requests(request: Request, call_next):
43-
logger.info("Instance %s handling %s %s", INSTANCE_NAME, request.method, request.url.path)
47+
start = time.perf_counter()
4448
response = await call_next(request)
49+
duration_ms = (time.perf_counter() - start) * 1000
50+
51+
logger.info(
52+
"Instance %s handled %s %s in %.2f ms",
53+
INSTANCE_NAME,
54+
request.method,
55+
request.url.path,
56+
duration_ms,
57+
)
4558
return response
4659

4760

@@ -66,6 +79,7 @@ def db_health():
6679
def redis_health():
6780
if not cache.redis_client:
6881
return {"redis_status": "not_configured"}
82+
6983
try:
7084
cache_ping = cache.redis_client.ping()
7185
return {"redis_status": "ok", "ping": cache_ping}
@@ -78,18 +92,20 @@ def redis_health():
7892
def shorten_url(
7993
request: Request,
8094
body: schemas.ShortenRequest,
81-
db: Session = Depends(get_db)
95+
db: Session = Depends(get_db),
8296
):
8397
check_rate_limit(request)
8498

8599
try:
86100
db_url = crud.create_short_url(db, body.original_url)
101+
87102
logger.info("Created short URL for %s", body.original_url)
88103

89104
return {
90105
"short_code": db_url.short_code,
91-
"short_url": f"{BASE_URL}/{db_url.short_code}"
106+
"short_url": f"{BASE_URL}/{db_url.short_code}",
92107
}
108+
93109
except Exception as exc:
94110
logger.exception("Failed to create short URL")
95111
raise HTTPException(status_code=500, detail="Failed to create short URL") from exc
@@ -106,7 +122,7 @@ def get_stats(short_code: str, db: Session = Depends(get_db)):
106122
"short_code": db_url.short_code,
107123
"original_url": db_url.original_url,
108124
"click_count": db_url.click_count,
109-
"created_at": db_url.created_at
125+
"created_at": db_url.created_at,
110126
}
111127

112128

@@ -117,18 +133,21 @@ def redirect_to_url(short_code: str, db: Session = Depends(get_db)):
117133

118134
if cached_url:
119135
logger.info("Cache hit for short code %s", short_code)
136+
120137
db_url = crud.get_url_by_code(db, short_code)
121138
if db_url:
122139
crud.increment_click_count(db, db_url)
140+
123141
return RedirectResponse(url=cached_url)
124142

125143
logger.info("Cache miss for short code %s", short_code)
126-
db_url = crud.get_url_by_code(db, short_code)
127144

145+
db_url = crud.get_url_by_code(db, short_code)
128146
if not db_url:
129147
raise HTTPException(status_code=404, detail="Short URL not found")
130148

131149
cache.set_cached_url(short_code, db_url.original_url)
150+
132151
crud.increment_click_count(db, db_url)
133152

134153
return RedirectResponse(url=db_url.original_url)

app/services/__init__.py

Whitespace-only changes.

app/cache.py renamed to app/services/cache.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import redis
22
import logging
3-
from app.config import REDIS_URL
3+
from app.core.config import REDIS_URL
44

55
logger = logging.getLogger(__name__)
66

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from fastapi import HTTPException, Request
2-
from app.cache import redis_client
2+
from app.services.cache import redis_client
33
import logging
44

55
logger = logging.getLogger(__name__)

scripts/benchmark_cache.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import statistics
2+
import time
3+
import requests
4+
5+
BASE_URL = "http://127.0.0.1:8000"
6+
ORIGINAL_URL = "https://www.google.com"
7+
HIT_RUNS = 20
8+
9+
def create_short_url() -> str:
10+
resp = requests.post(
11+
f"{BASE_URL}/shorten",
12+
json={"original_url": ORIGINAL_URL},
13+
timeout=10,
14+
)
15+
resp.raise_for_status()
16+
data = resp.json()
17+
return data["short_code"]
18+
19+
def measure_once(url: str) -> float:
20+
start = time.perf_counter()
21+
resp = requests.get(url, allow_redirects=False, timeout=10)
22+
resp.raise_for_status()
23+
end = time.perf_counter()
24+
return (end - start) * 1000.0
25+
26+
def main() -> None:
27+
short_code = create_short_url()
28+
short_url = f"{BASE_URL}/{short_code}"
29+
30+
miss_ms = measure_once(short_url)
31+
32+
hit_results = []
33+
for _ in range(HIT_RUNS):
34+
hit_results.append(measure_once(short_url))
35+
36+
avg_hit = statistics.mean(hit_results)
37+
min_hit = min(hit_results)
38+
max_hit = max(hit_results)
39+
40+
print(f"Short URL: {short_url}")
41+
print(f"Cache miss latency: {miss_ms:.2f} ms")
42+
print(f"Cache hit avg latency over {HIT_RUNS} runs: {avg_hit:.2f} ms")
43+
print(f"Cache hit min latency: {min_hit:.2f} ms")
44+
print(f"Cache hit max latency: {max_hit:.2f} ms")
45+
46+
if avg_hit > 0:
47+
print(f"Approx speedup (miss / avg hit): {miss_ms / avg_hit:.2f}x")
48+
49+
if __name__ == "__main__":
50+
main()

0 commit comments

Comments
 (0)