DEV Community

Cover image for Construí una solución OLAP con DuckDB y la desplegué automáticamente en la nube (sin AWS)
IKER ALBERTO SIERRA RUIZ
IKER ALBERTO SIERRA RUIZ

Posted on

Construí una solución OLAP con DuckDB y la desplegué automáticamente en la nube (sin AWS)

Las bases de datos de nuestras aplicaciones son excelentes guardando ventas una por una. Pero cuando alguien pregunta "¿cuánto vendimos por país y canal en cada trimestre, y qué categoría creció más?", esa misma base de datos empieza a sufrir.

Ese es el problema que resuelve OLAP (Online Analytical Processing). En este artículo te muestro cómo construí una solución OLAP completa con DuckDB, la convertí en un dashboard interactivo y la desplegué automáticamente en una nube pública con GitHub Actions + Hugging Face Spaces, sin usar AWS.

🔗 Demo en vivo: https://huggingface.co/spaces/TU_USUARIO_HF/olap-duckdb-analytics
💻 Repositorio: https://github.com/IkerASierraR/olap-duckdb-analytics


OLTP vs OLAP en 30 segundos

OLTP OLAP
Objetivo Registrar transacciones Analizar grandes volúmenes
Consultas INSERT/UPDATE de pocas filas GROUP BY sobre millones de filas
Almacenamiento Por filas Por columnas
Modelo Normalizado Dimensional (estrella)

Un sistema OLAP organiza los datos como un cubo: las medidas (ingresos, utilidad, unidades) se analizan desde varias dimensiones (tiempo, producto, geografía, canal). Sobre ese cubo se aplican cinco operaciones clásicas: slice, dice, roll-up, drill-down y pivot.

¿Por qué DuckDB?

Evalué ClickHouse, Apache Druid y Apache Pinot. Son excelentes, pero piden servidores, clústeres o infraestructura gestionada. DuckDB es "el SQLite del análisis":

  • 🗂️ Motor columnar y vectorizado, pensado para agregaciones.
  • 📦 Embebido: es un pip install, sin servidor.
  • 🧊 Soporta GROUPING SETS, ROLLUP y CUBE.
  • ⚡ Agrega 1 millón de filas en milisegundos.

Para un proyecto que debía ser reproducible, barato y fácil de desplegar, era la mejor opción.

Arquitectura

git push ─► GitHub Actions ─┬─ test:   pytest · build warehouse · docker build · smoke test
                            └─ deploy: huggingface_hub ─► Hugging Face Space (Docker)
                                                             │
                                  docker build: python -m olap.warehouse
                                                             │
                                  Streamlit ◄── DuckDB (sales.duckdb, solo lectura)
Enter fullscreen mode Exit fullscreen mode

Paso 1: El modelo dimensional (esquema estrella)

Generé un dataset determinístico de 1.000.000 de ventas de una cadena de retail en Perú, Chile, Colombia y México, entre 2023 y 2025. La tabla de hechos guarda las medidas; las dimensiones guardan las jerarquías:

  • dim_date: año › trimestre › mes › día
  • dim_product: categoría › subcategoría › producto
  • dim_store: país › región › ciudad › tienda
  • dim_channel: tienda física / online / marketplace

📸 [Captura: pestaña "Modelo" con el diagrama estrella]

Para que cada build produzca exactamente los mismos datos, usé hash() en lugar de random(). Así las pruebas del pipeline son reproducibles:

CREATE TABLE fact_sales AS
WITH base AS (
    SELECT i AS sale_id,
           (hash(i)     % 1000000) / 1000000.0 AS u_date,
           (hash(i * 7) % 1000000) / 1000000.0 AS u_prod
           -- ...
    FROM range(1, 1000001) t(i)
)
...
Enter fullscreen mode Exit fullscreen mode

El warehouse completo se construye en ~2 segundos.

Paso 2: El cubo OLAP con ROLLUP + CUBE

Esta es la parte central. En vez de calcular cada combinación de subtotales por separado, DuckDB las calcula todas en una sola consulta:

CREATE TABLE cube_sales AS
SELECT
    year, quarter, category, country, channel,
    GROUPING(year, quarter, category, country, channel) AS grouping_id,
    sum(revenue)  AS revenue,
    sum(profit)   AS profit,
    sum(quantity) AS units,
    count(*)      AS orders
FROM v_sales
GROUP BY ROLLUP (year, quarter), CUBE (category, country, channel);
Enter fullscreen mode Exit fullscreen mode
  • ROLLUP(year, quarter) respeta la jerarquía del tiempo: total → año → trimestre. No tiene sentido "trimestre sin año".
  • CUBE(category, country, channel) genera todas las combinaciones (2³ = 8).
  • GROUPING() indica qué columnas son subtotales.

Resultado: 1 millón de filas se resumen en 1.600 celdas pre-calculadas. En el dashboard, consultar el cubo resulta ~7x más rápido que agregar la tabla de hechos.

📸 [Captura: pestaña "Cubo pre-agregado" con la comparación de tiempos]

Paso 3: Las 5 operaciones OLAP

Toda la lógica está en olap/queries.py. Las dimensiones y medidas se validan contra una lista blanca, así la entrada del usuario nunca se concatena directamente al SQL:

HIERARCHIES = {
    "Tiempo":    ["year", "quarter_label", "year_month", "full_date"],
    "Producto":  ["category", "subcategory", "product"],
    "Geografía": ["country", "region", "city", "store"],
}

def drill(con, hierarchy, level, measure, filters=None):
    """Drill-down (level+1) / roll-up (level-1) a lo largo de una jerarquía."""
    levels = HIERARCHIES[hierarchy]
    return aggregate(con, levels[: level + 1], [measure], filters)
