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 +21 -0
- duckpipe-0.1.0/PKG-INFO +320 -0
- duckpipe-0.1.0/README.md +280 -0
- duckpipe-0.1.0/pyproject.toml +93 -0
- duckpipe-0.1.0/pyproject.toml.orig +97 -0
- duckpipe-0.1.0/src/duckpipe/__init__.py +41 -0
- duckpipe-0.1.0/src/duckpipe/cli.py +393 -0
- duckpipe-0.1.0/src/duckpipe/dag.py +164 -0
- duckpipe-0.1.0/src/duckpipe/fingerprint.py +68 -0
- duckpipe-0.1.0/src/duckpipe/remote.py +199 -0
- duckpipe-0.1.0/src/duckpipe/scheduler.py +502 -0
- duckpipe-0.1.0/src/duckpipe/state.py +540 -0
- duckpipe-0.1.0/src/duckpipe/task.py +127 -0
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.
|
duckpipe-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/woozyking/duckpipe/actions/workflows/ci.yml)
|
|
44
|
+
[](https://pypi.org/project/duckpipe/)
|
|
45
|
+
[](https://github.com/woozyking/duckpipe/blob/main/pyproject.toml)
|
|
46
|
+
[](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).
|
duckpipe-0.1.0/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# DuckPipe
|
|
2
|
+
|
|
3
|
+
[](https://github.com/woozyking/duckpipe/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/duckpipe/)
|
|
5
|
+
[](https://github.com/woozyking/duckpipe/blob/main/pyproject.toml)
|
|
6
|
+
[](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).
|