An open API service indexing awesome lists of open source software.

https://github.com/bit-web24/bittuly

Distibuted URL Shortener
https://github.com/bit-web24/bittuly

Last synced: 6 days ago
JSON representation

Distibuted URL Shortener

Awesome Lists containing this project

README

          

# Bittuly β€” Distributed URL Shortener

Bittuly is a production-grade, distributed URL shortener built with **Rust (Axum)** and **React (Vite)**. It uses a microservices architecture with isolated databases per service, a two-tier caching system, asynchronous event-driven analytics, and full observability via OpenTelemetry, Prometheus, and Grafana.

---

## πŸ—οΈ System Design

```mermaid
graph TB
%% ── Actors ──────────────────────────────────────────────
Browser(["🌐 Browser"])
GHCR[("πŸ“¦ GHCR\nghcr.io")]

%% ── Edge ────────────────────────────────────────────────
subgraph EDGE ["πŸ›‘οΈ Edge β€” NGINX :8000"]
GW["API Gateway\n━━━━━━━━━━━━━━━━━━━━━\n/api/auth/* β†’ auth-service\n/api/urls/* β†’ url-service\n/{short_code} β†’ url-service\n/* β†’ frontend\n━━━━━━━━━━━━━━━━━━━━━\nπŸ”’ Rate Limits\nlogin/signup 5 req/min burst 5\nredirects 20 req/s burst 20\nπŸ”’ Security Headers\nX-Frame-Options Β· CSP Β· HSTS\n🚫 /api/*/metrics β†’ 403"]
end

%% ── Services ────────────────────────────────────────────
subgraph SVC ["βš™οΈ Services β€” Kubernetes / bittuly ns"]
direction LR

FE["πŸ–₯️ Frontend\nReact 18 Β· Vite Β· TypeScript\n━━━━━━━━━━━━━━━━\nreplicas: 1\nCPU: 50m β†’ 200m"]

subgraph AUTH ["auth-service :3001"]
AS["Rust Β· Axum\nJWT HS256 Β· bcrypt\n━━━━━━━━━━━━━━━━━━━━━\nPOST /signup β†’ OTP email\nPOST /verify-otp β†’ create user\nPOST /login β†’ set cookies\nDEL /{id} β†’ cascade delete\n━━━━━━━━━━━━━━━━━━━━━\nreplicas: 1\nCPU: 50m β†’ 500m\nHealth: GET /health"]
end

subgraph URL ["url-service :3002"]
US["Rust Β· Axum\nJWT HS256 Β· base62\n━━━━━━━━━━━━━━━━━━━━━\nPOST /api/urls β†’ shorten\nGET /api/urls β†’ paginated list\nDEL /api/urls/{id}\nGET /{code} β†’ 307 redirect ⚑\n━━━━━━━━━━━━━━━━━━━━━\nreplicas: 2 HPA: 2β†’5 @ 60% CPU\nCPU: 100m β†’ 1000m\nHealth: GET /health"]
end

subgraph CONS ["consumer-service (worker)"]
CS["Rust Β· Tokio\nNo HTTP server\n━━━━━━━━━━━━━━━━━━━━━\nβ‘  click_events_queue\n batch flush every 30s or 17 clicks\n UPDATE click_count (unnest)\nβ‘‘ user_deleted_queue\n cascade DEL urls + Redis eviction\n retry 3sβ†’9sβ†’27s β†’ DLQ\n━━━━━━━━━━━━━━━━━━━━━\nreplicas: 1\nCPU: 50m β†’ 500m"]
end
end

%% ── Cache ───────────────────────────────────────────────
subgraph CACHE ["⚑ Three-Tier Redirect Cache"]
direction LR
L1["L1 Β· Moka\nin-process\n━━━━━━━━━\nTTL: 3s\nCap: 1M entries\nSingleflight\n(Thundering Herd\nprotection)"]
L2["L2 Β· Redis :6379\ndistributed\n━━━━━━━━━\nKey: {short_code}\nSET / SETEX / DEL\nmaxmem: 256 MB\npolicy: allkeys-lru"]
L3["L3 Β· Postgres\nbittuly_urls\n━━━━━━━━━\nSELECT original_url\nWHERE short_code=$1"]
L1 -->|"miss"| L2
L2 -->|"miss"| L3
end

%% ── Databases ───────────────────────────────────────────
PGA[("🐘 pg-auth :5432\nbittuly_auth\n━━━━━━━━━\ntable: users\nid Β· username\nemail Β· bcrypt(pw)\ncreated_at")]
PGU[("🐘 pg-urls :5433\nbittuly_urls\n━━━━━━━━━\ntable: urls\nurl_id BIGSERIAL\nshort_code base62\noriginal_url\nclick_count Β· expires_at")]

%% ── Messaging ───────────────────────────────────────────
subgraph MQ ["πŸ“¨ RabbitMQ :5672 β€” At-Least-Once"]
direction TB
CEQ["click_events_queue\ndurable"]
UDQ["user_deleted_queue\ndurable"]
RTQ["retry queues\n3s Β· 9s Β· 27s\n(TTL + DLX)"]
DLQ["user_deleted_dlq\n(dead letter)"]
UDQ -->|"failure\nx-retry-count"| RTQ
RTQ -->|"TTL expire\nDLX re-route"| UDQ
UDQ -->|"β‰₯ 3 retries"| DLQ
end

%% ── Observability ───────────────────────────────────────
subgraph OBS ["πŸ”­ Observability"]
direction LR
PROM["Prometheus :9090\nscrape every 5s\nhttp_requests_total\nhttp_request_duration\nlinks_shortened\ncache_hits Β· cache_misses"]
GF["Grafana :3000\nBittuly Live Traffic\nRPS Β· Latency\nCache Hits/Misses"]
JAE["Jaeger :16686\nOTLP/gRPC :4317\nW3C traceparent\nacross HTTP + AMQP"]
PROM --> GF
end

%% ── JWT ─────────────────────────────────────────────────
subgraph JWTBOX ["πŸ” JWT β€” HS256 HttpOnly Cookies"]
direction LR
JWTD["access_token 15 min cookie\nrefresh_token 30 days cookie\npending_token 10 min JSON body\n(carries bcrypt pw+otp β€” no DB row until OTP verified)"]
end

%% ── CI/CD ───────────────────────────────────────────────
subgraph CICD ["♾️ CI/CD β€” GitHub Actions"]
direction LR
CI["CI\nfmt Β· clippy Β· audit\ncargo test Γ— 2\nnpm typecheck"]
CD["CD (master only)\ndocker build Γ— 4\npush ghcr.io/{svc}:{sha}"]
CI --> CD
end

%% ── Edges ───────────────────────────────────────────────
Browser -->|":8000"| GW
GW --> FE & AS & US
CICD --> GHCR -->|"imagePull"| SVC

AS -->|"READ/WRITE"| PGA
AS -->|"publish {user_id}"| UDQ

US -->|"READ/WRITE"| PGU
US -->|"L1 get_with"| L1
L3 -.->|"sqlx"| PGU
US -->|"tokio::spawn\npublish short_code"| CEQ

CEQ -->|"consume"| CS
UDQ -->|"consume"| CS
CS -->|"batch UPDATE"| PGU
CS -->|"DEL key"| L2

PROM -->|"scrape"| AS & US
AS & US & CS -->|"OTLP/gRPC"| JAE
```

