DuckDB

Запланированные пайплайны DuckDB без базы данных перед вашей базой данных.

DuckDB работает внутри процесса и без сервера — именно поэтому у него нет ни планировщика, ни повторов, ни истории запусков. Dagu добавляет этот слой из одного бинарного файла, так что стек остаётся двумя исполняемыми файлами и каталогом данных.

Один бинарник оркестрирует другой, без базы данных в эксплуатации
max_active_runs обеспечивает правило единственного писателя DuckDB
Курсоры переживают перезапуск процесса при инкрементальной загрузке
Работает рядом с данными, в том числе в закрытых сетях
01

Встраиваемость означает отсутствие планировщика по замыслу

DuckDB выполняется внутри вашего процесса. Нет демона, нет порта и нет ничего, что ждало бы момента выполнить запрос в три часа ночи. Это замысел, а не упущение, и он означает, что расписание должно приходить извне.

  • Пайплайн DuckDB — это обычно вызов CLI, из-за чего cron становится ответом по умолчанию, на котором большинство команд и останавливается
  • cron даёт только выполнение: ни повтора при таймауте S3, ни истории, ни сигнала, когда прошлой ночью тихо ничего не произошло
  • Расширение cron от сообщества планирует внутри процесса, поэтому расписание умирает вместе с ним и не оставляет истории запусков
02

Не перечёркивайте причину, по которой выбрали DuckDB

Привлекательность DuckDB в том, что нет кластера, нет сервера и нет счёта за хранилище. Тяжёлый оркестратор перед ним возвращает всё это обратно. Airflow требует планировщик, базу метаданных и Python-фреймворк DAG. Kestra требует базу JDBC, объектное хранилище и четыре компонента. Dagu — это один бинарник с состоянием в файлах.

  • Добавлять Postgres ради планирования бессерверного аналитического движка — странный обмен
  • Определения рабочих процессов остаются в git рядом с тем SQL, который они выполняют, а не в метаданных другой платформы
  • Весь стек помещается на одном хосте, и обычно это тот же хост, где уже лежат данные
03

Единственный писатель — это задача планирования

Стандартный производственный совет для DuckDB — защищаться от пересекающихся записей блокировкой на уровне ОС, потому что база принимает только одного писателя. Когда эта защита состоит из cron и lock-файла, до ошибки остаётся один скрипт. Объявление её на уровне рабочего процесса устраняет весь класс дефектов.

  • max_active_runs, равный единице, означает, что медленный запуск задержит следующий, а не повредит базу
  • resources.limits.memory ограничивает запуск до того, как крупное соединение утянет за собой хост
  • retry_policy покрывает по-настоящему временные сбои, например таймаут объектного хранилища посреди чтения

Это ограничивает параллельные запуски данного рабочего процесса. DuckDB не становится многопользовательским на запись: другой процесс, пишущий в тот же файл, остаётся вашей заботой.

Ночная агрегация по Parquet в объектном хранилище
# 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

Инкрементальной загрузке нужен курсор, переживающий процесс

Перезагрузка всего при каждом запуске превращает быстрый локальный запрос в медленный и дорогой. Инкрементальной загрузке нужно помнить, где закончился последний успешный запуск, и эта память должна пережить перезапуск без базы данных, которая бы её хранила.

  • Dagu сохраняет небольшой JSON-курсор между запусками, поэтому каждый запуск читает только ещё не загруженное окно
  • Курсор сохраняется после успеха шага загрузки, поэтому неудачный запуск оставляет его нетронутым, а следующий повторяет то же окно
  • Ограничение окна с обеих сторон исключает гонку со строками, поступающими во время выполнения
Инкрементальное добавление с сохраняемой отметкой
# 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

Где эта связка слабее

Dagu планирует DuckDB. Он не меняет того, чем DuckDB является, и есть нагрузки, для которых связка — неверный ответ.

  • DuckDB одноузловой и однопишущий. Если несколько сервисов должны писать одновременно, ни один оркестратор этого не исправит
  • Он не рассчитан на частые мелкие записи; это по-прежнему задача PostgreSQL
  • Если вы уже эксплуатируете Airflow или хранилище с собственным планировщиком, добавлять второй оркестратор ради одного пайплайна редко оправдано

FAQ

Practical questions before adopting

Есть ли исполнитель или плагин для DuckDB?

Нет, и это осознанно. DuckDB поставляет CLI одним бинарным файлом, который уже делает работу, поэтому Dagu запускает его как обычный командный шаг. Отдельный исполнитель добавил бы лишь слой, который нужно синхронизировать с релизами DuckDB, давая меньше, чем даёт сам CLI.

Как не дать двум запускам повредить файл базы?

Установите max_active_runs в единицу на уровне рабочего процесса. Ещё идущий запуск блокирует следующий по расписанию вместо открытия второго писателя — это декларативная версия lock-файла, который документация DuckDB предлагает построить самостоятельно. Правило действует только на запуски этого процесса, поэтому другие процессы держите подальше от того же файла.

Что происходит, когда большому запросу не хватает памяти?

Задайте resources.limits.memory на уровне рабочего процесса, чтобы запуск был ограничен до того, как утянет хост, и добавьте шагу политику повторов для временных, а не структурных сбоев. Запрос, которому нужно больше памяти, чем есть у хоста, следует переписать, а не повторять.

У DuckDB есть расширение cron от сообщества. Почему не использовать его?

Оно планирует внутри процесса DuckDB, поэтому расписание существует лишь пока живёт этот процесс. Нет ни истории запусков, ни повторов, ни оповещения об ошибке, ни чего-либо, что можно посмотреть наутро. Это подходит долгоживущему встроенному приложению, а не пакетной работе на сервере.

Может ли DuckDB читать из S3 в запланированном запуске?

Да, через расширение httpfs — именно так большая часть запланированной работы с DuckDB читает Parquet без отдельного шага извлечения. Поскольку объектное хранилище является сетевой зависимостью, именно этому шагу стоит задать политику повторов.

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.