DuckPipe

A serverless-first, DuckDB-native pipeline orchestrator.

View the Project on GitHub woozyking/duckpipe

09 — nesting a pipeline inside a task, to any depth, and making that nesting visible

report_card and report_cash don’t just crunch data themselves: each calls duckpipe.run() on report_pipeline.py — a genuinely separate, independently-runnable pipeline (extract → clean → aggregate → summarize, daily trip count/revenue plus an overall summary) — once per payment type, each with its own state file. report_pipeline.py doesn’t stop there either: its own last task, summarize, nests a third pipeline, summary_pipeline.py. Three real levels, not a toy two. Nesting duckpipe.run() inside a task is safe at any depth (DESIGN.md §11): a task’s own body always runs in a thread-pool worker, never on the scheduler’s own event loop, so an inner run’s own asyncio.run() never collides with an outer one, no matter how many levels deep.

    report_card ─┐
                  ├─► combine
    report_cash ─┘

Each of report_card/report_cash is itself a full 4-task pipeline, and that pipeline’s own last task is itself a full 2-task pipeline — duckpipe show pipeline.py --mermaid alone can’t show any of that (DuckPipe has no way to discover that a task’s body happens to run another pipeline, let alone that that pipeline nests a third; that would mean statically analyzing arbitrary Python). show_nested_mermaid.py states all three levels explicitly instead, using to_mermaid’s subgraphs= parameter — now recursive, so the third level is the same argument shape as the first, just one level deeper:

uv run duckpipe run examples/09_nested_pipeline/pipeline.py --max-workers 1   # run it twice
uv run duckpipe run examples/09_nested_pipeline/pipeline.py --max-workers 1   # to see it skip
uv run python examples/09_nested_pipeline/show_nested_mermaid.py

The real, rendered result after those two runs (the second one, everything unchanged, correctly shows both outer nested calls as skipped — but every inner task, all the way down, still shows its own real history, from its own separate state file, not an outer one):

flowchart TD
    subgraph t_report_card ["report_card"]
        t_report_card__t_extract["extract"]
        t_report_card__t_clean["clean"]
        t_report_card__t_aggregate["aggregate"]
        subgraph t_report_card__t_summarize ["summarize"]
            t_report_card__t_summarize__t_load_daily["load_daily"]
            t_report_card__t_summarize__t_totals["totals"]
            t_report_card__t_summarize__t_load_daily --> t_report_card__t_summarize__t_totals
            class t_report_card__t_summarize__t_load_daily success
            class t_report_card__t_summarize__t_totals success
        end
        t_report_card__t_extract --> t_report_card__t_clean
        t_report_card__t_clean --> t_report_card__t_aggregate
        t_report_card__t_aggregate --> t_report_card__t_summarize
        class t_report_card__t_extract success
        class t_report_card__t_clean success
        class t_report_card__t_aggregate success
        class t_report_card__t_summarize success
    end
    subgraph t_report_cash ["report_cash"]
        t_report_cash__t_extract["extract"]
        t_report_cash__t_clean["clean"]
        t_report_cash__t_aggregate["aggregate"]
        subgraph t_report_cash__t_summarize ["summarize"]
            t_report_cash__t_summarize__t_load_daily["load_daily"]
            t_report_cash__t_summarize__t_totals["totals"]
            t_report_cash__t_summarize__t_load_daily --> t_report_cash__t_summarize__t_totals
            class t_report_cash__t_summarize__t_load_daily success
            class t_report_cash__t_summarize__t_totals success
        end
        t_report_cash__t_extract --> t_report_cash__t_clean
        t_report_cash__t_clean --> t_report_cash__t_aggregate
        t_report_cash__t_aggregate --> t_report_cash__t_summarize
        class t_report_cash__t_extract success
        class t_report_cash__t_clean success
        class t_report_cash__t_aggregate success
        class t_report_cash__t_summarize success
    end
    t_combine["combine"]
    t_report_card --> t_combine
    t_report_cash --> t_combine
    class t_report_card skipped
    class t_report_cash skipped
    class t_combine success
    classDef success fill:#d4f7dc,stroke:#2f9e44,color:#1a1a1a
    classDef skipped fill:#fff3cd,stroke:#d9a400,color:#1a1a1a

report_pipeline.py and summary_pipeline.py both stay independently runnable and testable too, the same as any other DuckPipe pipeline — each with its own explicit --db (the same convention every other example uses, and for the same reason: the CLI’s own default state-file name would otherwise collide with the file nesting it, right next to it):

uv run duckpipe run examples/09_nested_pipeline/report_pipeline.py \
    --db examples/09_nested_pipeline/report.duckdb
uv run duckpipe run examples/09_nested_pipeline/summary_pipeline.py \
    --db examples/09_nested_pipeline/summary.duckdb

Three things this example found the hard way, not by assuming