Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions solving-challenge-v1/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
ENV/

# Logs
*.log

# Tests
.pytest_cache/
.coverage

# Databases
*.sqlite3
26 changes: 26 additions & 0 deletions solving-challenge-v1/README.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions solving-challenge-v1/backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
74 changes: 74 additions & 0 deletions solving-challenge-v1/backend/main.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions solving-challenge-v1/backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
fastapi
uvicorn
pandas
pytest
httpx
sqlalchemy
psycopg2-binary
42 changes: 42 additions & 0 deletions solving-challenge-v1/backend/test_main.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions solving-challenge-v1/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions solving-challenge-v1/health_and_perf.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions solving-challenge-v1/run.sh
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions solving-challenge-v1/run_app.py
Original file line number Diff line number Diff line change
@@ -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