```mermaid
graph TB
%% ─── External Clients ───────────────────────────────────────────────────
Client(["πŸ‘€ Client\n(Browser / API)"])

%% ─── CI/CD Pipeline ─────────────────────────────────────────────────────
subgraph CICD ["☁️ CI/CD β€” GitHub Actions"]
direction LR
CI["πŸ”Ž CI Pipeline\ncargo fmt Β· clippy Β· cargo audit\ncargo test -p auth-service\ncargo test -p url-service\nnpm typecheck + build"]
CD["πŸ“¦ CD Pipeline\ndocker build Γ— 4 images\npush ghcr.io/{owner}/bittuly-{svc}:{sha}\n+ :latest tag"]
CI --> CD
end
GHCR[("πŸ“¦ GHCR\nghcr.io\nauth-service:{sha}\nurl-service:{sha}\nconsumer-service:{sha}\nfrontend-service:{sha}")]
CD --> GHCR

%% ─── Kubernetes Cluster ──────────────────────────────────────────────────
subgraph K8S ["βš“ Kubernetes Cluster β€” Namespace: bittuly"]
direction TB

subgraph Ingress ["πŸ”€ NGINX Ingress Controller"]
IG["nginx-ingress\nlimit-rps: 2000\n/api/auth β†’ auth-service:3001\n/api/urls β†’ url-service:3002\n/ β†’ frontend-service:80"]
end

subgraph Gateway ["πŸ›‘οΈ NGINX API Gateway (port 8000)"]
direction TB
GW["nginx:1.31.1-alpine\nworker_processes: auto\nworker_connections: 50 000\nmax FD: 200 000\n\n─── Rate Limiting ───\nredirect_limit 20 req/s burst 20 200 MB zone\nauth_limit 5 req/min burst 5 50 MB zone\n\n─── Security Headers ───\nX-Frame-Options: DENY\nX-Content-Type-Options: nosniff\nX-XSS-Protection: 1; mode=block\nReferrer-Policy: strict-origin-when-cross-origin\nPermissions-Policy: camera=() microphone=() geolocation=()\nContent-Security-Policy: default-src 'self'\n\n─── Routing ───\nGET /api/*/metrics β†’ 403 Forbidden\nPOST /api/auth/login|signup β†’ auth :3001 + auth_limit\n/api/auth/* β†’ auth :3001\n/api/urls/* β†’ url :3002\n/{short_code} β†’ url :3002 + redirect_limit\n/ dashboard login signup β†’ frontend :5173"]
end

subgraph Services ["βš™οΈ Microservices"]
direction LR

subgraph AuthSvc ["auth-service (port 3001)"]
direction TB
AS["Rust Β· Axum Β· HS256 JWT\n─── HTTP Routes ───\nPOST /api/auth/signup β†’ OTP email, returns pending_token JWT\nPOST /api/auth/verify-otp β†’ verify OTP, create user, set cookies\nPOST /api/auth/login β†’ bcrypt verify, set cookies\nPOST /api/auth/logout β†’ clear cookies\nGET /api/auth/{user_id} β†’ get user (owner only)\nPUT /api/auth/{user_id} β†’ update user, re-issue JWTs\nDEL /api/auth/{user_id} β†’ delete user, publish to RabbitMQ\nGET /api/auth/health β†’ health check\nGET /api/auth/metrics β†’ Prometheus (blocked by GW)\n\n─── K8s Resources ───\nreplicas: 1 CPU: 50mβ†’500m Mem: 64Miβ†’256Mi\nliveness: GET /api/auth/health (delay 10s period 15s)\nreadiness: GET /api/auth/health (delay 5s period 5s)"]
ASRB["πŸ“€ Publishes\nuser_deleted_queue\npayload: { user_id: UUID }\nW3C traceparent in AMQP headers"]
end

subgraph UrlSvc ["url-service (port 3002)"]
direction TB
US["Rust Β· Axum Β· HS256 JWT\n─── HTTP Routes ───\nPOST /api/urls β†’ shorten URL, incr links_shortened metric\nGET /api/urls β†’ cursor-paginated list (default 20 max 100)\nDEL /api/urls/{id} β†’ delete URL, evict Redis + Moka cache\nGET /{short_code} β†’ HTTP 307 redirect (hot path)\nGET /api/urls/health β†’ health check\nGET /api/urls/metrics β†’ Prometheus (blocked by GW)\n\n─── K8s Resources ───\nreplicas: 2 CPU: 100mβ†’1000m Mem: 128Miβ†’512Mi\nHPA: min=2 max=5 CPU target=60%\nliveness: GET /api/urls/health (delay 10s period 15s)\nreadiness: GET /api/urls/health (delay 5s period 5s)\n\n─── Short Code Generation ───\nbase62( url_id: BIGSERIAL )\nINSERT urls β†’ get url_id β†’ UPDATE short_code (in transaction)"]
USRB["πŸ“€ Publishes\nclick_events_queue\npayload: raw UTF-8 short_code bytes\nfire-and-forget via tokio::spawn\nW3C traceparent in AMQP headers"]
end

subgraph ConsSvc ["consumer-service (no HTTP server)"]
direction TB
CS["Rust Β· Tokio worker\n─── Consumers ───\n\nβ‘  click_events_queue (tag: consumer_service_clicks)\n in-memory HashMap short_codeβ†’count\n Flush on: size β‰₯ 17 clicks OR timer every 30s\n SQL: UPDATE urls SET click_count = click_count + delta\n FROM unnest arrays (single batch statement)\n ACK: basic_ack(multiple=true) on success\n NACK: basic_nack(multiple=true, requeue=true) on DB failure\n Retry: exponential backoff 3^n seconds\n\nβ‘‘ user_deleted_queue (tag: consumer_service_users)\n Extract W3C traceparent from AMQP headers\n DELETE FROM urls WHERE user_id = $1 RETURNING short_code\n DEL {short_code} from Redis (per returned code)\n ACK on success\n Retry: x-retry-count header\n count 0-2 β†’ user_deleted_retry_{3|9|27}s queue (TTL+DLX)\n count β‰₯ 3 β†’ user_deleted_dlq (Dead Letter Queue)\n Invalid JSON β†’ silent ack-discard\n\n─── K8s Resources ───\nreplicas: 1 CPU: 50mβ†’500m Mem: 64Miβ†’256Mi"]
end

subgraph FeSvc ["frontend-service (port 80 / dev :5173)"]
FE["React 18 Β· TypeScript Β· Vite Β· React Router v6\n─── Pages ───\n/signup Signup (OTP step 1)\n/verify-otp VerifyOtp (OTP step 2)\n/login Login\n/dashboard Dashboard (ProtectedRoute)\n/insights Insights (ProtectedRoute)\n/profile Profile (ProtectedRoute)\n/settings Settings (ProtectedRoute)\n/health ServiceHealth (ProtectedRoute)\n/unavailable Unavailable (fallback)\n\n─── Auth ───\nAuthContext + ProtectedRoute\nJWT via HttpOnly cookies (no JS access)\n\n─── K8s Resources ───\nreplicas: 1 CPU: 50mβ†’200m Mem: 64Miβ†’128Mi"]
end
end

subgraph DataLayer ["πŸ—„οΈ Data Layer"]
direction LR

subgraph Caching ["⚑ Three-Tier Redirect Cache"]
direction TB
L1["L1 β€” Moka (in-process)\nType: future::Cache\nCapacity: 1 000 000 entries\nTTL: 3 seconds\nStrategy: Singleflight coalescing\n(Thundering Herd protection)\nKey: short_code β†’ Option(original_url, expires_at)"]
L2["L2 β€” Redis 8 (distributed)\nConnectionManager (auto-reconnect)\nmaxmemory: 256 MB policy: allkeys-lru\nKey pattern: {short_code}\nSET {code} {url} (permanent)\nSETEX {code} {url} {ttl} (expiring)\nDEL {code} (on delete)"]
L3["L3 β€” PostgreSQL (source of truth)\nQuery: SELECT original_url, expires_at\nFROM urls WHERE short_code = $1"]
L1 --> L2
L2 --> L3
end

PGA[("🐘 pg-auth\nPostgres 17\nport 5432\nDB: bittuly_auth\n\ntable: users\n id UUID PK\n username TEXT\n email TEXT UNIQUE\n password TEXT (bcrypt)\n created_at TIMESTAMPTZ\n updated_at TIMESTAMPTZ")]

PGU[("🐘 pg-urls\nPostgres 17\nport 5433\nDB: bittuly_urls\n\ntable: urls\n url_id BIGSERIAL PK\n short_code TEXT UNIQUE (base62)\n original_url TEXT\n user_id UUID FK\n click_count BIGINT\n expires_at TIMESTAMPTZ nullable\n created_at TIMESTAMPTZ\n updated_at TIMESTAMPTZ")]

RD[("πŸ”΄ Redis 8\nport 6379\nL2 redirect cache\nURL expiry-aware TTL")]
end

subgraph Messaging ["πŸ“¨ RabbitMQ 4 β€” Event Bus (At-Least-Once)"]
direction TB
RMQ["RabbitMQ 4.3.1-management\nport 5672 management: 15672\nAll queues: durable\n\n─── Queue Topology ───\nclick_events_queue\n β”‚ payload: raw short_code bytes\n β””β†’ consumer-service (batch flush to pg-urls)\n\nuser_deleted_queue ◄──────────────────────────────┐\n β”‚ payload: { user_id: UUID } β”‚ DLX re-route\n β”‚ AMQP headers: W3C traceparent β”‚ (on TTL expire)\n β””β†’ consumer-service β”‚\n β”œβ”€ success β†’ basic_ack β”‚\n β”œβ”€ retry 1 β†’ user_deleted_retry_3s (TTL 3s) ─\n β”œβ”€ retry 2 β†’ user_deleted_retry_9s (TTL 9s) ─\n β”œβ”€ retry 3 β†’ user_deleted_retry_27s (TTL 27s)─\n └─ retry β‰₯3 β†’ user_deleted_dlq (permanent DLQ)"]
end

subgraph Observability ["πŸ”­ Observability Stack"]
direction LR
JAE["Jaeger 2.18.0\nOTLP/gRPC :4317\nUI: :16686\n\nTraced services:\n auth-service\n url-service\n consumer-service\n\nSpans:\n All HTTP routes (OtelAxumLayer)\n delete_user AMQP publish\n get_original_url AMQP publish\n process_click_batch\n attrs: batch_size, unique_urls\n process_user_deleted_event\n attrs: user_id\n W3C traceparent propagated\n across HTTP + AMQP boundaries"]

PROM["Prometheus v3\nUI: :9090\nScrape interval: 5s\n\nMetrics:\n http_requests_total\n {method, path, status}\n http_request_duration_seconds\n {method, path, status}\n links_shortened (counter)\n cache_hits (counter)\n cache_misses (counter)\n rabbit_mq_events_published\n {queue=click_events_queue}"]

GF["Grafana 11.6\nUI: :3000 (admin/admin)\nDashboard:\n Bittuly Live Traffic\n RPS Β· Latency\n Cache Hits / Misses\n Business Metrics"]

PROM --> GF
end

subgraph JWT ["πŸ” JWT Token System (HS256 / HttpOnly Cookies)"]
direction TB
JWTD["access_token cookie TTL 15 min\n { sub: UUID, exp, token_type: access }\n HttpOnly Β· SameSite=Strict Β· Secure (prod)\n\nrefresh_token cookie TTL 30 days\n { sub: UUID, exp, token_type: refresh }\n Silent auto-rotation on expiry\n\npending_token JSON body TTL 10 min\n { email, username, bcrypt(password),\n bcrypt(otp), token_type: pending }\n Never stored in DB until OTP verified\n Two-phase OTP: no user row until verified"]
end
end

%% ─── Connections ─────────────────────────────────────────────────────────
Client -->|"HTTPS / HTTP"| CICD
Client -->|"HTTP :8000"| GW
GW --> IG
IG --> AS
IG --> US
IG --> FE
AS -->|"bcrypt + sqlx"| PGA
AS --> ASRB
ASRB -->|"AMQP publish"| RMQ
US -->|"sqlx"| PGU
US -->|"Moka L1\nget_with singleflight"| L1
L1 -->|"cache miss"| L2
L2 -->|"Redis miss"| L3
L3 -->|"sqlx"| PGU
US --> USRB
USRB -->|"tokio::spawn\nAMQP publish"| RMQ
RMQ -->|"consume click_events_queue"| CS
RMQ -->|"consume user_deleted_queue"| CS
CS -->|"batch UPDATE click_count"| PGU
CS -->|"DEL {short_code}"| RD
L2 -.->|"backed by"| RD
AS -->|"OTLP/gRPC"| JAE
US -->|"OTLP/gRPC"| JAE
CS -->|"OTLP/gRPC"| JAE
PROM -->|"scrape :3001/metrics"| AS
PROM -->|"scrape :3002/metrics"| US
GHCR -->|"imagePullSecret"| K8S
```

