DuckDB

Pipelines DuckDB agendados, sem colocar uma base de dados à frente da sua base de dados.

O DuckDB corre dentro do processo e sem servidor, que é exatamente a razão pela qual não tem agendador, repetições nem histórico. O Dagu acrescenta essa camada operacional a partir de um único binário, pelo que a stack continua a ser dois executáveis e um diretório de dados.

Um binário a orquestrar um binário, sem base de dados para operar
max_active_runs garante a regra de escritor único do DuckDB
Os cursores sobrevivem a reinícios nas cargas incrementais
Corre junto aos dados, incluindo em redes fechadas
01

Ser embebido significa não ter agendador, por desenho

O DuckDB corre dentro do seu processo. Não há daemon, não há porta e não há nada à espera para executar uma consulta às três da manhã. É o propósito do desenho e não um descuido, e implica que o agendamento venha de fora.

  • Um pipeline DuckDB é normalmente uma invocação de CLI, o que torna o cron a resposta por omissão onde a maioria das equipas fica
  • O cron dá apenas execução: nenhuma repetição quando o S3 expira, nenhum histórico e nenhum sinal quando a noite passada silenciosamente não fez nada
  • A extensão cron da comunidade agenda dentro do processo, pelo que o agendamento morre com ele e não deixa histórico
02

Não desfaça a razão pela qual escolheu o DuckDB

O apelo do DuckDB é não haver cluster, servidor nem fatura de data warehouse. Colocar à frente um orquestrador pesado devolve tudo isso. O Airflow quer um agendador, uma base de metadados e uma framework de DAG em Python. O Kestra quer uma base JDBC, armazenamento de objetos e quatro componentes. O Dagu é um binário com estado em ficheiros.

  • Acrescentar Postgres para agendar um motor analítico sem servidor é uma troca estranha
  • As definições de workflow ficam no git ao lado do SQL que executam, e não nos metadados de outra plataforma
  • Toda a stack cabe num host, normalmente o mesmo onde os dados já estão
03

O escritor único é um problema de agendamento

O conselho habitual de produção para o DuckDB é proteger contra escritas sobrepostas com um bloqueio ao nível do sistema operativo, porque a base aceita um único escritor. Quando essa proteção é cron mais um ficheiro de bloqueio, está a um script de estar errada. Declará-la no workflow elimina a classe de erro inteira.

  • max_active_runs a 1 faz com que uma execução lenta atrase a seguinte em vez de corromper a base
  • resources.limits.memory limita uma execução antes de um join grande levar o host consigo
  • retry_policy cobre as falhas genuinamente transitórias, como o armazenamento de objetos expirar a meio da leitura

Isto limita execuções concorrentes deste workflow. Não torna o DuckDB multi-escritor: outro processo a escrever no mesmo ficheiro continua a ser problema seu.

Agregação noturna sobre Parquet em armazenamento 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

As cargas incrementais precisam de um cursor que sobreviva ao processo

Recarregar tudo em cada execução é o que transforma uma consulta local rápida numa lenta e cara. Uma carga incremental precisa de se lembrar onde terminou a última execução bem sucedida, e essa memória tem de sobreviver a um reinício sem uma base de dados que a guarde.

  • O Dagu guarda um pequeno cursor JSON entre execuções, para que cada execução leia apenas a janela ainda não carregada
  • O cursor é gravado depois de o passo de carga ter êxito, pelo que uma execução falhada o deixa intacto e a seguinte repete a mesma janela
  • Delimitar a janela nas duas pontas evita competir com linhas que chegam durante a execução
Acréscimo incremental com marca de água 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

Onde esta combinação é a escolha mais fraca

O Dagu agenda o DuckDB. Não altera aquilo que o DuckDB é, e há cargas em que a combinação é a resposta errada.

  • O DuckDB é de nó único e escritor único. Se vários serviços precisarem de escrever em simultâneo, nenhum orquestrador resolve
  • Não foi feito para escritas pequenas e frequentes; isso continua a ser função do PostgreSQL
  • Se já opera Airflow ou um warehouse com agendador próprio, acrescentar um segundo orquestrador por causa de um pipeline raramente compensa

FAQ

Practical questions before adopting

Existe um executor ou plugin de DuckDB?

Não, e é deliberado. O DuckDB disponibiliza uma CLI num único binário que já faz o trabalho, por isso o Dagu executa-a como um passo de comando comum. Um executor dedicado só acrescentaria uma camada a manter ao ritmo das versões do DuckDB, oferecendo menos do que a própria CLI.

Como impeço que duas execuções corrompam o ficheiro da base?

Defina max_active_runs a 1 no workflow. Uma execução ainda a decorrer bloqueia a seguinte em vez de abrir um segundo escritor, que é a versão declarativa do ficheiro de bloqueio que a documentação do DuckDB manda construir. Só governa execuções desse workflow, por isso mantenha outros processos longe do mesmo ficheiro.

O que acontece quando uma consulta grande esgota a memória?

Defina resources.limits.memory no workflow para limitar a execução antes de levar o host consigo, e dê ao passo uma política de repetição para falhas transitórias e não estruturais. Uma consulta que precisa de mais memória do que o host tem deve ser reescrita, não repetida.

O DuckDB tem uma extensão cron da comunidade. Porque não usá-la?

Agenda dentro do processo do DuckDB, pelo que o agendamento só existe enquanto esse processo viver. Não há histórico, nem repetições, nem alerta quando falha, nem nada para ver na manhã seguinte. Serve uma aplicação embebida de vida longa, não trabalho em lote num servidor.

O DuckDB consegue ler do S3 numa execução agendada?

Sim, através da extensão httpfs, que é como a maior parte do trabalho agendado com DuckDB lê Parquet sem um passo de extração separado. Como o armazenamento de objetos é uma dependência de rede, é precisamente esse o passo que merece uma política de repetição.

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.