From 044ce40e6911e12c265ab6750699e231ceb68872 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sat, 20 Jun 2026 04:09:38 -0400 Subject: [PATCH] Remove redis --- README.md | 4 +- TODO | 5 ++ backend/app/core/config.py | 6 +- backend/app/main.py | 96 +++----------------------------- backend/requirements.txt | 1 - backend/tests/README.md | 6 +- backend/tests/test_app_health.py | 38 +++++++++++++ docker-compose.prod.yml | 14 ----- docker-compose.yml | 11 +--- 9 files changed, 56 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 75d85e8..88bc0f6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ A modern, containerized Prior Permission Required (PPR) system for aircraft oper - **Backend**: FastAPI with Python 3.11 - **Database**: MySQL 8.0 -- **Cache**: Redis 7 - **Container**: Docker & Docker Compose ## Features @@ -63,7 +62,7 @@ The container automatically handles: - Database connection verification - Schema creation/migration (Alembic) - Reference data seeding (if needed) -- Production server startup (4 workers) +- Production server startup (single worker for in-process WebSocket broadcasts) **Monitor deployment:** ```bash @@ -289,7 +288,6 @@ This ensures consistency across different time zones and complies with aviation - Database connection pooling - Indexed columns for fast queries -- Redis caching (ready for implementation) - Async/await for non-blocking operations ## Monitoring diff --git a/TODO b/TODO index afe56e9..8cb13a5 100644 --- a/TODO +++ b/TODO @@ -1,5 +1,9 @@ TODO +Allow corrections + +Post-strip reporting + Implement mark's 'tick off the PPRs' in the old admin screen Define schema for 'movements' table. We generate movement records as they happen so as not to reply on maths @@ -9,3 +13,4 @@ Flow to create an arrival and maybe departure from a PPR. Perhaps we need a corr Ability to add a position report to a strip Improve journaling + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ee88fd8..72bea08 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,4 @@ from pydantic_settings import BaseSettings -from typing import Optional class Settings(BaseSettings): @@ -36,9 +35,6 @@ class Settings(BaseSettings): # Public booking settings allow_public_booking: bool = False # Enable/disable public flight booking - # Redis settings (for future use) - redis_url: Optional[str] = None - class Config: env_file = ".env" case_sensitive = False @@ -48,4 +44,4 @@ class Settings(BaseSettings): return f"mysql+pymysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index 039660c..b4a6878 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,8 @@ -from fastapi import FastAPI, Depends, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from typing import List import json import logging -import asyncio -import redis.asyncio as redis from app.core.config import settings from app.api.api import api_router @@ -22,10 +20,6 @@ from app.models.drone_request import DroneRequest logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# Redis client for pub/sub (cross-worker communication) -redis_client = None -pubsub = None - app = FastAPI( title=settings.project_name, openapi_url=f"{settings.api_v1_str}/openapi.json", @@ -46,7 +40,6 @@ app.add_middleware( class ConnectionManager: def __init__(self): self.active_connections: List[WebSocket] = [] - self.redis_listener_task = None async def connect(self, websocket: WebSocket): await websocket.accept() @@ -61,102 +54,27 @@ class ConnectionManager: async def send_personal_message(self, message: str, websocket: WebSocket): await websocket.send_text(message) - async def broadcast_local(self, message_str: str): - """Broadcast to connections on this worker only""" + async def broadcast(self, message: dict): + """Broadcast an update to every websocket connected to this process.""" + message_str = json.dumps(message) dead_connections = [] - for connection in self.active_connections: + for connection in list(self.active_connections): try: await connection.send_text(message_str) except Exception as e: logger.warning(f"Failed to send to connection: {e}") dead_connections.append(connection) - + # Remove dead connections for connection in dead_connections: if connection in self.active_connections: self.active_connections.remove(connection) - + if dead_connections: logger.info(f"Removed {len(dead_connections)} dead connections") - async def broadcast(self, message: dict): - """Broadcast via Redis pub/sub to all workers""" - message_str = json.dumps(message) - print(f"Publishing message to Redis channel: {message.get('type', 'unknown')}") - logger.info(f"Publishing message to Redis channel: {message.get('type', 'unknown')}") - - try: - if redis_client: - await redis_client.publish('ppr_updates', message_str) - print(f"✓ Message published to Redis") - else: - # Fallback to local broadcast if Redis not available - print("⚠ Redis not available, falling back to local broadcast") - logger.warning("Redis not available, falling back to local broadcast") - await self.broadcast_local(message_str) - except Exception as e: - print(f"✗ Failed to publish to Redis: {e}") - logger.error(f"Failed to publish to Redis: {e}") - # Fallback to local broadcast - await self.broadcast_local(message_str) - - async def start_redis_listener(self): - """Listen for Redis pub/sub messages and broadcast to local connections""" - global redis_client, pubsub - - try: - # Connect to Redis - redis_url = settings.redis_url or "redis://redis:6379" - print(f"Connecting to Redis at: {redis_url}") - redis_client = await redis.from_url(redis_url, encoding="utf-8", decode_responses=True) - pubsub = redis_client.pubsub() - await pubsub.subscribe('ppr_updates') - - print("✓ Redis listener started for PPR updates") - logger.info("Redis listener started for PPR updates") - - async for message in pubsub.listen(): - if message['type'] == 'message': - message_data = message['data'] - print(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections") - logger.info(f"Received Redis message, broadcasting to {len(self.active_connections)} local connections") - await self.broadcast_local(message_data) - - except Exception as e: - print(f"Redis listener error: {e}") - logger.error(f"Redis listener error: {e}") - await asyncio.sleep(5) # Wait before retry - # Retry connection - if self.redis_listener_task and not self.redis_listener_task.done(): - asyncio.create_task(self.start_redis_listener()) - manager = ConnectionManager() -@app.on_event("startup") -async def startup_event(): - """Start Redis listener when application starts""" - print("=" * 50) - print("STARTUP: Starting application and Redis listener...") - print("=" * 50) - logger.info("Starting application and Redis listener...") - manager.redis_listener_task = asyncio.create_task(manager.start_redis_listener()) - -@app.on_event("shutdown") -async def shutdown_event(): - """Clean up Redis connections on shutdown""" - logger.info("Shutting down application...") - global redis_client, pubsub - - if manager.redis_listener_task: - manager.redis_listener_task.cancel() - - if pubsub: - await pubsub.unsubscribe('ppr_updates') - await pubsub.close() - - if redis_client: - await redis_client.close() - @app.websocket("/ws/tower-updates") async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) diff --git a/backend/requirements.txt b/backend/requirements.txt index e3d5f06..bd50c3d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,5 @@ pytest==7.4.3 pytest-cov==4.1.0 pytest-asyncio==0.21.1 httpx==0.25.2 -redis==5.0.1 aiosmtplib==3.0.1 jinja2==3.1.2 diff --git a/backend/tests/README.md b/backend/tests/README.md index 956bc2a..6251872 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -1,6 +1,6 @@ # Backend API Test Guide -This directory contains the backend API test suite. The tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database. The goal is to cover the business-critical API behaviour without relying on MySQL, Redis, SMTP, or a running browser. +This directory contains the backend API test suite. The tests use pytest, FastAPI's `TestClient`, and an isolated in-memory SQLite database. The goal is to cover the business-critical API behaviour without relying on MySQL, SMTP, or a running browser. ## How To Run @@ -190,8 +190,8 @@ Why it matters: ## Current Scope -The suite intentionally focuses on API behaviour and database side effects. It does not deeply test: -- WebSocket connection lifecycle and Redis pub/sub behaviour. +The suite intentionally focuses on API behaviour, local WebSocket broadcast behaviour, and database side effects. It does not deeply test: +- Full browser WebSocket lifecycle. - Real SMTP delivery. - Browser UI behaviour. - Every branch of low-level validators or helper functions. diff --git a/backend/tests/test_app_health.py b/backend/tests/test_app_health.py index 2b01973..0b0274d 100644 --- a/backend/tests/test_app_health.py +++ b/backend/tests/test_app_health.py @@ -1,3 +1,10 @@ +import json + +import pytest + +from app.main import ConnectionManager + + def test_root_returns_api_metadata(client): response = client.get("/") @@ -12,3 +19,34 @@ def test_health_check_reports_database_connection(client): assert response.status_code == 200 assert response.json()["status"] == "healthy" assert response.json()["database"] == "connected" + + +class FakeWebSocket: + def __init__(self, fail_send=False): + self.accepted = False + self.fail_send = fail_send + self.messages = [] + + async def accept(self): + self.accepted = True + + async def send_text(self, message): + if self.fail_send: + raise RuntimeError("socket closed") + self.messages.append(message) + + +@pytest.mark.asyncio +async def test_connection_manager_broadcasts_to_active_connections_and_removes_dead_ones(): + manager = ConnectionManager() + active_socket = FakeWebSocket() + dead_socket = FakeWebSocket(fail_send=True) + + await manager.connect(active_socket) + await manager.connect(dead_socket) + await manager.broadcast({"type": "ppr_updated", "id": 123}) + + assert active_socket.accepted is True + assert dead_socket.accepted is True + assert json.loads(active_socket.messages[0]) == {"type": "ppr_updated", "id": 123} + assert manager.active_connections == [active_socket] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 22d162a..dc5e6a0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -25,12 +25,10 @@ services: MAIL_FROM: ${MAIL_FROM} MAIL_FROM_NAME: ${MAIL_FROM_NAME} BASE_URL: ${BASE_URL} - REDIS_URL: ${REDIS_URL} TAG: ${TAG} TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} ALLOW_PUBLIC_BOOKING: ${ALLOW_PUBLIC_BOOKING} ENVIRONMENT: production - WORKERS: "4" ports: - "${API_PORT_EXTERNAL}:8000" volumes: @@ -56,18 +54,6 @@ services: retries: 3 start_period: 40s - # Redis for caching (optional) - redis: - image: redis:7-alpine - restart: always - networks: - - app_network - deploy: - resources: - limits: - cpus: '0.5' - memory: 512M - # Nginx web server for public frontend web: image: nginx:alpine diff --git a/docker-compose.yml b/docker-compose.yml index 600187e..423b7c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,6 @@ services: MAIL_FROM: ${MAIL_FROM} MAIL_FROM_NAME: ${MAIL_FROM_NAME} BASE_URL: ${BASE_URL} - REDIS_URL: ${REDIS_URL} TOWER_NAME: ${TOWER_NAME} TOP_BAR_BASE_COLOR: ${TOP_BAR_BASE_COLOR} ENVIRONMENT: ${ENVIRONMENT} @@ -72,14 +71,6 @@ services: networks: - public_network - # Redis for caching (optional for now) - redis: - image: redis:7-alpine - container_name: ppr_nextgen_redis - restart: unless-stopped - networks: - - private_network - # phpMyAdmin for database management phpmyadmin: image: phpmyadmin/phpmyadmin @@ -106,4 +97,4 @@ networks: private_network: driver: bridge public_network: - driver: bridge \ No newline at end of file + driver: bridge