---

## πŸ” Key Request Flows

### Flow 1 β€” URL Redirect (Hot Path, p99 < 4ms)

```
Client ──► NGINX (redirect_limit 20rps)
──► url-service GET /{short_code}
β”œβ”€ L1 HIT β†’ Moka cache (singleflight, ~0.01ms) β†’ HTTP 307
β”œβ”€ L2 HIT β†’ Redis GET {code} (~0.3ms) β†’ HTTP 307
└─ L3 MISS β†’ PostgreSQL SELECT β†’ populate Redis + Moka β†’ HTTP 307
↓ (fire-and-forget, non-blocking)
tokio::spawn β†’ publish short_code to click_events_queue
```

### Flow 2 β€” User Signup (Two-Phase OTP)

```
Client ──► POST /api/auth/signup
β”‚ validate payload (validator crate)
β”‚ generate 6-digit OTP
β”‚ bcrypt(otp) + bcrypt(password)
β”‚ create pending_token JWT (10 min TTL) carrying { email, username, bcrypt(pw), bcrypt(otp) }
β”‚ send OTP email via SMTP (lettre) [dev: print to console]
└─ return { pending_token } [no DB write yet]

Client ──► POST /api/auth/verify-otp { pending_token, otp }
β”‚ decode pending_token JWT
β”‚ bcrypt verify otp
β”‚ INSERT INTO users (...) [first DB write]
β”‚ generate access_token (15m) + refresh_token (30d)
└─ set HttpOnly cookies + return user
```

