Syed Arfan commited on
Commit
66da1e3
·
1 Parent(s): 09107be

Add Docker Compose setup with PostgreSQL, Redis, and Nginx; implement caching and database models

Browse files
Files changed (6) hide show
  1. docker-compose.yml +57 -0
  2. nginx/nginx.conf +19 -0
  3. requirements.txt +8 -1
  4. src/cache.py +151 -0
  5. src/database.py +91 -0
  6. src/main.py +81 -5
docker-compose.yml ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ postgres:
3
+ image: postgres:15-alpine
4
+ container_name: sentiment-api-postgres
5
+ environment:
6
+ POSTGRES_DB: sentiment
7
+ POSTGRES_USER: user
8
+ POSTGRES_PASSWORD: pass
9
+ ports:
10
+ - "5432:5432"
11
+ volumes:
12
+ - postgres_data:/var/lib/postgresql/data
13
+ healthcheck:
14
+ test: ["CMD-SHELL", "pg_isready -U user -d sentiment"]
15
+ interval: 10s
16
+ timeout: 5s
17
+ retries: 5
18
+
19
+ redis:
20
+ image: redis:7-alpine
21
+ container_name: sentiment-api-redis
22
+ ports:
23
+ - "6379:6379" # Redis default port
24
+ healthcheck:
25
+ test: ["CMD", "redis-cli", "ping"]
26
+ interval: 10s
27
+ timeout: 3s
28
+ retries: 5
29
+ command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
30
+
31
+ nginx:
32
+ image: nginx:alpine
33
+ container_name: sentiment-api-nginx
34
+ ports:
35
+ - "80:80"
36
+ volumes:
37
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
38
+ depends_on:
39
+ - api
40
+
41
+ api:
42
+ build: .
43
+ container_name: sentiment-api
44
+ ports:
45
+ - "8000:8000"
46
+ environment:
47
+ DATABASE_URL: postgresql://user:pass@postgres:5432/sentiment
48
+ REDIS_URL: redis://redis:6379 # ← ADD THIS LINE
49
+ depends_on:
50
+ postgres:
51
+ condition: service_healthy
52
+ redis: # ← ADD THIS
53
+ condition: service_healthy
54
+ command: uvicorn src.main:app --host 0.0.0.0 --port 8000
55
+
56
+ volumes:
57
+ postgres_data:
nginx/nginx.conf ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ events {
2
+ worker_connections 1024;
3
+ }
4
+
5
+ http {
6
+ upstream api {
7
+ server sentiment-api:8000;
8
+ }
9
+
10
+ server {
11
+ listen 80;
12
+
13
+ location / {
14
+ proxy_pass http://api;
15
+ proxy_set_header Host $host;
16
+ proxy_set_header X-Real-IP $remote_addr;
17
+ }
18
+ }
19
+ }
requirements.txt CHANGED
@@ -8,4 +8,11 @@ httpx==0.28.1
8
 
9
  # Testing
10
  pytest==8.3.4
11
- pytest-cov==6.0.0
 
 
 
 
 
 
 
 
8
 
9
  # Testing
10
  pytest==8.3.4
