Librería solución escrita en Python para prueba técnica de NeuralWorks.
Consta de un servicio tipo API REST, una cola de procesamiento en Celery y un websocket para notificar cuando el procesamiento se ha completado.
Se requiere apoyar con la creación de turnos entre personas con viajes similares. Se ha proporciado un archivo .csv
con una muestra de los datos a considerar. Se ha requerido:
- Procesos automatizados para ingerir y almacenar datos bajo demanda
- Se requiere agrupar los viajes que son similares en terminos de origen, destino y hora del día.
- Servicio que proporcione:
- Promedio semanal de la cantidad de viajes para un área definida por un bounding box y la región.
- Informe de la ingesta de datos sin utilizar polling.
- Solución escalabre a 100 millones de entradas.
- Solución escrita en Python usando una base de datos sql.
- Incluir contenedores en la solución, dibujar como configurar la aplicación en GCP
Dada las instrucciones del challenge, se consideraron los siguientes supuestos:
- Los datos pueden cargarse más de una vez (se puede inicializar con un
.csv
pero se espera poder almacenar más datos en el futuro).- Bajo este supuesto, se implementó dos formas de poder agregar nuevos datos: A través de archivos (
.csv
con el esquema propuesto en la muestra proporcionada), y víaPOST
a unaAPI
.
- Bajo este supuesto, se implementó dos formas de poder agregar nuevos datos: A través de archivos (
- Se asume como servicio una API REST:
- Así la petición al promedio semanal se hace a través de la API, y las notificaciones respecto a la ingesta de datos se hacen a través de un websocket (así evitamos polling).
-
🟡 Procesos automatizados para ingerir y almacenar datos bajo demanda:
- El usuario debe gatillar el proceso mediante una petición
POST
con la ubicación del archivo, esto es intencional puesto que se busca imitar el comportamiento del programa ante un evento de procesar un archivo con su ubicación. Es fácilmente escalable a la nube usandoPub/Sub Notifications
deGCP
. Así gatillamos unCloud Function
con el procesado cuando se cree un nuevo archivo en el bucket. - No se realizó la agrupación. Esto fue en honor al tiempo debido a que no alcancé a pensar en una forma de almacenar la agrupación en la base de datos. Llegué a la siguiente consulta:
-- Esta consulta retorna la agrupación solicitada, es decir, con esto puedo saber la cantidad de viajes similares agrupados por una cierta distancia a una cierta hora, pero pierdo la información de CUALES son los viajes agrupados. -- @Distance: Distancia máxima en la que los viajes deben estar para considerarse similares -- @Timelapse: Tiempo máximo en lo que los viajes pueden estar distanciados para considerarse similares -- Por ejemplo, podríamos considerar que un viaje es similar a otro si -- @Distance es 0.5 (KM) y @Timelapse son 300 (segundos) SELECT region, ST_ClusterWithin(v_trip::geometry, @Distance) FROM ( SELECT id, region, origin - destination AS v_trip, 'timestamp', source, SUM(nearest) OVER (ORDER BY t ASC) AS time_group FROM ( SELECT dts.*, CASE WHEN dt > @Timelapse THEN 1 ELSE 0 END AS nearest FROM ( SELECT *, 'timestamp' AS t, lag('timestamp') OVER (ORDER BY 'timestamp' ASC) AS t_prev, extract(epoch FROM 'timestamp' - lag('timestamp') OVER (ORDER BY 'timestamp' ASC)) AS dt FROM travels ) dts ) AS nearest_group ) AS t_group GROUP BY region, time_group ORDER BY region
Estuve manejando algunas soluciones como la creación de una vista materializada en postgres que se fuera actualizando cada vez que se insertara un viaje, pero hacer esto supone un costo muy alto y no soluciona el problema de saber cuales fueron los viajes agrupados.
Por lo que decidí no implementarlo, sin embargo lo adjunto acá.
- El usuario debe gatillar el proceso mediante una petición
-
🟢 Servicio que proporcionen el promedio semanal de la cantidad de viajes para un área definida por un bounding box y la región, y un informe de la ingesta de datos sin utilizar polling.
- Realizado con exito via solución tipo
API
en conjunto conWebsockets
para la notificación de la ingesta de datos.
- Realizado con exito via solución tipo
-
🟢 Solución escalabre a 100 millones de entradas.
- El procesamiento de datos (ingesta de datos) puede ser colocada en una
Cloud Function
, a su vez que laAPI
. Por lo que soluciona el problema del escalado.
- El procesamiento de datos (ingesta de datos) puede ser colocada en una
-
🟢 Solución escrita en Python usando una base de datos sql.
-
🟢 Incluir contenedores en la solución, dibujar como configurar la aplicación en GCP
- La descripción de los contenedores se encuentra más abajo, se adjunta a continuación diagrama de configuración en GCP:
Se debe contar con docker
y docker compose
instalados en la máquina a ejecutar.
En este repositorio se encuentran todos los archivos para poder ejecutar la aplicación.
Solo se debe crear un archivo .env
con la siguiente información:
BROKER_URL="redis://redis:6379/0"
POSTGRES_HOST="postgres"
POSTGRES_USER="cualquier_usuario"
POSTGRES_PASSWORD="cualquier_password"
POSTGRES_DB="cualquier_nombre_para_la_db"
Y guardarlo en la carpeta raiz del repositorio.
Finalmente levantar el proyecto con docker compose up -d
La aplicación sigue la siguiente estructura de carpetas:
├── Dockerfile.backend <- Dockerfile para la API
├── Dockerfile.worker <- Dockerfile para Celery
├── README.md <- Esta documentación
├── client.py <- Cliente de prueba para websocket
├── docker-compose.yaml <- Manifiesto para levantar la aplicación
├── fileupload <- Carpeta donde se almacenan los archivos .csv que se quieren procesar
├── init.sql <- Inicialización de la base de datos
├── main.py <- Entrypoint de la API
├── optimized_shifts <- Codigo fuente de la API
│ ├── celery <- Configuración de Celery para el procesamiento
│ ├── crud <- CRUD para la base de datos
│ ├── dependencies <- Carpeta con utilidades para mantener estado de la API
│ ├── handlers <- Carpeta que maneja estados de error de la API
│ ├── lifespan.py <- Maneja el startup y el shutdown de la API
│ ├── routes <- Rutas de la API
│ ├── schemas <- Validación de datos de la API
│ ├── state.py <- Definición de estados globales
│ └── ws.py <- Websocket
├── poetry.lock
├── pyproject.toml <- Se usó poetry como gestor de dependencias
├── tests <- Carpeta con los tests de la API y del procesado
│ ├── conftest.py
│ ├── procesor
│ └── routes
La solución contempla una API para consultar las estadisticas solicitadas, una cola de procesamiento (celery) para procesar los archivos y almacenarlos en la base de datos, y un websocket para el notificado del procesamiento.
Específicamente la solución emplea los siguientes servicios:
- API: Proporciona un endpoint para consultar el promedio semanal solicitado, a su vez que proporciona un endpoint para subir archivos via
.csv
(que delega al servicio de Celery retornando una ID para seguir el estado del procesamiento) y un websocket en el que recibiremos notificaciones del estado de los procesamientos (podemos seguirlo a través de la ID descrita anteriormente). - Celery: Cola de procesamiento, recibe tareas desde la API para buscar, descargar y procesar los archivos requeridos, subiendo los datos a Postgres y enviando una notificación del estado del procesamiento a una cola Redis para su distribución.
- Redis: Base de datos key-value que es utilizada como sistema de publicador / subscriptor, en el que Celery publicará los estados de los procesamientos y el websocket se subscribirá para notificar a todos los usuarios conectados a el sobre el estado de procesamiento.
- Postgresql: Base de datos elegida para el desafío, dado que los datos a procesar son geométricos, Postgresql tiene una buena comunidad con Postgis para este tipo de datos.
A continuación se describen en detalle cada uno de ellos:
Este servicio proporciona los siguientes endpoints:
-
[GET] /api/v1/trips/stats
: Obtiene el promedio semanal de la cantidad de viajes para un bounding box y región definidas. Tiene como parámetros obligatorios:region
: Nombre de la región (Ejemplo:Paris
)nortest
: Esquina superior derecha del bounding box, en formatox,y
(Ejemplo:1.25,3.43
)southest
: Esquina inferior izquierda del bounding box, en formatox,y
(Ejemplo:2.34,3.45
)
Ejemplo en python para petición:
import requests from urllib.parse import urlencode q = { "nortest": "3.5,0.5", "southest": "3,0", "region": "Paris", } response = requests.get("http://localhost:8000/api/v1/trips/stats?" + urlencode(q)) response.json() >>> { "mean": 3 }
-
[POST] /api/v1/trips
: Creación de viajes, esto puede ser mediante JSON o la url de un archivo.csv
con el formato de la muestra proporcionada. Este endpoint espera los siguientes datos:data_type
: Tipo de dato a enviar, puede serjson
(insertará los datos proporcionados en el campodata
),gcp
(Ordenará a celery la busqueda de un archivo.csv
enCloud Storage
usando la ruta proporcionada endata
) omocked
(Simula sergcp
pero en vez de ir a la nube a buscar el archivo, lo busca en disco)data
: URL o JSON con formato de puntos especificados a continuación.
Ejemplo de petición usando
mocked
import requests q = { "data_type": "mocked", "data": "/app/files/trips.csv" } response = requests.post("http://localhost:8000/api/v1/trips", json=q) response.json() >>> { "message": "Task is processing: 877439ae-df3b-47e1-b2ff-ea00f70d9077", "metadata": "PENDING" }
Ejemplo de petición usando
json
import requests q = { "data_type": "json", "data": [ { "region": "Paris", "origin": [ 1.0, 1.0 ], "destination": [ 1.5, 1.0 ], "timestamp": "2023-01-01 00:00:00", "source": "from_api_point" } ] } response = requests.post("http://localhost:8000/api/v1/trips", json=q) response.json() >>> { "message": "Points inserted" }
-
[WS] /api/v1/trips/live
: Websocket que emite mensajes cuando la cola de procesamiento ha terminado de procesar.- Un cliente conectado recibirá mensajes cada vez que un procesado de archivos gatillado por una petición
POST
a la API ha terminado.
Ejemplo para escuchar el websocket usando python
import json import asyncio import websockets async def hello(): uri = "ws://localhost:8000/api/v1/trips/live" async with websockets.connect(uri) as websocket: while True: message = json.loads(await websocket.recv()) if message["type"] == "ping": # Para detectar si seguimos conectados await websocket.send(json.dumps({"type": "pong"})) continue elif message["type"] == "notification": print(message["data"]) if __name__ == "__main__": asyncio.run(hello())
Si hacemos una petición
POST
de tipomocked
ogcp
como en el ejemplo del punto anterior, recibiremos un mensaje con la ID de la tarea de procesamiento (877439ae-df3b-47e1-b2ff-ea00f70d9077
en el ejemplo anterior), por lo que una vez que esté listo, recibiremos en el websocket el siguiente mensaje:{ "task_id": "877439ae-df3b-47e1-b2ff-ea00f70d9077", "status": "DONE", "message": "Succesfully inserted data to postgis" }
- Un cliente conectado recibirá mensajes cada vez que un procesado de archivos gatillado por una petición
Servicio que descarga, lee y procesa un archivo .csv
.
Para esto:
- Descarga el archivo solicitado:
- Si la petición fue de tipo
mocked
, buscará en disco el archivo, usando como ruta lo proporcionado endata
- Si la petición fue de tipo
gcp
, buscará en el bucket de GCP en función de la ruta proporcionada endata
(Este no está implementado, se utilizamocked
en su lugar para mostrar un propotipo de lo que se puede hacer) - Notese que es facil agregar más tipos de procesamiento, como puede ser Amazon S3 u otros.
- Si la petición fue de tipo
- Lee el CSV y lo procesa:
- Para esto convierte las columnas geométricas (
origin_coord
,destination_coord
) a floats y parsea la columnadatetime
a, en efecto, undatetime
- Luego queda propuesto una agrupación para almacenar los clusteres en Postgres, en honor al tiempo no se efectuó dicha agrupación y solo se insertan los datos en una tabla similar al formato de la muestra.
- Para esto convierte las columnas geométricas (
- Inserta los datos en Postgres.
- Publica en
Redis
el resultado del procesamiento.
Base de datos key-value en memoria, escogida para implementar sistema pub/sub, en el que Celery
publicará los resultados de los procesamientos, y un websocket
actuará como subscriptor para notificar a los usuarios sobre el estado del procesamiento.
Lo anterior se puede resumir en el siguiente diagrama:
Para esto debes levantar la aplicación como está descrito en el apartado de Levantamiento de la app
, se expondrá la API
en el puerto 8000
, por lo que puedes hacer consultas en http://localhost:8000/api/v1
Guarda un archivo .csv
con el mismo formato que contenía el archivo de muestra en fileupload
, esto hará que el archivo esté ubicado dentro del contenedor en /app/files
, por ejemplo, si guardaste un archivo prueba.csv
(es decir /fileupload/prueba.csv
) entonces la ruta dentro del contenedor será /app/files/prueba.csv
Ahora envía una petición POST
a http://localhost:8000/api/v1/trips
con los siguientes datos:
{
"data_type": "mocked",
"data": "/app/data/prueba.csv"
}
Recibirás una respuesta con la id
de la tarea de procesamiento (es decir, tu archivo ahora se está procesando y guardando en postgres)
Si estás conectado al websocket como fue descrito anteriormente, recibirás una notificación en cuanto el procesamiento haya finalizado :)
Basta enviar una petición POST
a http://localhost:8000/api/v1/trips
con los siguientes datos:
{
"data_type": "json",
"data": [
{
"region": "nombre de la región",
"origin": [
1.0,
1.0
],
"destination": [
1.5,
1.0
],
"timestamp": "YYYY-mm-dd HH:MM:SS",
"source": "similar a datasource"
}
]
}
Donde origin
y destination
es una tupla de dos floats (x, y). Notese que se está enviando un arreglo, por lo que podemos mandar más de un viaje si se quisiera
Recibiras una respuesta con la confirmación de que los datos fueron almacenados en la base de datos.
Basta enviar una petición GET
a http://localhost:8000/api/v1/trips/stats
con los parámetros definidos anteriormente, recibirás una respuesta con el promedio si es que existe, o None
en el caso de que no hayan datos.