DuckDB

Geplante DuckDB-Pipelines, ohne eine Datenbank vor die Datenbank zu stellen.

DuckDB läuft im Prozess und ohne Server. Genau deshalb hat es keinen Scheduler, keine Wiederholungen und keine Laufhistorie. Dagu ergänzt diese Betriebsschicht aus einer einzigen Binary, sodass der Stack aus zwei ausführbaren Dateien und einem Datenverzeichnis besteht.

Eine Binary orchestriert eine Binary, ohne Datenbank im Betrieb
max_active_runs sichert die Single-Writer-Regel von DuckDB ab
Cursor überstehen Prozessneustarts bei inkrementellen Ladevorgängen
Läuft dort, wo die Daten liegen, auch in geschlossenen Netzen
01

Eingebettet heißt: kein Scheduler, und zwar bewusst

DuckDB läuft in Ihrem Prozess. Es gibt keinen Daemon, keinen Port und nichts, das darauf wartet, um drei Uhr nachts eine Abfrage auszuführen. Das ist Absicht und kein Versäumnis, und es bedeutet, dass der Zeitplan von außen kommen muss.

  • Eine DuckDB-Pipeline ist meist ein CLI-Aufruf, wodurch cron zur Standardantwort wird, bei der die meisten Teams stehen bleiben
  • cron liefert nur Ausführung: keine Wiederholung, wenn S3 in ein Timeout läuft, keine Historie und kein Signal, wenn letzte Nacht still nichts passiert ist
  • Die Community-cron-Erweiterung plant innerhalb des Prozesses, der Zeitplan stirbt also mit dem Prozess und hinterlässt keine Laufhistorie
02

Machen Sie den Grund nicht zunichte, aus dem Sie DuckDB gewählt haben

Der Reiz von DuckDB besteht darin, dass es keinen Cluster, keinen Server und keine Warehouse-Rechnung gibt. Ein schwergewichtiger Orchestrator davor gibt all das zurück. Airflow verlangt einen Scheduler, eine Metadatenbank und ein Python-DAG-Framework. Kestra verlangt eine JDBC-Datenbank, Objektspeicher und vier Komponenten. Dagu ist eine Binary mit dateibasiertem Zustand.

  • Postgres hinzuzufügen, um eine serverlose Analyse-Engine zu planen, ist ein merkwürdiger Tausch
  • Workflow-Definitionen bleiben in git neben dem SQL, das sie ausführen, statt in den Metadaten einer anderen Plattform
  • Der gesamte Stack passt auf einen Host, und das ist meist derselbe, auf dem die Daten ohnehin liegen
03

Single-Writer ist ein Scheduling-Problem

Der übliche Produktionsrat für DuckDB lautet, überlappende Schreibvorgänge mit einer Sperre auf Betriebssystemebene zu verhindern, denn die Datenbank akzeptiert genau einen Writer. Wenn diese Absicherung aus cron plus Lockfile besteht, ist sie ein Skript davon entfernt, falsch zu sein. Deklariert man sie am Workflow, verschwindet die gesamte Fehlerklasse.

  • max_active_runs auf 1 bedeutet, dass ein langsamer Lauf den nächsten verzögert, statt die Datenbank zu beschädigen
  • resources.limits.memory begrenzt einen Lauf, bevor ein großer Join den Host mitreißt
  • retry_policy deckt die Fehler ab, die wirklich vorübergehend sind, etwa ein Objektspeicher-Timeout mitten im Scan

Das begrenzt gleichzeitige Läufe dieses Workflows. DuckDB wird dadurch nicht multi-writer-fähig: Ein anderer Prozess, der in dieselbe Datei schreibt, bleibt Ihr Problem.

Nächtliche Aggregation über Parquet im Objektspeicher
# 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

Inkrementelle Ladevorgänge brauchen einen Cursor, der den Prozess überlebt

Bei jedem Lauf alles neu zu laden verwandelt eine schnelle lokale Abfrage in eine langsame und teure. Ein inkrementeller Ladevorgang muss sich merken, wo der letzte erfolgreiche Lauf endete, und diese Erinnerung muss einen Neustart überstehen, ohne dass es eine Datenbank gibt, die sie aufbewahrt.

  • Dagu speichert einen kleinen JSON-Cursor über Läufe hinweg, sodass jeder Lauf nur das noch nicht geladene Fenster liest
  • Der Cursor wird erst nach erfolgreichem Ladeschritt gespeichert; ein fehlgeschlagener Lauf lässt ihn unberührt und der nächste wiederholt dasselbe Fenster
  • Das Fenster an beiden Enden zu begrenzen verhindert ein Rennen mit Zeilen, die während des Laufs eintreffen
Inkrementelles Anhängen mit persistierter Wasserlinie
# 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

Wo diese Kombination die schwächere Wahl ist

Dagu plant DuckDB. Es ändert nicht, was DuckDB ist, und es gibt Lasten, für die die Kombination die falsche Antwort ist.

  • DuckDB ist Single-Node und Single-Writer. Wenn mehrere Dienste gleichzeitig schreiben müssen, behebt das kein Orchestrator
  • Für häufige kleine Schreibvorgänge ist es nicht gebaut; das bleibt die Aufgabe von PostgreSQL
  • Wer bereits Airflow oder ein Warehouse mit eigenem Scheduler betreibt, gewinnt selten durch einen zweiten Orchestrator für eine Pipeline

FAQ

Practical questions before adopting

Gibt es einen DuckDB-Executor oder ein Plugin?

Nein, und das ist Absicht. DuckDB liefert eine Single-Binary-CLI, die die Arbeit bereits erledigt, deshalb führt Dagu sie als gewöhnlichen Kommandoschritt aus. Ein eigener Executor würde nur eine Schicht hinzufügen, die mit den DuckDB-Releases synchron gehalten werden muss, und dabei weniger bieten als die CLI.

Wie verhindere ich, dass zwei Läufe die Datenbankdatei beschädigen?

Setzen Sie max_active_runs am Workflow auf 1. Ein noch laufender Lauf blockiert den nächsten geplanten Lauf, statt einen zweiten Writer zu öffnen. Das ist die deklarative Fassung des Lockfiles, das die DuckDB-Dokumentation Sie bauen lässt. Es regelt nur Läufe dieses Workflows, halten Sie andere Prozesse also von derselben Datei fern.

Was passiert, wenn einer großen Abfrage der Speicher ausgeht?

Setzen Sie resources.limits.memory am Workflow, damit der Lauf begrenzt wird, bevor er den Host mitnimmt, und geben Sie dem Schritt eine Retry-Policy für vorübergehende statt struktureller Fehler. Eine Abfrage, die mehr Speicher braucht als der Host hat, gehört umgeschrieben, nicht wiederholt.

DuckDB hat eine Community-cron-Erweiterung. Warum nicht die nutzen?

Sie plant innerhalb des DuckDB-Prozesses, der Zeitplan existiert also nur, solange dieser Prozess läuft. Es gibt keine Laufhistorie, keine Wiederholungen, keine Benachrichtigung bei Fehlern und am nächsten Morgen nichts anzusehen. Das passt zu einer langlebigen eingebetteten Anwendung, nicht zu Batch-Arbeit auf einem Server.

Kann DuckDB in einem geplanten Lauf aus S3 lesen?

Ja, über die httpfs-Erweiterung, und so liest der Großteil geplanter DuckDB-Arbeit Parquet ohne separaten Extraktionsschritt. Da der Objektspeicher eine Netzwerkabhängigkeit ist, lohnt sich genau dort eine Retry-Policy.

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.