11
+ pytest-cov==6.0.0
12
+
13
+ # Database
14
+ psycopg2-binary==2.9.9
15
+ sqlalchemy==2.0.23
16
+
17
+ # Cache
18
+ redis==5.2.1
src/cache.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Redis cache layer for sentiment analysis API
3
+
4
+ Caches ML inference results to avoid redundant model calls
5
+ Uses Redis for sub-millisecond lookup times
6
+ """
7
+
8
+ import redis
9
+ import hashlib
10
+ import json
11
+ import os
12
+ from typing import Optional, Dict, Any
13
+
14
+ # Get Redis URL from environment variable
15
+ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
16
+
17
+ # Create Redis client
18
+ # decode_responses=True converts bytes to strings automatically
19
+ redis_client = redis.from_url(REDIS_URL, decode_responses=True)
20
+
21
+ # Cache TTL (Time To Live) - how long to keep cached results
22
+ CACHE_TTL_SECONDS = 3600 # 1 hour
23
+
24
+
25
+ def generate_cache_key(text: str) -> str:
26
+ """
27
+ Generate a unique cache key for the input text
28
+
29
+ Uses SHA-256 hash to create consistent keys
30
+ Same text always generates same key
31
+
32
+ Args:
33
+ text: Input text to analyze
34
+
35
+ Returns:
36
+ Cache key string (e.g., "sentiment:abc123...")
37
+ """
38
+ # Create hash of the text (consistent for same input)
39
+ text_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
40
+ return f"sentiment:{text_hash}"
41
+
42
+
43
+ def get_cached_result(text: str) -> Optional[Dict[str, Any]]:
44
+ """
45
+ Retrieve cached sentiment analysis result
46
+
47
+ Args:
48
+ text: Input text to look up
49
+
50
+ Returns:
51
+ Cached result dict if found, None if cache miss
52
+ """
53
+ try:
54
+ cache_key = generate_cache_key(text)
55
+ cached_data = redis_client.get(cache_key)
56
+
57
+ if cached_data:
58
+ # Parse JSON string back to dict
59
+ return json.loads(cached_data)
60
+
61
+ return None
62
+ except Exception as e:
63
+ # If Redis fails, log but don't crash
64
+ print(f"Cache retrieval error: {e}")
65
+ return None
66
+
67
+
68
+ def cache_result(text: str, result: Dict[str, Any]) -> bool:
69
+ """
70
+ Store sentiment analysis result in cache
71
+
72
+ Args:
73
+ text: Input text that was analyzed
74
+ result: Analysis result to cache
75
+
76
+ Returns:
77
+ True if cached successfully, False otherwise
78
+ """
79
+ try:
80
+ cache_key = generate_cache_key(text)
81
+
82
+ # Convert dict to JSON string
83
+ result_json = json.dumps(result)
84
+
85
+ # Store in Redis with TTL
86
+ redis_client.setex(
87
+ cache_key,
88
+ CACHE_TTL_SECONDS,
89
+ result_json
90
+ )
91
+
92
+ return True
93
+ except Exception as e:
94
+ print(f"Cache storage error: {e}")
95
+ return False
96
+
97
+
98
+ def get_cache_stats() -> Dict[str, Any]:
99
+ """
100
+ Get Redis cache statistics
101
+
102
+ Returns:
103
+ Dict with cache info (memory usage, keys, hits, etc.)
104
+ """
105
+ try:
106
+ info = redis_client.info("stats")
107
+ memory = redis_client.info("memory")
108
+
109
+ # Count sentiment-related keys
110
+ sentiment_keys = len(redis_client.keys("sentiment:*"))
111
+
112
+ return {
113
+ "status": "connected",
114
+ "total_keys": redis_client.dbsize(),
115
+ "sentiment_keys": sentiment_keys,
116
+ "memory_used_mb": round(memory["used_memory"] / 1024 / 1024, 2),
117
+ "hits": info.get("keyspace_hits", 0),
118
+ "misses": info.get("keyspace_misses", 0),
119
+ "hit_rate": round(
120
+ info.get("keyspace_hits", 0) /
121
+ max(info.get("keyspace_hits", 0) + info.get("keyspace_misses", 0), 1) * 100,
122
+ 2
123
+ )
124
+ }
125
+ except Exception as e:
126
+ return {
127
+ "status": "error",
128
+ "error": str(e)
129
+ }
130
+
131
+
132
+ def clear_cache() -> bool:
133
+ """
134
+ Clear all sentiment cache entries
135
+
136
+ WARNING: This removes all cached results
137
+
138
+ Returns:
139
+ True if cleared successfully
140
+ """
141
+ try:
142
+ # Get all sentiment keys
143
+ keys = redis_client.keys("sentiment:*")
144
+
145
+ if keys:
146
+ redis_client.delete(*keys)
147
+
148
+ return True
149
+ except Exception as e:
150
+ print(f"Cache clear error: {e}")
151
+ return False
src/database.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database connection and models for sentiment API
3
+
4
+ Uses SQLAlchemy ORM for clean database interactions
5
+ PostgreSQL for persistent storage (runs in Docker container - FREE!)
6
+ """
7
+
8
+ from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime
9
+ from sqlalchemy.ext.declarative import declarative_base
10
+ from sqlalchemy.orm import sessionmaker
11
+ from datetime import datetime
12
+ import os
13
+
14
+ # Get database URL from environment variable
15
+ # Set by docker-compose.yml
16
+ DATABASE_URL = os.getenv(
17
+ "DATABASE_URL",
18
+ "postgresql://user:pass@localhost:5432/sentiment" # Fallback for local dev
19
+ )
20
+
21
+ # Create database engine
22
+ # This connects to PostgreSQL container
23
+ engine = create_engine(DATABASE_URL)
24
+
25
+ # Create session factory
26
+ # Sessions are like "conversations" with the database
27
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
28
+
29
+ # Base class for database models
30
+ Base = declarative_base()
31
+
32
+
33
+ # Database Model (Table Definition)
34
+ class SentimentAnalysis(Base):
35
+ """
36
+ Stores sentiment analysis results
37
+
38
+ Table: sentiment_analyses
39
+ """
40
+ __tablename__ = "sentiment_analyses"
41
+
42
+ # Primary key (auto-incrementing ID)
43
+ id = Column(Integer, primary_key=True, index=True)
44
+
45
+ # The text that was analyzed
46
+ text = Column(String(512), nullable=False)
47
+
48
+ # Sentiment result (POSITIVE or NEGATIVE)
49
+ sentiment = Column(String(50), nullable=False)
50
+
51
+ # Confidence score (0.0 to 1.0)
52
+ confidence = Column(Float, nullable=False)
53
+
54
+ # Processing time in milliseconds
55
+ processing_time_ms = Column(Integer, nullable=False)
56
+
57
+ # When this analysis was created
58
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
59
+
60
+
61
+ # Create all tables in the database
62
+ # This runs when the app starts
63
+ def init_db():
64
+ """
65
+ Initialize database tables
66
+
67
+ Creates sentiment_analyses table if it doesn't exist
68
+ Safe to call multiple times (won't recreate existing tables)
69
+ """
70
+ Base.metadata.create_all(bind=engine)
71
+
72
+
73
+ # Dependency for FastAPI routes
74
+ # Provides a database session to each request
75
+ def get_db():
76
+ """
77
+ FastAPI dependency that provides a database session
78
+
79
+ Usage in routes:
80
+ @app.post("/analyze")
81
+ def analyze(db: Session = Depends(get_db)):
82
+ # Use db here
83
+ pass
84
+
85
+ Automatically closes session after request
86
+ """
87
+ db = SessionLocal()
88
+ try:
89
+ yield db
90
+ finally:
91
+ db.close()
src/main.py CHANGED
@@ -1,7 +1,11 @@
1
  from fastapi import FastAPI, HTTPException
