DuckDB

DuckDB の前にデータベースを置かずに、定期実行する。

DuckDB はプロセス内で動きサーバーを持ちません。だからスケジューラもリトライも実行履歴もありません。Dagu は単一バイナリでその運用層を足すので、構成は実行ファイル 2 つとデータディレクトリのままです。

単一バイナリが単一バイナリを回す。運用する DB なし
max_active_runs が DuckDB の単一ライター制約を担保する
差分ロード用のカーソルがプロセス再起動をまたいで残る
データのある場所で動く。閉域ネットワークでも
01

組み込み型である以上、スケジューラは持たない

DuckDB は呼び出し側のプロセスの中で動きます。デーモンもポートもなく、深夜 3 時にクエリを実行するために待っているものが存在しません。これは設計上の意図であって欠陥ではなく、スケジュールは外から与えるしかないということです。

  • DuckDB のパイプラインは実質 CLI の呼び出しなので、既定の答えが cron になり、多くのチームがそこで止まります
  • cron が与えるのは実行だけです。S3 がタイムアウトしてもリトライせず、履歴も残らず、昨夜静かに何もしなかったことに気づく手段もありません
  • コミュニティの cron 拡張はプロセス内でスケジュールするため、プロセスが終わればスケジュールも消え、実行履歴も残りません
02

DuckDB を選んだ理由を打ち消さない

DuckDB の利点はクラスタもサーバーもデータウェアハウスの請求書もないことです。その前に重いオーケストレータを置けば、全部戻ってきます。Airflow はスケジューラとメタデータ DB と Python のフレームワークを要求します。Kestra は JDBC データベースとオブジェクトストレージと 4 つのコンポーネントを要求します。Dagu は状態をファイルに持つ単一バイナリです。

  • サーバーレスな分析エンジンを動かすために Postgres を足すのは、割に合わない取引です
  • ワークフロー定義は、別プラットフォームのメタデータではなく、実行する SQL の隣の Git に残ります
  • 構成一式が 1 台に収まります。たいていそれは、データがすでに置いてあるホストです
03

単一ライターはスケジューリングの問題

DuckDB の本番運用で定番の助言は、OS レベルのロックで書き込みの重なりを防ぐことです。データベースが受け付けるライターが 1 つだからです。その防御が cron とロックファイルである限り、スクリプト 1 本のミスで壊れます。ワークフロー側で宣言すれば、この種類のバグ自体がなくなります。

  • max_active_runs を 1 にすると、遅れた実行はデータベースを壊すのではなく次の実行を待たせます
  • 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 や自前スケジューラを持つウェアハウスを運用しているなら、パイプライン 1 本のために 2 つ目のオーケストレータを足す価値はほとんどありません

FAQ

導入前によくある質問

DuckDB 専用のステップやプラグインはありますか?

ありません。これは意図的な選択です。DuckDB は単一バイナリの CLI を配布しており、それだけで仕事が完結します。Dagu は通常のコマンドステップとして実行します。専用エグゼキュータを作っても、DuckDB のリリースに追従し続ける層が増えるだけで、CLI が提供する以上のものは得られません。

2 つの実行がデータベースファイルを壊さないようにするには?

ワークフローに max_active_runs: 1 を設定してください。実行中のものがあれば次のスケジュール実行はブロックされ、2 つ目のライターは開きません。DuckDB のドキュメントが自作を勧めているロックファイルの、宣言的な版にあたります。制御が及ぶのはそのワークフローの実行だけなので、他のプロセスは同じファイルから遠ざけてください。

大きなクエリがメモリを使い切ったらどうなりますか?

ワークフローに resources.limits.memory を設定して、ホストごと巻き込む前に実行を止めてください。あわせて、構造的ではなく一時的な失敗に対してステップにリトライを設定します。ホストの搭載量を超えるメモリを要求するクエリは、リトライではなく書き直す対象です。

DuckDB にはコミュニティの cron 拡張があります。それではだめですか?

あれは DuckDB のプロセス内でスケジュールするため、スケジュールはそのプロセスが生きている間しか存在しません。実行履歴もリトライも失敗時の通知もなく、翌朝に見るものが何も残りません。サーバー上のバッチ処理ではなく、長時間動き続ける組み込みアプリケーション向けの機能です。

スケジュール実行の中で DuckDB から S3 を読めますか?

httpfs 拡張で読めます。スケジュールされた DuckDB の処理が、抽出ステップを別に置かずに Parquet を読む一般的なやり方です。オブジェクトストレージはネットワーク依存なので、まさにそのステップにリトライを設定する価値があります。

次のステップ

まず 1 本から始める。

Dagu を入れて、いま cron で動いているスクリプトを 1 本だけ YAML に移し、実際の実行履歴で判断してください。