### Flow 3 β€” User Deletion Cascade

```
Client ──► DELETE /api/auth/{user_id}
β”‚ DELETE FROM users WHERE id = $1
β”‚ publish { user_id } to user_deleted_queue (AMQP, W3C traceparent injected)
└─ HTTP 204 immediately returned

[async] consumer-service ◄── user_deleted_queue
β”‚ DELETE FROM urls WHERE user_id = $1 RETURNING short_code
β”‚ for each short_code: DEL url:{short_code} from Redis
└─ basic_ack
↓ on failure (DB down)
retry_count 0 β†’ user_deleted_retry_3s (TTL 3s β†’ DLX back to queue)
retry_count 1 β†’ user_deleted_retry_9s (TTL 9s β†’ DLX back to queue)
retry_count 2 β†’ user_deleted_retry_27s (TTL 27s β†’ DLX back to queue)
retry_count β‰₯ 3 β†’ user_deleted_dlq (permanent dead letter)
```

### Flow 4 β€” Click Analytics (Eventually Consistent, Batched)

```
[background] consumer-service ◄── click_events_queue (continuous stream)
β”‚ accumulate in HashMap
β”‚
β”œβ”€ trigger: total_clicks β‰₯ 17
└─ trigger: tokio::interval every 30s
↓
UPDATE urls SET click_count = click_count + delta
FROM (SELECT unnest($1::text[]) AS code, unnest($2::bigint[]) AS delta) d
WHERE urls.short_code = d.code
↓ success ↓ failure
basic_ack(multiple=true) basic_nack(requeue=true)
clear HashMap exponential backoff (3^n seconds)
```