Enter fullscreen mode Exit fullscreen mode
Operación Qué hace En el dashboard
Slice Fija una dimensión (ej. solo 2025) Filtro de la barra lateral
Dice Sub-cubo con varios filtros (Perú + Online) Varios filtros combinados
Roll-up Sube de nivel (mes → año) Slider hacia la izquierda
Drill-down Baja de nivel (país → ciudad → tienda) Slider hacia la derecha
Pivot Rota el cubo (filas × columnas) Pestaña Pivot con mapa de calor

📸 [Captura: drill-down de Geografía hasta nivel Tienda]
📸 [Captura: Pivot Categoría × País]

También agregué una consola SQL para consultas ad-hoc. Es segura porque la conexión es de solo lectura y no tiene acceso a archivos ni a la red:

duckdb.connect(DB_PATH, read_only=True,
               config={"enable_external_access": False})
Enter fullscreen mode Exit fullscreen mode

Paso 4: Pruebas que validan la lógica OLAP

Las pruebas no se limitan a "que corra". Verifican propiedades de un cubo:

def test_rollup_is_consistent_with_drilldown(con):
    total = q.kpis(con)["revenue"]
    by_year, _  = q.drill(con, "Tiempo", 0, "revenue")
    by_month, _ = q.drill(con, "Tiempo", 2, "revenue")
    assert by_year["revenue"].sum()  == pytest.approx(total)
    assert by_month["revenue"].sum() == pytest.approx(total)

def test_cube_matches_fact_table(con):
    cube, _  = q.from_cube(con, ["country"], "revenue")
    facts, _ = q.aggregate(con, ["country"], ["revenue"])
    assert cube...  == pytest.approx(facts...)
Enter fullscreen mode Exit fullscreen mode

Si el cubo y la tabla de hechos no coinciden, el despliegue se detiene.

Paso 5: Despliegue automatizado con GitHub Actions

Cada git push a main dispara este pipeline:

jobs:
  test:
    steps:
      - run: pytest -v
      - run: python -m olap.warehouse          # construye 1M filas + cubo
      - run: docker build -t olap-duckdb .
      - name: Smoke test container
        run: |
          docker run -d -p 7860:7860 olap-duckdb
          curl -sf http://localhost:7860/_stcore/health

  deploy:
    needs: test
    steps:
      - run: python deploy/deploy_space.py
        env:
          HF_TOKEN: ${{ secrets.HF_TOKEN }}
          HF_SPACE_ID: ${{ vars.HF_SPACE_ID }}
Enter fullscreen mode Exit fullscreen mode

El script de despliegue usa huggingface_hub. Crea el Space si no existe y sube el código:

api.create_repo(space_id, repo_type="space", space_sdk="docker", exist_ok=True)
api.upload_folder(folder_path=stage, repo_id=space_id, repo_type="space")
Enter fullscreen mode Exit fullscreen mode

Paso 6: Publicado en la nube pública

Elegí Hugging Face Spaces porque es un SaaS público y gratuito que ejecuta contenedores Docker. El warehouse se construye dentro del docker build, así la app arranca con los datos listos:

FROM python:3.12-slim
RUN useradd -m -u 1000 user
USER user
WORKDIR /home/user/app
COPY --chown=user requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=user . .
RUN python -m olap.warehouse --rows 1000000
CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
Enter fullscreen mode Exit fullscreen mode

📸 [Captura: pipeline en verde en GitHub Actions]
📸 [Captura: la app corriendo en Hugging Face]

Resultados

Métrica Valor
Filas en la tabla de hechos 1.000.000
Tiempo de construcción del warehouse ~2,3 s
Celdas del cubo pre-agregado 1.600
Agregación sobre 1M de filas ~13 ms
Misma consulta desde el cubo ~2 ms
Costo de infraestructura $0

Lo que aprendí

  1. El modelado importa más que la herramienta. Un buen esquema estrella con jerarquías claras hace que el drill-down sea simplemente agregar una columna al GROUP BY.
  2. ROLLUP y CUBE son subestimados. Una consulta reemplaza docenas de agregaciones.
  3. No siempre necesitas un clúster. Para millones de filas, un motor embebido como DuckDB es suficiente y elimina toda la operación.
  4. Prueba propiedades, no solo código. "El total por año es igual al total por mes" detecta errores reales.
  5. La automatización es parte del producto. Un git push basta para probar, construir y publicar.

¿Qué sigue?

  • Cargar datos reales desde Parquet en almacenamiento de objetos.
  • Comparar con ClickHouse o Apache Pinot cuando el volumen pase de cientos de millones de filas.
  • Agregar actualizaciones incrementales del cubo.

Si te sirvió, deja una ⭐ en el repositorio y cuéntame en los comentarios qué motor OLAP usas tú. 🦆

Top comments (1)

Collapse
 
_firelinks profile image
Mike Dabydeen •

El paso que yo agregaría va después de upload_folder. Hoy deploy_space.py imprime "Deployed" cuando termina la subida, pero en ese momento el Space apenas empieza a construir su propia imagen con el Dockerfile que le enviaste. La imagen que pasó tu smoke test en GitHub Actions no es la que atiende a los usuarios: es una segunda construcción que nadie revisó, y si falla en Hugging Face, el pipeline ya quedó en verde.

Lo cerraría con una espera corta al final del job de deploy: consultar get_space_runtime de huggingface_hub hasta que stage pase de BUILDING a RUNNING, fallar si no llega en unos minutos, y luego repetir el mismo curl a /_stcore/health contra la URL .hf.space del Space. Como ya fijaste las versiones en requirements.txt y generas los datos con hash(), las dos construcciones deberían dar el mismo resultado, así que esa verificación cuesta poco y confirma que lo publicado es lo que probaste. Un detalle menor: el enlace de la demo todavía dice TU_USUARIO_HF.