DuckDB

Pipelines de DuckDB programados, sin poner una base de datos delante de tu base de datos.

DuckDB se ejecuta dentro del proceso y sin servidor, que es exactamente por lo que no tiene planificador, ni reintentos, ni historial. Dagu añade esa capa operativa desde un único binario, así el stack sigue siendo dos ejecutables y un directorio de datos.

Un binario que orquesta otro binario, sin base de datos que operar
max_active_runs garantiza la regla de escritor único de DuckDB
Los cursores sobreviven a reinicios en cargas incrementales
Se ejecuta junto a los datos, incluso en redes cerradas
01

Embebido significa sin planificador, por diseño

DuckDB se ejecuta dentro de tu proceso. No hay demonio, ni puerto, ni nada esperando para lanzar una consulta a las tres de la madrugada. Es el propósito del diseño, no un descuido, y significa que la planificación tiene que venir de fuera.

  • Un pipeline de DuckDB suele ser una invocación de CLI, lo que convierte a cron en la respuesta por defecto donde la mayoría de equipos se detiene
  • cron sólo da ejecución: ningún reintento cuando S3 agota el tiempo, ningún historial y ninguna señal cuando anoche no hizo nada en silencio
  • La extensión cron de la comunidad planifica dentro del proceso, así que la planificación muere con él y no deja historial
02

No deshagas la razón por la que elegiste DuckDB

El atractivo de DuckDB es que no hay clúster, ni servidor, ni factura de almacén de datos. Poner delante un orquestador pesado devuelve todo eso. Airflow quiere un planificador, una base de metadatos y un framework de DAG en Python. Kestra quiere una base JDBC, almacenamiento de objetos y cuatro componentes. Dagu es un binario con estado en ficheros.

  • Añadir Postgres para planificar un motor analítico sin servidor es un intercambio extraño
  • Las definiciones de workflow permanecen en git junto al SQL que ejecutan, y no en los metadatos de otra plataforma
  • Todo el stack cabe en un host, que suele ser el mismo donde ya viven los datos
03

El escritor único es un problema de planificación

El consejo estándar en producción para DuckDB es protegerse de escrituras solapadas con un bloqueo a nivel de sistema operativo, porque la base admite un solo escritor. Cuando esa protección es cron más un fichero de bloqueo, está a un script de estar equivocada. Declararla en el workflow elimina toda la clase de fallo.

  • max_active_runs en 1 hace que una ejecución lenta retrase la siguiente en lugar de corromper la base
  • resources.limits.memory limita una ejecución antes de que un join grande se lleve el host por delante
  • retry_policy cubre los fallos que sí son transitorios, como que el almacenamiento de objetos expire a mitad de lectura

Esto limita las ejecuciones concurrentes de este workflow. No convierte a DuckDB en multiescritor: otro proceso escribiendo en el mismo fichero sigue siendo cosa tuya.

Agregación nocturna sobre Parquet en almacenamiento de objetos
# duckdb-nightly-rollup.yaml
schedule: "0 3 * * *"
max_active_runs: 1

resources:
  limits:
    memory: "8Gi"

steps:
  - id: rollup
    action: duckdb@v1
    with:
      database: /data/analytics.duckdb
      query: |
        INSTALL httpfs; LOAD httpfs;
        CREATE OR REPLACE TABLE daily_sales AS
          SELECT order_date, region, sum(amount) AS amount
          FROM read_parquet('s3://warehouse/raw/orders/*.parquet')
          GROUP BY 1, 2;
    retry_policy:
      limit: 2
      interval_sec: 300

  - id: export
    action: duckdb@v1
    with:
      database: /data/analytics.duckdb
      query: |
        COPY daily_sales TO '/data/export/daily_sales.parquet' (FORMAT parquet);
    depends: rollup

  - id: count_rows
    action: duckdb@v1
    with:
      database: /data/analytics.duckdb
      readonly: true
      query: SELECT count(*) AS row_count FROM daily_sales;
    depends: export

  - id: verify
    env:
      - COUNT_JSON: ${steps.count_rows.outputs.result}
    run: test "$(printf '%s\n' "$COUNT_JSON" | jq -r '.[0].row_count')" -gt 0
    depends: count_rows

handler_on:
  failure:
    run: /opt/analytics/notify-failure.sh

mail_on:
  failure: true
04

Las cargas incrementales necesitan un cursor que sobreviva al proceso