2
  from pydantic import BaseModel, Field
3
  from transformers import pipeline
 
 
 
4
  import time
 
5
 
6
  app = FastAPI(
7
  title="Sentiment Analysis API",
@@ -9,6 +13,14 @@ app = FastAPI(
9
  version="1.0.0"
10
  )
11
 
 
 
 
 
 
 
 
 
12
  # Load model once at startup
13
  print("Loading sentiment analysis model...")
14
  sentiment_analyzer = pipeline(
@@ -26,6 +38,7 @@ class SentimentResponse(BaseModel):
26
  sentiment: str
27
  confidence: float
28
  processing_time_ms: int
 
29
 
30
  @app.get("/")
31
  def root():
@@ -37,29 +50,92 @@ def root():
37
  }
38
 
39
  @app.post("/analyze", response_model=SentimentResponse)
40
- def analyze_sentiment(request: TextRequest):
 
 
 
41
  """
42
- Analyze sentiment of input text.
 
43
  Returns sentiment (POSITIVE/NEGATIVE) with confidence score.
 
44
  """
45
  start_time = time.time()
46
 
47
  try:
48
- # Run inference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  result = sentiment_analyzer(request.text)[0]
50
 
51
  processing_time = int((time.time() - start_time) * 1000)
52
 
53
- return SentimentResponse(
 
 
 
 
 
 
 
 
 
 
54
  text=request.text,
55
  sentiment=result['label'],
56
  confidence=round(result['score'], 4),
57
  processing_time_ms=processing_time
58
  )
 
 
 
 
 
 
 
 
 
 
59
  except Exception as e:
60
  raise HTTPException(status_code=500, detail=str(e))
61
 
62
  @app.get("/health")
63
  def health():
64
  """Kubernetes-style health check"""
65
- return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from fastapi import FastAPI, HTTPException
2
  from pydantic import BaseModel, Field
3
  from transformers import pipeline
4
+ from sqlalchemy.orm import Session
5
+ from fastapi import Depends
6
+ from .database import init_db, get_db, SentimentAnalysis
7
  import time
8
+ from . import cache
9
 
10
  app = FastAPI(
11
  title="Sentiment Analysis API",
 
13
  version="1.0.0"
14
  )
15
 
16
+ # Initialize database on startup
17
+ @app.on_event("startup")
18
+ def startup_event():
19
+ """Create database tables if they don't exist"""
20
+ print("Initializing database...")
21
+ init_db()
22
+ print("Database ready!")
23
+
24
  # Load model once at startup
25
  print("Loading sentiment analysis model...")
26
  sentiment_analyzer = pipeline(
 
38
  sentiment: str
39
  confidence: float
40
  processing_time_ms: int
41
+ cached: bool = False # ← ADD THIS LINE
42
 
43
  @app.get("/")
44
  def root():
 
50
  }
51
 
52
  @app.post("/analyze", response_model=SentimentResponse)
53
+ def analyze_sentiment(
54
+ request: TextRequest,
55
+ db: Session = Depends(get_db)
56
+ ):
57
  """
58
+ Analyze sentiment of input text with caching.
59
+
60
  Returns sentiment (POSITIVE/NEGATIVE) with confidence score.
61
+ Stores result in PostgreSQL database and Redis cache.
62
  """
63
  start_time = time.time()
64
 
65
  try:
66
+ cached_result = cache.get_cached_result(request.text)
67
+
68
+ if cached_result:
69
+ # Cache HIT - return cached result
70
+ print(f"Cache HIT for: {request.text[:50]}")
71
+
72
+ # Add cache indicator
73
+ cached_result["cached"] = True
74
+ cached_result["processing_time_ms"] = int((time.time() - start_time) * 1000)
75
+
76
+ return SentimentResponse(**cached_result)
77
+
78
+ # Cache MISS - run ML model
79
+ print(f"Cache MISS for: {request.text[:50]}")
80
+
81
  result = sentiment_analyzer(request.text)[0]
82
 
83
  processing_time = int((time.time() - start_time) * 1000)
84
 
85
+ # Create response
86
+ response_data = {
87
+ "text": request.text,
88
+ "sentiment": result['label'],
89
+ "confidence": round(result['score'], 4),
90
+ "processing_time_ms": processing_time,
91
+ "cached": False # NEW: indicate this wasn't cached
92
+ }
93
+
94
+ # Store in database
95
+ db_analysis = SentimentAnalysis(
96
  text=request.text,
97
  sentiment=result['label'],
98
  confidence=round(result['score'], 4),
99
  processing_time_ms=processing_time
100
  )
101
+ db.add(db_analysis)
102
+ db.commit()
103
+ db.refresh(db_analysis)
104
+
105
+ # ===== NEW: Store in cache =====
106
+ cache.cache_result(request.text, response_data)
107
+ # ===============================
108
+
109
+ return SentimentResponse(**response_data)
110
+
111
  except Exception as e:
112
  raise HTTPException(status_code=500, detail=str(e))
113
 
114
  @app.get("/health")
115
  def health():
116
  """Kubernetes-style health check"""
117
+ return {"status": "ok"}
118
+
119
+ @app.get("/cache/stats")
120
+ def get_cache_statistics():
121
+ """
122
+ Get Redis cache statistics
123
+
124
+ Shows cache hit rate, memory usage, and key counts
125
+ """
126
+ return cache.get_cache_stats()
127
+
128
+
129
+ @app.delete("/cache/clear")
130
+ def clear_cache_endpoint():
131
+ """
132
+ Clear all cached sentiment results
133
+
134
+ Use this to force fresh analysis for all requests
135
+ """
136
+ success = cache.clear_cache()
137
+
138
+ if success:
139
+ return {"message": "Cache cleared successfully"}
140
+ else:
141
+ return {"message": "Failed to clear cache"}