---

## πŸ“¨ RabbitMQ Queue Topology

```mermaid
graph LR
AS_PUB["auth-service\nDELETE /api/auth/{id}"]
US_PUB["url-service\nGET /{short_code}"]

UDQ["user_deleted_queue\ndurable"]
CEQ["click_events_queue\ndurable"]

R3["user_deleted_retry_3s\nTTL: 3 000ms\nDLX β†’ user_deleted_queue"]
R9["user_deleted_retry_9s\nTTL: 9 000ms\nDLX β†’ user_deleted_queue"]
R27["user_deleted_retry_27s\nTTL: 27 000ms\nDLX β†’ user_deleted_queue"]
DLQ["user_deleted_dlq\nDead Letter Queue\n(permanent)"]

CS["consumer-service"]

AS_PUB -->|"publish\n{user_id}"| UDQ
US_PUB -->|"publish\nshort_code"| CEQ
UDQ -->|"consume"| CS
CEQ -->|"consume"| CS

CS -->|"retry_count=0\nnack"| R3
CS -->|"retry_count=1\nnack"| R9
CS -->|"retry_count=2\nnack"| R27
CS -->|"retry_countβ‰₯3\nnack"| DLQ
R3 -->|"TTL expire\nDLX re-route"| UDQ
R9 -->|"TTL expire\nDLX re-route"| UDQ
R27 -->|"TTL expire\nDLX re-route"| UDQ
```

