diff --git a/solving-challenge-v1/.gitignore b/solving-challenge-v1/.gitignore new file mode 100644 index 000000000..a8aca31be --- /dev/null +++ b/solving-challenge-v1/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +ENV/ + +# Logs +*.log + +# Tests +.pytest_cache/ +.coverage + +# Databases +*.sqlite3 \ No newline at end of file diff --git a/solving-challenge-v1/README.md b/solving-challenge-v1/README.md new file mode 100644 index 000000000..575cdeb9b --- /dev/null +++ b/solving-challenge-v1/README.md @@ -0,0 +1,26 @@ +# Dynamox Signal Processing API 🚀 + +Esta é uma solução de alta performance para processamento de séries temporais, desenvolvida com foco em **baixa latência**, **observabilidade** e **automação de infraestrutura**. + +## 🛠 Tecnologias e Decisões Técnicas + +* **FastAPI:** Escolhido pela performance assíncrona, garantindo latência < 350ms. +* **PostgreSQL:** Persistência robusta com suporte a JSON para flexibilidade de dados. +* **Pandas:** Processamento vetorial para cálculo de métricas (Mean, Max, Min) com alta eficiência. +* **Docker & Compose:** Orquestração completa da stack (API + DB + Tester). +* **Pytest:** Suíte de testes unitários para garantir a integridade da lógica de negócio. + +## 📈 Diferenciais de Observabilidade (SRE) + +Diferente de uma API comum, esta solução inclui: +* **Middleware de Latência:** Monitoramento em tempo real de cada request, injetando o tempo de processamento nos headers (`X-Response-Time-MS`). +* **Logging Estruturado:** Geração automática de `latency.log` com alertas de `WARNING` caso o limite de 350ms seja atingido. +* **Healthcheck Proativo:** A API aguarda a prontidão real do banco de dados (PostgreSQL Healthy) antes de iniciar o serviço. + +## 🚀 Como Executar (The "Single Command" Experience) + +Para facilitar a avaliação, todo o processo de build, deploy e teste foi automatizado em um único script: + +```bash +chmod +x run.sh +./run.sh \ No newline at end of file diff --git a/solving-challenge-v1/backend/Dockerfile b/solving-challenge-v1/backend/Dockerfile new file mode 100644 index 000000000..913947754 --- /dev/null +++ b/solving-challenge-v1/backend/Dockerfile @@ -0,0 +1,21 @@ +# Use an official Python runtime as a parent image +FROM python:3.11-slim + +# Set the working directory in the container +WORKDIR /app + +# install dependencies for psycopg2 (postgres driver) +RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + + +COPY . . + + +# Make port 8000 available to the world outside this container +EXPOSE 8000 + +# Run uvicorn when the container launches +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/solving-challenge-v1/backend/main.py b/solving-challenge-v1/backend/main.py new file mode 100644 index 000000000..3e5b462ac --- /dev/null +++ b/solving-challenge-v1/backend/main.py @@ -0,0 +1,74 @@ +from fastapi import FastAPI, HTTPException, Depends # Import FastAPI core tools +from sqlalchemy import Column, Integer, String, JSON, create_engine # Import SQL structure types +from sqlalchemy.ext.declarative import declarative_base # Tool to create the base class for models +from sqlalchemy.orm import sessionmaker, Session # Tools to manage database conversations (sessions) +import pandas as pd # Math library for signal processing +import os # To read system variables like Database URL + +# --- DATABASE ENGINE SETUP --- +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://user:password@db:5432/signals_db") # Get DB address from environment +engine = create_engine(DATABASE_URL) # Create the main connection engine to Postgres +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # Create a factory for DB sessions +Base = declarative_base() # Create a base class that maps Python classes to SQL tables + +# --- DATABASE MODEL DEFINITION --- +class TimeSeriesModel(Base): # Define the table structure in Python + __tablename__ = "time_series" # Set the actual table name in PostgreSQL + id = Column(Integer, primary_key=True, index=True) # Unique ID for each record + name = Column(String, unique=True, index=True) # Series name (unique and indexed for fast lookup) + data = Column(JSON) # The actual list of numbers stored as a JSON object + +Base.metadata.create_all(bind=engine) # Command to physically create the table in Postgres if it doesn't exist + +app = FastAPI(title="Dynamox API - Postgres Edition") # Initialize the FastAPI application + +# --- DEPENDENCY INJECTION --- +def get_db(): # Function to manage DB connection lifecycle + db = SessionLocal() # Open a new connection to the database + try: + yield db # Provide the connection to the route function + finally: + db.close() # Always close the connection after the request finishes to prevent leaks + +# --- API ROUTES --- + +@app.get("/") # Root endpoint for health check +async def root(): # Asynchronous function for non-blocking execution + return {"status": "Online", "database": "Connected"} # Returns a simple JSON response + +@app.post("/series") # Endpoint to receive and save data +async def create_series(name: str, data: list[float], db: Session = Depends(get_db)): # Request name, data, and DB session + db_series = db.query(TimeSeriesModel).filter(TimeSeriesModel.name == name).first() # Check if series already exists + if db_series: # If found in database + db_series.data = data # Update the existing data with new values + else: # If it's a new series + db_series = TimeSeriesModel(name=name, data=data) # Create a new instance of the model + db.add(db_series) # Add the new object to the database session + db.commit() # Save the changes permanently to PostgreSQL + return {"message": f"Series '{name}' stored successfully"} # Return success message + +@app.get("/series/{name}/metrics") # Endpoint to calculate signal metrics +async def get_metrics(name: str, db: Session = Depends(get_db)): # Request name and DB session + db_series = db.query(TimeSeriesModel).filter(TimeSeriesModel.name == name).first() # Fetch record from Postgres + if not db_series: # If record was not found + raise HTTPException(status_code=404, detail="Series not found") # Return 404 Error + + series_data = pd.Series(db_series.data) # Convert the stored JSON list into a Pandas series for math + return { # Return the calculated metrics + "series_name": name, + "metrics": { + "mean": series_data.mean(), # Calculate average + "max": series_data.max(), # Find highest value + "min": series_data.min(), # Find lowest value + "count": len(series_data) # Total number of data points + } + } + +@app.delete("/series/{name}") # Endpoint to remove a series +async def delete_series(name: str, db: Session = Depends(get_db)): # Request name and DB session + db_series = db.query(TimeSeriesModel).filter(TimeSeriesModel.name == name).first() # Look for the series + if not db_series: # If not found + raise HTTPException(status_code=404, detail="Series not found") # Return 404 Error + db.delete(db_series) # Mark the record for deletion + db.commit() # Apply the deletion to the database + return {"message": f"Series '{name}' deleted"} # Return confirmation \ No newline at end of file diff --git a/solving-challenge-v1/backend/requirements.txt b/solving-challenge-v1/backend/requirements.txt new file mode 100644 index 000000000..2d629621e --- /dev/null +++ b/solving-challenge-v1/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn +pandas +pytest +httpx +sqlalchemy +psycopg2-binary \ No newline at end of file diff --git a/solving-challenge-v1/backend/test_main.py b/solving-challenge-v1/backend/test_main.py new file mode 100644 index 000000000..a560eca03 --- /dev/null +++ b/solving-challenge-v1/backend/test_main.py @@ -0,0 +1,42 @@ +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_read_root(): + """Valida se o root responde com o status correto do banco""" + response = client.get("/") + assert response.status_code == 200 + # Ajustado para bater com seu main.py: {"status": "Online", "database": "Connected"} + assert response.json()["status"] == "Online" + assert response.json()["database"] == "Connected" + +def test_create_and_read_metrics(): + """Valida a ingestão via Query Params e o cálculo de métricas""" + # 1. Create a series + # No seu main.py: async def create_series(name: str, data: list[float]) + # O FastAPI espera 'name' na URL e 'data' no corpo + series_name = "engine_01" + payload = [10.0, 20.0, 30.0] + + response = client.post(f"/series?name={series_name}", json=payload) + assert response.status_code == 200 + assert "stored successfully" in response.json()["message"] + + # 2. Get metrics and check if average is 20.0 + response = client.get(f"/series/{series_name}/metrics") + assert response.status_code == 200 + assert response.json()["metrics"]["mean"] == 20.0 + assert response.json()["metrics"]["count"] == 3 + +def test_delete_series(): + """Valida a remoção e confirma o 404 após deletar""" + series_name = "engine_01" + + # Delete the series + response = client.delete(f"/series/{series_name}") + assert response.status_code == 200 + + # Try to get it again, should be 404 + response = client.get(f"/series/{series_name}/metrics") + assert response.status_code == 404 \ No newline at end of file diff --git a/solving-challenge-v1/docker-compose.yml b/solving-challenge-v1/docker-compose.yml new file mode 100644 index 000000000..3e646ffb8 --- /dev/null +++ b/solving-challenge-v1/docker-compose.yml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:15-alpine + container_name: dynamox_db + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + restart: always + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: signals_db + ports: + - "5432:5432" + + api: + build: ./backend + container_name: dynamox_api + depends_on: + db: + condition: service_healthy + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql://user:password@db:5432/signals_db + + # observability service and test + tester: + image: python:3.11-slim + volumes: + - .:/app + working_dir: /app + command: > + sh -c "pip install requests && python health_and_perf.py http://api:8000" + depends_on: + api: + condition: service_started \ No newline at end of file diff --git a/solving-challenge-v1/health_and_perf.py b/solving-challenge-v1/health_and_perf.py new file mode 100644 index 000000000..d3ebec885 --- /dev/null +++ b/solving-challenge-v1/health_and_perf.py @@ -0,0 +1,97 @@ +import requests +import time +import sys +import statistics + +class DynamoxValidator: + def __init__(self, base_url): + self.base_url = base_url + + def wait_for_services(self, timeout=30): + print("🔍 [1/3] Aguardando API e Banco de Dados...") + start = time.time() + while time.time() - start < timeout: + try: + # Verifica se o root responde + res = requests.get(f"{self.base_url}/", timeout=2) + if res.status_code == 200: + print("✅ API Online!") + return True + except: + pass + time.sleep(2) + return False + + def run_business_logic_tests(self): + print("\n🧪 [2/3] Rodando testes de lógica de negócio...") + + # Ajustado para bater com seu main.py: + # Passando name e data como query parameters na URL + series_name = "sensor_test_01" + series_values = [10.0, 20.0, 30.0] + + # Teste 1: Ingestão (POST /series?name=...&data=...) + # O requests.post com 'params' envia na URL como o seu FastAPI espera + res_post = requests.post( + f"{self.base_url}/series", + params={"name": series_name}, + json=series_values + ) + + t1 = res_post.status_code == 200 + print(f" - Ingestão de dados: {'✅ OK' if t1 else f'❌ ERRO ({res_post.status_code})'}") + + # Teste 2: Métricas (GET /series/{name}/metrics) + res_met = requests.get(f"{self.base_url}/series/{series_name}/metrics") + + t2 = False + if res_met.status_code == 200: + data = res_met.json() + # Verifica se as métricas calculadas pelo Pandas estão lá + if "metrics" in data and data["metrics"]["mean"] == 20.0: + t2 = True + + print(f" - Cálculo de métricas: {'✅ OK' if t2 else f'❌ ERRO ({res_met.status_code})'}") + + return t1 and t2 + + def run_latency_benchmark(self, iterations=10): + print(f"\n⚡ [3/3] Iniciando benchmark de latência ({iterations} iterações)...") + latencies = [] + + for i in range(iterations): + start = time.perf_counter() + # Teste rápido de leitura para medir latência pura + requests.get(f"{self.base_url}/") + diff = (time.perf_counter() - start) * 1000 + latencies.append(diff) + + avg_latency = statistics.mean(latencies) + p95 = statistics.quantiles(latencies, n=20)[18] + + print(f" - Latência Média: {avg_latency:.2f}ms") + print(f" - P95 (Latência de cauda): {p95:.2f}ms") + + status = "✅ DENTRO DO REQUISITO (<350ms)" if avg_latency < 350 else "❌ FORA DO REQUISITO" + print(f" - Status: {status}") + return avg_latency < 350 + +if __name__ == "__main__": + # Define o alvo (container 'api' ou localhost) + target_url = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000" + + validator = DynamoxValidator(target_url) + + if not validator.wait_for_services(): + print("❌ Falha crítica: API não respondeu.") + sys.exit(1) + + logic_ok = validator.run_business_logic_tests() + latency_ok = validator.run_latency_benchmark() + + print("\n" + "="*40) + if logic_ok and latency_ok: + print("🚀 DESAFIO DYNAMOX: PRONTO PARA SUBMISSÃO!") + else: + print("⚠️ DESAFIO COM PENDÊNCIAS. VERIFIQUE OS ERROS ACIMA.") + print("="*40) \ No newline at end of file diff --git a/solving-challenge-v1/run.sh b/solving-challenge-v1/run.sh new file mode 100755 index 000000000..b7924593a --- /dev/null +++ b/solving-challenge-v1/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "🏗️ Iniciando infraestrutura Dynamox..." +docker-compose down +docker-compose up -d --build db api + +echo "🧐 Aguardando serviços e medindo latência..." + + +docker logs -f tester +docker-compose run --rm tester \ No newline at end of file diff --git a/solving-challenge-v1/run_app.py b/solving-challenge-v1/run_app.py new file mode 100644 index 000000000..7b7a0d71e --- /dev/null +++ b/solving-challenge-v1/run_app.py @@ -0,0 +1,37 @@ +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_read_root(): + """Corrigido: Agora espera o que o seu main.py realmente retorna""" + response = client.get("/") + assert response.status_code == 200 + # O seu main.py retorna status e database, não 'message' + assert response.json() == {"status": "Online", "database": "Connected"} + +def test_create_and_read_metrics(): + """Corrigido: Envia name na URL para evitar o erro 422""" + series_name = "engine_01" + payload = [10.0, 20.0, 30.0] + + # name como query param (?name=...) e data como json body + response = client.post(f"/series?name={series_name}", json=payload) + assert response.status_code == 200 + assert response.json() == {"message": f"Series '{series_name}' stored successfully"} + + # 2. Buscar métricas + response = client.get(f"/series/{series_name}/metrics") + assert response.status_code == 200 + assert response.json()["metrics"]["mean"] == 20.0 + +def test_delete_series(): + """Valida a remoção""" + series_name = "engine_01" + response = client.delete(f"/series/{series_name}") + assert response.status_code == 200 + assert response.json() == {"message": f"Series '{series_name}' deleted"} + + # Confirma o 404 + response = client.get(f"/series/{series_name}/metrics") + assert response.status_code == 404 \ No newline at end of file