duckpipe 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
duckpipe-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Runzhou Li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,320 @@
1
+ Metadata-Version: 2.4
2
+ Name: duckpipe
3
+ Version: 0.1.0
4
+ Summary: A serverless-first, DuckDB-native pipeline orchestrator. No scheduler daemon, no central metadata database required, no broker — a run is a Python process that starts, does work, records what it did to a `.duckdb` file, and exits.
5
+ Author: Runzhou Li
6
+ Author-email: Runzhou Li <runzhou.li@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Database
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Dist: duckdb>=1.4.0
16
+ Requires-Dist: typer>=0.15.0
17
+ Requires-Dist: rich>=13.9.0
18
+ Requires-Dist: pyarrow>=18.0.0 ; extra == 'arrow'
19
+ Requires-Dist: duckpipe[remote] ; extra == 'azure'
20
+ Requires-Dist: adlfs>=2024.10.0 ; extra == 'azure'
21
+ Requires-Dist: pytz>=2024.1 ; extra == 'ducklake'
22
+ Requires-Dist: duckpipe[remote] ; extra == 'gcs'
23
+ Requires-Dist: gcsfs>=2024.10.0 ; extra == 'gcs'
24
+ Requires-Dist: fsspec>=2024.10.0 ; extra == 'remote'
25
+ Requires-Dist: duckpipe[remote] ; extra == 's3'
26
+ Requires-Dist: s3fs>=2024.10.0 ; extra == 's3'
27
+ Requires-Python: >=3.12
28
+ Project-URL: Homepage, https://woozyking.github.io/duckpipe/
29
+ Project-URL: Repository, https://github.com/woozyking/duckpipe
30
+ Project-URL: Issues, https://github.com/woozyking/duckpipe/issues
31
+ Project-URL: Documentation, https://woozyking.github.io/duckpipe/docs/
32
+ Project-URL: Changelog, https://github.com/woozyking/duckpipe/blob/main/CHANGELOG.md
33
+ Provides-Extra: arrow
34
+ Provides-Extra: azure
35
+ Provides-Extra: ducklake
36
+ Provides-Extra: gcs
37
+ Provides-Extra: remote
38
+ Provides-Extra: s3
39
+ Description-Content-Type: text/markdown
40
+
41
+ # DuckPipe
42
+
43
+ [![CI](https://github.com/woozyking/duckpipe/actions/workflows/ci.yml/badge.svg)](https://github.com/woozyking/duckpipe/actions/workflows/ci.yml)
44
+ [![PyPI](https://img.shields.io/pypi/v/duckpipe.svg)](https://pypi.org/project/duckpipe/)
45
+ [![Python](https://img.shields.io/badge/python-%E2%89%A53.12-blue)](https://github.com/woozyking/duckpipe/blob/main/pyproject.toml)
46
+ [![License: MIT](https://img.shields.io/badge/license-MIT-informational)](LICENSE)
47
+
48
+ **[Try it live in your browser →](https://woozyking.github.io/duckpipe/examples/08_browser_wasm/)**
49
+ No install, nothing to run — a real DuckDB pipeline, entirely in this
50
+ page, checking a file for sensitive-looking columns without ever
51
+ uploading it.
52
+
53
+ A serverless-first, DuckDB-native pipeline orchestrator. No scheduler
54
+ daemon, no central metadata database required, no broker — a run is a
55
+ Python process that starts, does work, records what it did to a
56
+ `.duckdb` file, and exits.
57
+
58
+ ```python
59
+ # pipeline.py
60
+ import duckdb
61
+ from duckpipe import task, run
62
+
63
+ TAXI_DATA = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet"
64
+
65
+
66
+ @task
67
+ def extract():
68
+ return duckdb.sql(f"SELECT * FROM read_parquet('{TAXI_DATA}')")
69
+
70
+
71
+ @task(cache=True)
72
+ def daily_totals(trips=extract):
73
+ return trips.aggregate(
74
+ "date_trunc('day', tpep_pickup_datetime) AS day, sum(fare_amount) AS total"
75
+ ).pl()
76
+
77
+
78
+ if __name__ == "__main__":
79
+ run(__file__)
80
+ ```
81
+
82
+ ```bash
83
+ uv run duckpipe run pipeline.py
84
+ ```
85
+
86
+ Copy that into an empty file and it just runs: `extract` streams NYC
87
+ TLC's public trip data straight off the network via DuckDB's own
88
+ `httpfs` — no download, no local file, no fixture to go find first.
89
+ That's the whole surface area otherwise. `daily_totals(trips=extract)`
90
+ is how you declare a dependency — no `depends_on=[...]` boilerplate, no
91
+ separate DAG object, just a normal Python default argument. Run it
92
+ again and `daily_totals` reports `skipped`: nothing about its code or
93
+ `extract`'s changed, so there's nothing to redo.
94
+
95
+ (That URL is NYC TLC's current official distribution endpoint,
96
+ confirmed directly against their own [trip record data
97
+ page](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page) —
98
+ not a permanent guarantee, though: TLC changed both the file format and
99
+ the hosting once before, from CSV on S3 to Parquet on this CloudFront
100
+ domain, back in May 2022. If this URL ever breaks, that page is where
101
+ to find the current one; see
102
+ [`examples/data/README.md`](examples/data/README.md) for more on
103
+ running against remote data at scale.)
104
+
105
+ ## Why
106
+
107
+ Most teams reach for Airflow/Prefect/Dagster the moment they need "more
108
+ than one script that depends on another script," which means standing up
109
+ a scheduler, a metadata database, and usually a broker before running a
110
+ single real pipeline — fixed infrastructure tax whether your DAG moves a
111
+ thousand rows once a day or a billion rows every minute. Most pipeline
112
+ DAGs are a handful of dependent steps over data that fits on one
113
+ machine. DuckPipe is the orchestrator for *that* case: a library, not a
114
+ platform — though never at odds with a central metadata store either:
115
+ if you already run one and want several DuckPipe deployments sharing a
116
+ catalog, that's an opt-in upgrade away, not a different architecture
117
+ (see [DuckLake observability upgrade](docs/ducklake.md)). See
118
+ [`DESIGN.md`](DESIGN.md) for the full design rationale and
119
+ prior-art landscape check.
120
+
121
+ ## Install
122
+
123
+ Requires Python ≥3.12; developed against 3.14.
124
+
125
+ ```bash
126
+ uv add duckpipe # core: duckdb + typer + rich
127
+ uv add "duckpipe[remote]" # + fsspec, for state_uri sync to any fsspec-supported store
128
+ uv add "duckpipe[s3]" # + s3fs, for state_uri="s3://..."
129
+ uv add "duckpipe[gcs]" # + gcsfs, for state_uri="gs://..."
130
+ uv add "duckpipe[azure]" # + adlfs, for state_uri="az://..."
131
+ uv add "duckpipe[arrow]" # + pyarrow, for cache_backend="arrow"
132
+ uv add "duckpipe[ducklake]" # + pytz, for the DuckLake observability backend
133
+ uv add duckpipe-tuning # optional, separate package -- see below
134
+ ```
135
+
136
+ Only pull in what a given trigger/backend actually needs — `s3`/`gcs`/`azure`
137
+ each already include `remote`, so e.g. a Lambda deployment package or a CI
138
+ job that only uses `state_uri="s3://..."` needs just `duckpipe[s3]`, not
139
+ every extra at once (see [`docs/triggers.md`](docs/triggers.md) for
140
+ trigger-specific install recipes).
141
+
142
+ ## The whole mental model
143
+
144
+ - **`@task`** decorates a plain Python function. Any signature, any
145
+ return type — the core never inspects what a task returns. A
146
+ side-effect-only task (send a Slack alert, kick off a training run,
147
+ write a log line) is exactly as first-class as one that moves data:
148
+ just return `None`. Add `cache=True` if you want it to fire only once
149
+ per code/upstream change instead of on every re-run — the same
150
+ fingerprint mechanism that skips a data task skips a side effect too,
151
+ since neither ever depended on inspecting a return value. The one
152
+ caveat is the same one any retry-based system has: keep the side
153
+ effect idempotent if you set `retries>0`, since a retried attempt
154
+ could otherwise repeat part of it.
155
+ - **Dependencies are inferred from default argument values**: writing
156
+ `def b(x=a)` where `a` is another task tells DuckPipe `b` depends on
157
+ `a`, and at run time `x` receives `a`'s actual result. Tasks with no
158
+ data dependency but a real ordering requirement — the common case for
159
+ side-effect tasks — use the `@task(depends_on=[...])` escape hatch.
160
+ - **A pipeline is a Python module.** `duckpipe run pipeline.py` imports
161
+ it once, discovers every `@task`-decorated function reachable from the
162
+ module namespace, and runs the resulting DAG. Splitting tasks across
163
+ sibling files needs no DuckPipe-specific mechanism — normal Python
164
+ imports (including relative ones, `from .extract import extract`)
165
+ between files in the same package work exactly as they would anywhere
166
+ else; `pipeline.py` just needs to end up importing (directly or
167
+ transitively) every task you want included.
168
+ - **State is a `.duckdb` file** next to the pipeline (path configurable).
169
+ `SELECT * FROM task_runs` in any DuckDB client tells you what happened
170
+ — no separate UI.
171
+ - **Caching is `@task(cache=True)`.** DuckPipe fingerprints a task's
172
+ source + config + upstream fingerprints (never its output data) and,
173
+ on a cache hit, skips the task and hands the cached value downstream.
174
+ `--force` re-runs everything regardless. Default cache storage is
175
+ pickle; `cache_backend="arrow"` (needs `duckpipe[arrow]`) is a leaner
176
+ alternative for tabular results — a DuckDB relation, pandas/Polars
177
+ DataFrame, or pyarrow Table.
178
+ - **Resuming from a failure needs no special flag.** A failed task never
179
+ gets a fingerprint or cached value, so re-running the exact same
180
+ command only re-executes what failed (and anything downstream of it)
181
+ — everything else with `cache=True` and unchanged code just skips, the
182
+ same as any other unchanged re-run.
183
+ - **Retries are `@task(retries=N, retry_delay=seconds)`.**
184
+ - **Triggers are just "run the command."** Cron, CI, a webhook handler, a
185
+ Lambda entrypoint, or a single step embedded inside Airflow/Prefect/
186
+ Dagster all just call `duckpipe.run(...)` — DuckPipe has no scheduler
187
+ daemon of its own.
188
+
189
+ No work pools, no deployments-as-a-separate-entity, no task-runner menu.
190
+ Full rationale for each of these choices — including the open questions
191
+ still being resolved — is in [`DESIGN.md`](DESIGN.md) §5, §12.
192
+
193
+ ## CLI
194
+
195
+ ```bash
196
+ duckpipe run pipeline.py [--db PATH] [--state-uri URI] [--force] [--max-workers N] [--only TASK]
197
+ duckpipe show pipeline.py [--db PATH] [--json] [--mermaid] # resolved DAG, last-run status, next-run preview, or a flowchart
198
+ duckpipe stats duckpipe.db [--limit N] [--snapshots] # recent runs + per-task timing, or DuckLake time travel
199
+ duckpipe compact state_uri # fold distributed workers' pending state into one file
200
+ ```
201
+
202
+ `--db` accepts a plain path or a `ducklake:...` catalog string — see
203
+ [DuckLake observability upgrade](docs/ducklake.md).
204
+
205
+ A malformed pipeline (a dependency cycle, two tasks sharing a name) is
206
+ reported as one short line, not a framework traceback. `duckpipe show`
207
+ in particular doubles as a dry run: its "next run" column tells you
208
+ which tasks would skip vs. re-run before you spend the time actually
209
+ running it. `duckpipe show pipeline.py --mermaid` prints a
210
+ [Mermaid](https://mermaid.js.org) flowchart of the same DAG instead —
211
+ colored by each task's last recorded status when state exists — paste
212
+ it straight into a PR description, a wiki page, or anywhere else that
213
+ renders Mermaid:
214
+
215
+ ```mermaid
216
+ flowchart TD
217
+ t_extract["extract"]
218
+ t_daily_totals["daily_totals"]
219
+ t_extract --> t_daily_totals
220
+ class t_extract success
221
+ class t_daily_totals success
222
+ classDef success fill:#d4f7dc,stroke:#2f9e44,color:#1a1a1a
223
+ ```
224
+
225
+ The state file's own views (`v_latest_task_status`, `v_run_summary`,
226
+ `v_task_stats`) are plain SQL and queryable from any DuckDB client, not
227
+ just through the CLI:
228
+
229
+ ```sql
230
+ duckdb duckpipe.db -c "SELECT * FROM v_run_summary ORDER BY started_at DESC LIMIT 5"
231
+ ```
232
+
233
+ ## Examples
234
+
235
+ Eight realistic pipelines over real, bundled open data (NYC TLC taxi
236
+ trips) live in [`examples/`](examples/README.md) — one per facet of
237
+ DuckPipe, from a plain batch ETL through distributed execution,
238
+ DuckLake, a serverless executor, and running in the browser. Every
239
+ non-distributed one also runs unmodified against the full public
240
+ dataset by setting one environment variable; see
241
+ [`examples/data/README.md`](examples/data/README.md).
242
+
243
+ ## Scaling out
244
+
245
+ Four upgrades, each opt-in and each usable on its own — full detail in
246
+ [`docs/`](docs/):
247
+
248
+ - **[Distributed execution](docs/distributed_execution.md)** — sync
249
+ state to remote storage (`state_uri`), then scope a run to one task
250
+ (`only=`/`--only`) so many workers can safely share a DAG at once,
251
+ with no lock contention.
252
+ - **[DuckLake observability](docs/ducklake.md)** — point `db_path` at a
253
+ DuckLake catalog instead of a plain file for real snapshot history,
254
+ time travel, and schema evolution with no migration step. Same
255
+ argument, same commands.
256
+ - **[Serverless executor](docs/serverless_executor.md)** — the
257
+ distributed-execution primitive above, checked against two genuinely
258
+ different invocation shapes (a container, a FaaS `handler`) to prove
259
+ it isn't tied to one platform.
260
+ - **[Beefy-node mode](docs/remote_execution.md)** and
261
+ **[running in the browser](docs/browser.md)** — the same code
262
+ unchanged on a bigger machine, or inside a browser tab via Pyodide.
263
+
264
+ ## Tuning (optional, separate package)
265
+
266
+ `duckpipe-tuning` suggests DuckDB `threads`/`memory_limit` settings from
267
+ host specs (CPU count, RAM) — pure functions, no query execution, no
268
+ data inspection. It's a genuinely separate package in this repo's uv
269
+ workspace (`packages/duckpipe-tuning/`), not a submodule: `duckpipe`
270
+ itself never imports `psutil` or knows this package exists.
271
+
272
+ ```python
273
+ import duckdb
274
+ from duckpipe_tuning import suggest_duckdb_settings
275
+
276
+ con = duckdb.connect()
277
+ settings = suggest_duckdb_settings(workload="join")
278
+ con.execute(f"SET threads = {settings['threads']}")
279
+ con.execute(f"SET memory_limit = '{settings['memory_limit']}'")
280
+ ```
281
+
282
+ ## Docs
283
+
284
+ [`docs/`](docs/) has the full chapter list — triggers, interop,
285
+ distributed execution, DuckLake, the serverless executor, beefy-node
286
+ mode, and the browser — each short and linking back to the example code
287
+ it describes. [`DESIGN.md`](DESIGN.md) is the design rationale and
288
+ prior-art landscape check behind all of it.
289
+
290
+ ## Development
291
+
292
+ This repo is a uv workspace: `duckpipe` at the root, `duckpipe-tuning`
293
+ under `packages/`.
294
+
295
+ ```bash
296
+ uv sync --group dev
297
+ uv run pytest # duckpipe
298
+ uv run --directory packages/duckpipe-tuning pytest # duckpipe-tuning
299
+ uv run ruff check .
300
+ uv run python scripts/phase0_bench_fanout.py # DAG-level concurrency benchmark
301
+ ```
302
+
303
+ CI (`.github/workflows/ci.yml`) runs all of the above on every push and
304
+ PR — also a live example of the GitHub Actions trigger recipe above.
305
+
306
+ ## Status
307
+
308
+ Pre-1.0. Everything documented above is implemented and covered by the
309
+ test suite: the core scheduler/fingerprinting/CLI, optional `fsspec`
310
+ remote state sync with an advisory lock, task-scoped distributed
311
+ execution (`only=`/`--only`) with delta-merge state, the DuckLake
312
+ observability upgrade (local SQLite catalog or a shared Postgres/MySQL
313
+ one), the serverless-executor and beefy-node patterns, browser execution
314
+ via Pyodide, and the Mermaid DAG export.
315
+
316
+ **Not yet built:**
317
+ - Remote sync (`state_uri`) and the DuckLake backend don't work inside
318
+ the browser example — needs `fsspec`-in-Pyodide verified first
319
+ (DESIGN.md §8, §12).
320
+ - A versioning/compatibility promise are still open decisions (DESIGN.md §12).
@@ -0,0 +1,280 @@
1
+ # DuckPipe
2
+
3
+ [![CI](https://github.com/woozyking/duckpipe/actions/workflows/ci.yml/badge.svg)](https://github.com/woozyking/duckpipe/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/duckpipe.svg)](https://pypi.org/project/duckpipe/)
5
+ [![Python](https://img.shields.io/badge/python-%E2%89%A53.12-blue)](https://github.com/woozyking/duckpipe/blob/main/pyproject.toml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-informational)](LICENSE)
7
+
8
+ **[Try it live in your browser →](https://woozyking.github.io/duckpipe/examples/08_browser_wasm/)**
9
+ No install, nothing to run — a real DuckDB pipeline, entirely in this
10
+ page, checking a file for sensitive-looking columns without ever
11
+ uploading it.
12
+
13
+ A serverless-first, DuckDB-native pipeline orchestrator. No scheduler
14
+ daemon, no central metadata database required, no broker — a run is a
15
+ Python process that starts, does work, records what it did to a
16
+ `.duckdb` file, and exits.
17
+
18
+ ```python
19
+ # pipeline.py
20
+ import duckdb
21
+ from duckpipe import task, run
22
+
23
+ TAXI_DATA = "https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet"
24
+
25
+
26
+ @task
27
+ def extract():
28
+ return duckdb.sql(f"SELECT * FROM read_parquet('{TAXI_DATA}')")
29
+
30
+
31
+ @task(cache=True)
32
+ def daily_totals(trips=extract):
33
+ return trips.aggregate(
34
+ "date_trunc('day', tpep_pickup_datetime) AS day, sum(fare_amount) AS total"
35
+ ).pl()
36
+
37
+
38
+ if __name__ == "__main__":
39
+ run(__file__)
40
+ ```
41
+
42
+ ```bash
43
+ uv run duckpipe run pipeline.py
44
+ ```
45
+
46
+ Copy that into an empty file and it just runs: `extract` streams NYC
47
+ TLC's public trip data straight off the network via DuckDB's own
48
+ `httpfs` — no download, no local file, no fixture to go find first.
49
+ That's the whole surface area otherwise. `daily_totals(trips=extract)`
50
+ is how you declare a dependency — no `depends_on=[...]` boilerplate, no
51
+ separate DAG object, just a normal Python default argument. Run it
52
+ again and `daily_totals` reports `skipped`: nothing about its code or
53
+ `extract`'s changed, so there's nothing to redo.
54
+
55
+ (That URL is NYC TLC's current official distribution endpoint,
56
+ confirmed directly against their own [trip record data
57
+ page](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page) —
58
+ not a permanent guarantee, though: TLC changed both the file format and
59
+ the hosting once before, from CSV on S3 to Parquet on this CloudFront
60
+ domain, back in May 2022. If this URL ever breaks, that page is where
61
+ to find the current one; see
62
+ [`examples/data/README.md`](examples/data/README.md) for more on
63
+ running against remote data at scale.)
64
+
65
+ ## Why
66
+
67
+ Most teams reach for Airflow/Prefect/Dagster the moment they need "more
68
+ than one script that depends on another script," which means standing up
69
+ a scheduler, a metadata database, and usually a broker before running a
70
+ single real pipeline — fixed infrastructure tax whether your DAG moves a
71
+ thousand rows once a day or a billion rows every minute. Most pipeline
72
+ DAGs are a handful of dependent steps over data that fits on one
73
+ machine. DuckPipe is the orchestrator for *that* case: a library, not a
74
+ platform — though never at odds with a central metadata store either:
75
+ if you already run one and want several DuckPipe deployments sharing a
76
+ catalog, that's an opt-in upgrade away, not a different architecture
77
+ (see [DuckLake observability upgrade](docs/ducklake.md)). See
78
+ [`DESIGN.md`](DESIGN.md) for the full design rationale and
79
+ prior-art landscape check.
80
+
81
+ ## Install
82
+
83
+ Requires Python ≥3.12; developed against 3.14.
84
+
85
+ ```bash
86
+ uv add duckpipe # core: duckdb + typer + rich
87
+ uv add "duckpipe[remote]" # + fsspec, for state_uri sync to any fsspec-supported store
88
+ uv add "duckpipe[s3]" # + s3fs, for state_uri="s3://..."
89
+ uv add "duckpipe[gcs]" # + gcsfs, for state_uri="gs://..."
90
+ uv add "duckpipe[azure]" # + adlfs, for state_uri="az://..."
91
+ uv add "duckpipe[arrow]" # + pyarrow, for cache_backend="arrow"
92
+ uv add "duckpipe[ducklake]" # + pytz, for the DuckLake observability backend
93
+ uv add duckpipe-tuning # optional, separate package -- see below
94
+ ```
95
+
96
+ Only pull in what a given trigger/backend actually needs — `s3`/`gcs`/`azure`
97
+ each already include `remote`, so e.g. a Lambda deployment package or a CI
98
+ job that only uses `state_uri="s3://..."` needs just `duckpipe[s3]`, not
99
+ every extra at once (see [`docs/triggers.md`](docs/triggers.md) for
100
+ trigger-specific install recipes).
101
+
102
+ ## The whole mental model
103
+
104
+ - **`@task`** decorates a plain Python function. Any signature, any
105
+ return type — the core never inspects what a task returns. A
106
+ side-effect-only task (send a Slack alert, kick off a training run,
107
+ write a log line) is exactly as first-class as one that moves data:
108
+ just return `None`. Add `cache=True` if you want it to fire only once
109
+ per code/upstream change instead of on every re-run — the same
110
+ fingerprint mechanism that skips a data task skips a side effect too,
111
+ since neither ever depended on inspecting a return value. The one
112
+ caveat is the same one any retry-based system has: keep the side
113
+ effect idempotent if you set `retries>0`, since a retried attempt
114
+ could otherwise repeat part of it.
115
+ - **Dependencies are inferred from default argument values**: writing
116
+ `def b(x=a)` where `a` is another task tells DuckPipe `b` depends on
117
+ `a`, and at run time `x` receives `a`'s actual result. Tasks with no
118
+ data dependency but a real ordering requirement — the common case for
119
+ side-effect tasks — use the `@task(depends_on=[...])` escape hatch.
120
+ - **A pipeline is a Python module.** `duckpipe run pipeline.py` imports
121
+ it once, discovers every `@task`-decorated function reachable from the
122
+ module namespace, and runs the resulting DAG. Splitting tasks across
123
+ sibling files needs no DuckPipe-specific mechanism — normal Python
124
+ imports (including relative ones, `from .extract import extract`)
125
+ between files in the same package work exactly as they would anywhere
126
+ else; `pipeline.py` just needs to end up importing (directly or
127
+ transitively) every task you want included.
128
+ - **State is a `.duckdb` file** next to the pipeline (path configurable).
129
+ `SELECT * FROM task_runs` in any DuckDB client tells you what happened
130
+ — no separate UI.
131
+ - **Caching is `@task(cache=True)`.** DuckPipe fingerprints a task's
132
+ source + config + upstream fingerprints (never its output data) and,
133
+ on a cache hit, skips the task and hands the cached value downstream.
134
+ `--force` re-runs everything regardless. Default cache storage is
135
+ pickle; `cache_backend="arrow"` (needs `duckpipe[arrow]`) is a leaner
136
+ alternative for tabular results — a DuckDB relation, pandas/Polars
137
+ DataFrame, or pyarrow Table.
138
+ - **Resuming from a failure needs no special flag.** A failed task never
139
+ gets a fingerprint or cached value, so re-running the exact same
140
+ command only re-executes what failed (and anything downstream of it)
141
+ — everything else with `cache=True` and unchanged code just skips, the
142
+ same as any other unchanged re-run.
143
+ - **Retries are `@task(retries=N, retry_delay=seconds)`.**
144
+ - **Triggers are just "run the command."** Cron, CI, a webhook handler, a
145
+ Lambda entrypoint, or a single step embedded inside Airflow/Prefect/
146
+ Dagster all just call `duckpipe.run(...)` — DuckPipe has no scheduler
147
+ daemon of its own.
148
+
149
+ No work pools, no deployments-as-a-separate-entity, no task-runner menu.
150
+ Full rationale for each of these choices — including the open questions
151
+ still being resolved — is in [`DESIGN.md`](DESIGN.md) §5, §12.
152
+
153
+ ## CLI
154
+
155
+ ```bash
156
+ duckpipe run pipeline.py [--db PATH] [--state-uri URI] [--force] [--max-workers N] [--only TASK]
157
+ duckpipe show pipeline.py [--db PATH] [--json] [--mermaid] # resolved DAG, last-run status, next-run preview, or a flowchart
158
+ duckpipe stats duckpipe.db [--limit N] [--snapshots] # recent runs + per-task timing, or DuckLake time travel
159
+ duckpipe compact state_uri # fold distributed workers' pending state into one file
160
+ ```
161
+
162
+ `--db` accepts a plain path or a `ducklake:...` catalog string — see
163
+ [DuckLake observability upgrade](docs/ducklake.md).
164
+
165
+ A malformed pipeline (a dependency cycle, two tasks sharing a name) is
166
+ reported as one short line, not a framework traceback. `duckpipe show`
167
+ in particular doubles as a dry run: its "next run" column tells you
168
+ which tasks would skip vs. re-run before you spend the time actually
169
+ running it. `duckpipe show pipeline.py --mermaid` prints a
170
+ [Mermaid](https://mermaid.js.org) flowchart of the same DAG instead —
171
+ colored by each task's last recorded status when state exists — paste
172
+ it straight into a PR description, a wiki page, or anywhere else that
173
+ renders Mermaid:
174
+
175
+ ```mermaid
176
+ flowchart TD
177
+ t_extract["extract"]
178
+ t_daily_totals["daily_totals"]
179
+ t_extract --> t_daily_totals
180
+ class t_extract success
181
+ class t_daily_totals success
182
+ classDef success fill:#d4f7dc,stroke:#2f9e44,color:#1a1a1a
183
+ ```
184
+
185
+ The state file's own views (`v_latest_task_status`, `v_run_summary`,
186
+ `v_task_stats`) are plain SQL and queryable from any DuckDB client, not
187
+ just through the CLI:
188
+
189
+ ```sql
190
+ duckdb duckpipe.db -c "SELECT * FROM v_run_summary ORDER BY started_at DESC LIMIT 5"
191
+ ```
192
+
193
+ ## Examples
194
+
195
+ Eight realistic pipelines over real, bundled open data (NYC TLC taxi
196
+ trips) live in [`examples/`](examples/README.md) — one per facet of
197
+ DuckPipe, from a plain batch ETL through distributed execution,
198
+ DuckLake, a serverless executor, and running in the browser. Every
199
+ non-distributed one also runs unmodified against the full public
200
+ dataset by setting one environment variable; see
201
+ [`examples/data/README.md`](examples/data/README.md).
202
+
203
+ ## Scaling out
204
+
205
+ Four upgrades, each opt-in and each usable on its own — full detail in
206
+ [`docs/`](docs/):
207
+
208
+ - **[Distributed execution](docs/distributed_execution.md)** — sync
209
+ state to remote storage (`state_uri`), then scope a run to one task
210
+ (`only=`/`--only`) so many workers can safely share a DAG at once,
211
+ with no lock contention.
212
+ - **[DuckLake observability](docs/ducklake.md)** — point `db_path` at a
213
+ DuckLake catalog instead of a plain file for real snapshot history,
214
+ time travel, and schema evolution with no migration step. Same
215
+ argument, same commands.
216
+ - **[Serverless executor](docs/serverless_executor.md)** — the
217
+ distributed-execution primitive above, checked against two genuinely
218
+ different invocation shapes (a container, a FaaS `handler`) to prove
219
+ it isn't tied to one platform.
220
+ - **[Beefy-node mode](docs/remote_execution.md)** and
221
+ **[running in the browser](docs/browser.md)** — the same code
222
+ unchanged on a bigger machine, or inside a browser tab via Pyodide.
223
+
224
+ ## Tuning (optional, separate package)
225
+
226
+ `duckpipe-tuning` suggests DuckDB `threads`/`memory_limit` settings from
227
+ host specs (CPU count, RAM) — pure functions, no query execution, no
228
+ data inspection. It's a genuinely separate package in this repo's uv
229
+ workspace (`packages/duckpipe-tuning/`), not a submodule: `duckpipe`
230
+ itself never imports `psutil` or knows this package exists.
231
+
232
+ ```python
233
+ import duckdb
234
+ from duckpipe_tuning import suggest_duckdb_settings
235
+
236
+ con = duckdb.connect()
237
+ settings = suggest_duckdb_settings(workload="join")
238
+ con.execute(f"SET threads = {settings['threads']}")
239
+ con.execute(f"SET memory_limit = '{settings['memory_limit']}'")
240
+ ```
241
+
242
+ ## Docs
243
+
244
+ [`docs/`](docs/) has the full chapter list — triggers, interop,
245
+ distributed execution, DuckLake, the serverless executor, beefy-node
246
+ mode, and the browser — each short and linking back to the example code
247
+ it describes. [`DESIGN.md`](DESIGN.md) is the design rationale and
248
+ prior-art landscape check behind all of it.
249
+
250
+ ## Development
251
+
252
+ This repo is a uv workspace: `duckpipe` at the root, `duckpipe-tuning`
253
+ under `packages/`.
254
+
255
+ ```bash
256
+ uv sync --group dev
257
+ uv run pytest # duckpipe
258
+ uv run --directory packages/duckpipe-tuning pytest # duckpipe-tuning
259
+ uv run ruff check .
260
+ uv run python scripts/phase0_bench_fanout.py # DAG-level concurrency benchmark
261
+ ```
262
+
263
+ CI (`.github/workflows/ci.yml`) runs all of the above on every push and
264
+ PR — also a live example of the GitHub Actions trigger recipe above.
265
+
266
+ ## Status
267
+
268
+ Pre-1.0. Everything documented above is implemented and covered by the
269
+ test suite: the core scheduler/fingerprinting/CLI, optional `fsspec`
270
+ remote state sync with an advisory lock, task-scoped distributed
271
+ execution (`only=`/`--only`) with delta-merge state, the DuckLake
272
+ observability upgrade (local SQLite catalog or a shared Postgres/MySQL
273
+ one), the serverless-executor and beefy-node patterns, browser execution
274
+ via Pyodide, and the Mermaid DAG export.
275
+
276
+ **Not yet built:**
277
+ - Remote sync (`state_uri`) and the DuckLake backend don't work inside
278
+ the browser example — needs `fsspec`-in-Pyodide verified first
279
+ (DESIGN.md §8, §12).
280
+ - A versioning/compatibility promise are still open decisions (DESIGN.md §12).