---

## ⚑ Three-Tier Caching Strategy (Redirect Hot Path)

```mermaid
flowchart LR
REQ["GET /{short_code}"]

subgraph L1 ["L1 β€” Moka (in-process)"]
M["Capacity: 1 000 000 entries\nTTL: 3 seconds\nSingleflight coalescing\n→ Thundering Herd protection\n~0.01ms"]
end

subgraph L2 ["L2 β€” Redis (distributed)"]
R["Auto-reconnect ConnectionManager\nmaxmemory 256MB Β· allkeys-lru\nExpiry-aware TTL (SETEX)\n~0.3ms"]
end

subgraph L3 ["L3 β€” PostgreSQL (source of truth)"]
DB["bittuly_urls DB\n~2-5ms"]
end

REQ --> M
M -->|"HIT β†’ 307"| RESP["HTTP 307 Redirect"]
M -->|"MISS"| R
R -->|"HIT β†’ populate L1 β†’ 307"| RESP
R -->|"MISS"| DB
DB -->|"populate L2 + L1 β†’ 307"| RESP
```

---

## πŸš€ Getting Started

### 1. Start Infrastructure

```bash
docker compose up -d
```

Starts: `postgres-auth` (5432) Β· `postgres-urls` (5433) Β· `redis` (6379) Β· `rabbitmq` (5672/15672) Β· `jaeger` (16686) Β· `prometheus` (9090) Β· `grafana` (3000) Β· `nginx` (8000)