Recargarlo todo en cada ejecución es lo que convierte una consulta local rápida en una lenta y cara. Una carga incremental necesita recordar dónde terminó la última ejecución con éxito, y esa memoria debe sobrevivir a un reinicio sin una base de datos donde guardarla.

  • Dagu guarda un pequeño cursor JSON entre ejecuciones, de modo que cada una lee sólo la ventana que aún no ha cargado
  • El cursor se guarda tras el éxito del paso de carga, así que una ejecución fallida lo deja intacto y la siguiente reintenta la misma ventana
  • Acotar la ventana por ambos extremos evita competir con filas que llegan mientras la ejecución sigue en marcha
Adición incremental con marca de agua persistida
# duckdb-incremental-load.yaml
schedule: "*/15 * * * *"
max_active_runs: 1

steps:
  - id: load_cursor
    action: state.get
    output: CURSOR
    with:
      key: cursors/events-loaded-through
      default:
        loaded_through: "2026-01-01T00:00:00Z"

  - id: window
    run: |
      printf 'since=%s\n' "$(printf '%s\n' "$CURSOR" | jq -r .value.loaded_through)" >> "$DAGU_OUTPUT_FILE"
      printf 'until=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$DAGU_OUTPUT_FILE"
    outputs:
      - name: since
      - name: until
    depends: load_cursor

  - id: append_new_events
    action: duckdb@v1
    with:
      database: /data/analytics.duckdb
      query: |
        INSTALL httpfs; LOAD httpfs;
        INSERT INTO events
          SELECT * FROM read_parquet('s3://warehouse/events/*.parquet')
          WHERE ingested_at >  TIMESTAMP '${steps.window.outputs.since}'
            AND ingested_at <= TIMESTAMP '${steps.window.outputs.until}';
    depends: window
    retry_policy:
      limit: 3
      interval_sec: 60

  - id: save_cursor
    action: state.set
    with:
      key: cursors/events-loaded-through
      value:
        loaded_through: "${steps.window.outputs.until}"
    depends: append_new_events

mail_on:
  failure: true
05

Dónde esta combinación es la opción más débil

Dagu planifica DuckDB. No cambia lo que DuckDB es, y hay cargas donde la combinación es la respuesta equivocada.

  • DuckDB es de un solo nodo y un solo escritor. Si varios servicios necesitan escribir a la vez, ningún orquestador lo arregla
  • No está pensado para escrituras pequeñas y frecuentes; eso sigue siendo trabajo de PostgreSQL
  • Si ya operas Airflow o un almacén con su propio planificador, añadir un segundo orquestador por un pipeline rara vez compensa

FAQ

Practical questions before adopting

¿Hay un ejecutor o plugin de DuckDB?

No, y es deliberado. DuckDB distribuye una CLI de un solo binario que ya hace el trabajo, así que Dagu la ejecuta como un paso de comando normal. Un ejecutor dedicado sólo añadiría una capa que mantener al ritmo de las versiones de DuckDB, ofreciendo menos que la propia CLI.

¿Cómo evito que dos ejecuciones corrompan el fichero de la base?

Pon max_active_runs a 1 en el workflow. Una ejecución en curso bloquea la siguiente programada en lugar de abrir un segundo escritor, que es la versión declarativa del fichero de bloqueo que la documentación de DuckDB te pide construir. Sólo rige las ejecuciones de ese workflow, así que mantén otros procesos lejos del mismo fichero.

¿Qué pasa si una consulta grande se queda sin memoria?

Define resources.limits.memory en el workflow para limitar la ejecución antes de que se lleve el host, y da al paso una política de reintentos para los fallos transitorios, no los estructurales. Una consulta que necesita más memoria de la que tiene el host hay que reescribirla, no reintentarla.

DuckDB tiene una extensión cron de la comunidad. ¿Por qué no usarla?

Planifica dentro del proceso de DuckDB, así que la planificación existe sólo mientras ese proceso viva. No hay historial, ni reintentos, ni aviso cuando algo falla, ni nada que mirar a la mañana siguiente. Encaja con una aplicación embebida de larga vida, no con trabajo por lotes en un servidor.

¿Puede DuckDB leer de S3 en una ejecución programada?

Sí, mediante la extensión httpfs, que es como la mayoría del trabajo programado con DuckDB lee Parquet sin un paso de extracción aparte. Como el almacenamiento de objetos es una dependencia de red, ése es justo el paso al que merece la pena darle una política de reintentos.

Next step

Start with one workflow.

Install Dagu, move one script that runs on cron today into YAML, and decide from a real run history.