### 2. Configure Environment

Copy `.env.example` to `.env`. Set `MODE=development` to print OTPs to console instead of sending emails.

```bash
cp .env.example .env
```

### 3. Run Services

```bash
cargo dev-auth # Terminal 1 β€” auth-service :3001
cargo dev-urls # Terminal 2 β€” url-service :3002
cargo dev-consumer # Terminal 3 β€” consumer-service
cd web && npm run dev # Terminal 4 β€” frontend :5173
```

Access via NGINX gateway: `http://localhost:8000`

### 4. Run Tests

```bash
./scripts/test.sh # all services
./scripts/test.sh auth # auth-service only
./scripts/test.sh url # url-service only
```

---

## βš“ Kubernetes Local Deployment

### Deploy

```bash
./scripts/k8s.sh up # create kind cluster + load images + apply manifests
./scripts/k8s.sh down # destroy cluster
```

### Access

| Service | URL |
|---|---|
| App + API Gateway | `http://localhost:8000` |
| RabbitMQ UI | `kubectl port-forward -n bittuly svc/rabbitmq 15672:15672` |
| Jaeger UI | `kubectl port-forward -n bittuly svc/jaeger 16686:16686` |

### K8s Resource Summary

| Workload | Replicas | CPU | Memory | HPA |
|---|---|---|---|---|
| auth-service | 1 | 50m β†’ 500m | 64Mi β†’ 256Mi | β€” |
| url-service | 2 | 100m β†’ 1000m | 128Mi β†’ 512Mi | min 2, max 5, CPU 60% |
| consumer-service | 1 | 50m β†’ 500m | 64Mi β†’ 256Mi | β€” |
| frontend-service | 1 | 50m β†’ 200m | 64Mi β†’ 128Mi | β€” |

---

## πŸ›‘οΈ CI/CD Pipeline

```mermaid
flowchart LR
GH["git push / PR"]
subgraph CI ["CI (on: push PR β†’ dev master)"]
direction TB
LINT["rust-lint\ncargo fmt --check\ncargo clippy -D warnings\ncargo audit"]
TA["test-auth\nspins up postgres:17\ncargo test -p auth-service"]
TU["test-url\nspins up postgres:17 + redis:7\ncargo test -p url-service"]
FE2["frontend\nnpm ci\nnpm run typecheck\nnpm run build"]
DB2["docker-build\nbuildx smoke build Γ— 4\npush: false"]
LINT --> TA & TU
TA & TU --> DB2
end
subgraph CD ["CD (on: push β†’ master only)"]
direction TB
PUSH["build-and-push\ndocker buildx Γ— 4 images\nghcr.io/{owner}/bittuly-{svc}:{sha}\nghcr.io/{owner}/bittuly-{svc}:latest\nGHA layer cache"]
DEPLOY["deploy (commented β€” pending k8s target)\nkubectl set image\nkubectl rollout status --timeout 120s"]
PUSH -.->|"future"| DEPLOY
end
GH --> CI
CI --> CD
CD --> GHCR2[("ghcr.io")]
```

---

## πŸ“Š Observability Endpoints

| Tool | URL | Purpose |
|---|---|---|
| NGINX Gateway | `http://localhost:8000` | Main entry point |
| Grafana | `http://localhost:3000` (admin/admin) | Live traffic dashboards |
| Prometheus | `http://localhost:9090` | Raw metrics + query |
| Jaeger | `http://localhost:16686` | Distributed traces |
| RabbitMQ UI | `http://localhost:15672` (bittu/bittu) | Queue management |

> **Note**: `/api/*/metrics` endpoints are blocked with `403` at the NGINX gateway level. Prometheus scrapes services directly on their internal ports.

---

## πŸ› οΈ Useful Commands

```bash
# Wipe databases and restart fresh
docker compose down -v && docker compose up -d

# Run full local CI checks (same as GitHub Actions)
./scripts/check.sh

# API endpoint smoke tests
./scripts/api-test.sh

# Load test (k6 via kubectl inside cluster)
./scripts/load-test.sh

# Production build
cargo build --release
```