Windmill 中编写 DuckDB 脚本实战指南:CLI 工作流、Ducklake 数据湖与 S3 集成
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
本指南以 Windmill 开源仓库中的write-script-duckdb技能文档(system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md)为核心,系统讲解在 Windmill 中编写、预览、测试和部署 DuckDB 脚本的完整流程。你将掌握:如何用wmill script preview/wmill script run/wmill generate-metadata正确驱动本地脚本迭代与元数据同步,如何用$name参数语法定义 DuckDB 脚本入参,以及如何通过ATTACH接入 Ducklake 数据湖、外部数据库(PostgreSQL 等)和 S3 对象存储,实现从查询到写回的数据管道。
为什么 Windmill 中的 DuckDB 值得单独一套工作流
Windmill 是一个把脚本变成 Webhook、工作流和 UI 的开源开发者平台,其脚本语言支持非常广泛。DuckDB 在 2025 年 5 月被正式纳入后端支持,这一点可以从数据库迁移脚本中找到直接证据:backend/migrations/20250515084520_duckdb_support.up.sql 中通过ALTER TYPE SCRIPT_LANG ADD VALUE IF NOT EXISTS 'duckdb';将duckdb注册为一种原生脚本语言,并同时把duckdb标签加入默认 worker 的worker_tags,意味着 DuckDB 脚本由专门的 worker 标签调度执行。
DuckDB 与其他 SQL 方言(PostgreSQL、MySQL、Snowflake 等)在 Windmill 中的差异主要体现在三处:参数绑定语法($name而非$1、?等)、数据源接入方式(ATTACH而非--指令),以及S3 写回方式(原生COPY ... TO,而非其他方言的-- s3流式指令)。理解这些差异,是写出可在 Windmill 中稳定运行的 DuckDB 脚本的前提。
CLI 命令总览:本地编辑与远程部署的分界
SKILL.md 开篇就给出了一条核心纪律:先写脚本、放好文件,然后根据用户意图选择对应的 CLI 命令。四条主要命令的分工如下:
| 命令 | 作用 | 是否改动工作区(远程) |
|---|---|---|
wmill script preview <script_path> | 运行本地文件,不部署。本地迭代脚本时的默认选择 | 否 |
wmill script run <path> | 运行已部署到工作区的脚本 | 否(运行的是远端已有版本) |
wmill generate-metadata | 重新生成.script.yaml(输入 schema)与.lock(解析后的依赖),刷新wmill-lock.yaml中的内容哈希。只写本地文件 | 否 |
git push或wmill sync push | 将本地变更部署到工作区 | 是(唯一会变更远端状态的步骤) |
其中wmill script preview的实现位于 cli/src/commands/script/script.ts:它会读取磁盘上的脚本内容(而非工作区版本),并支持从本地解析相对导入——也就是说,当你本地同时改了多个互相 import 的脚本时,preview 会优先使用本地未部署的版本,而不是远端旧版本。该函数还会拒绝指向.script.json/.script.yaml元数据文件的路径,明确要求传入脚本内容文件(.py、.ts、.go、.sh等)。
Preview vs run:按意图选择,而非按习惯
SKILL.md 特别强调:当用户说"运行脚本 / 试试 / 测一下能不能用"、而脚本文件存在本地未部署的编辑时,一律使用wmill script preview。绝对不要先把脚本push上去再用wmill script run测试——push 本身就是一次部署,用未经测试的本地改动覆盖工作区版本是危险的。
wmill script run只在两种情况下使用:
- 用户明确说"运行已部署的版本 / 运行服务器上的那个";
- 本地根本没有正在编辑的脚本(只是调用一个已存在的脚本)。
wmill sync push(或git push)只在用户明确要求部署/发布/推送时使用,且 preview 已验证过变更。
写完后主动提供测试,而非被动等待
如果用户没有在原始请求中要求运行/测试,写完脚本后应给出一句话的下一步建议(例如"需要我用示例参数运行wmill script preview吗?"),不要抛出多选项菜单。如果用户原本就要求测试/运行,则直接执行:
wmill script preview <path> -d '<args>'参数-d传入脚本声明的参数。参数值的写法因语言而异:代码类语言用main(...),SQL 方言用各自的占位符(PostgreSQL 用$1,MySQL/Snowflake 用?,MSSQL 用@P1,BigQuery 用@name),Bash 用位置参数$1、$2,PowerShell 用param(...)。DuckDB 属于前者范畴,详见下文"参数语法"一节。
需要补充说明的是:wmill script preview虽然不部署,但仍会实际执行脚本代码,可能产生副作用;wmill generate-metadata则只写本地文件(锁文件、schema、哈希),不执行代码。只有当用户明确要求部署/发布/push 时,才会触发git push或wmill sync push修改远端状态。
编辑后保持元数据同步:generate-metadata 的深层机制
Windmill 的 CLI 工作区(wmill.yaml 驱动的同步仓库)中,wmill-lock.yaml为每个条目记录一个内容哈希。当你增删 import或修改main的参数时,该哈希失效,导致.lock、.script.yaml输入 schema 和哈希记录三者全部过期。过期状态会在 git-sync 和 CI 中产生虚假 diff,所以编辑后应运行:
wmill generate-metadata该命令的实现位于 cli/src/commands/generate-metadata/generate-metadata.ts,核心逻辑是遍历本地脚本、flow 文件夹与 app 条目(walkLocalScripts、walkLocalFlowFolders、walkLocalAppItems),对比内容哈希找出过期项(StaleItem),再逐项重新解析依赖、生成 schema。它只写本地文件,不是部署,但因为它会重新解析依赖,所以可能升级未固定版本的依赖(与从 UI 部署的行为一致,属预期而非 bug)。
因此 SKILL.md 给出的默认策略是:主动提出并征得用户同意后运行,而不是每次编辑后静默执行——除非项目的AGENTS.md明确选择了自动运行元数据(参考"Keeping metadata in sync"偏好设置)。无论哪种模式,命令由你(Agent)执行。运行后要 diff 重新生成的.lock/.script.lock文件,并告知用户哪些依赖版本变了(例如requests 2.31.0 → 2.32.0),以便在部署前发现意外的版本升级——即使处于Metadata: auto模式下也应如此告知,因为这是信息而非确认门槛。要固定版本,就在代码里显式 pin。
不带路径参数时的增量语义
不带路径参数运行generate-metadata时,它只重新生成内容哈希漂移的条目,而非全部。Import 会传播:编辑一个被其他脚本 import 的脚本,会标记所有 importer 为过期,因此对共享模块的一行改动可能触发大量锁文件重新生成——这是设计使然(它们的锁必须反映被 import 的代码)。
如果影响范围超出预期,先用 dry-run 检查:
wmill generate-metadata --dry-run它只列出每个过期条目及原因(content changed或depends on <path>),不做任何修改。然后可以用路径参数收窄范围:
wmill generate-metadata f/foo或使用--strict-folder-boundaries限制在文件夹边界内。
rehash 子命令:仅刷新哈希
如果磁盘上的.lock和.script.yaml已经正确,只是wmill-lock.yaml的哈希需要刷新(哈希漂移,或引导缺失条目),使用:
wmill generate-metadata rehash它只从磁盘重新记录哈希,不做后端往返、不改变依赖。
DuckDB 脚本参数语法:注释定义 +$name引用
DuckDB 脚本在 Windmill 中的参数定义方式与其他语言不同:参数用注释声明,脚本体内用$name语法引用:
-- $name (text) = default -- $age (integer) SELECT * FROM users WHERE name = $name AND age > $age;-- $name (text) = default声明了一个类型为text、默认值为default的参数$name;-- $age (integer)声明了无默认值的整数参数$age。这些注释由解析器读取,生成.script.yaml输入 schema,进而在 Windmill UI 中自动渲染出参数表单。该 schema 正是上文wmill generate-metadata负责再生成的对象。
Ducklake 集成:一行 ATTACH 接入数据湖
Ducklake 是 Windmill 的托管数据湖层,DuckDB 脚本可以通过ATTACH直接挂载。SKILL.md 给出的两种形式:
-- Main ducklake ATTACH 'ducklake' AS dl; -- Named ducklake ATTACH 'ducklake://my_lake' AS dl; -- Then query SELECT * FROM dl.schema.table;第一行挂载主 Ducklake 实例,第二行挂载命名实例my_lake,之后即可用dl.schema.table三段式命名查询任意表。Ducklake 相关能力在仓库中有大量配套基础设施:包括建表迁移 backend/migrations/20250724084100_ducklake.up.sql、实例设置迁移、以及物化写入支持——在 backend/parsers/windmill-parser/src/sql_materialize.rs 中可以看到 DuckDB 物化写入会通过保留别名_wm_target解析真实的ATTACH 'ducklake:…'语句,并在写入后通过ducklake_snapshots('_wm_target')捕获快照 ID,说明 Ducklake 具备快照级的物化能力。
连接外部数据库:通过资源(Resource)ATTACH
DuckDB 可以借助 Windmill 的资源(Resource)体系连接外部数据库,资源中保存连接凭证与地址,脚本内用$res:前缀引用:
ATTACH '$res:path/to/resource' AS db (TYPE postgres); SELECT * FROM db.schema.table;$res:path/to/resource会解析为工作区中已配置的资源路径,(TYPE postgres)声明数据库类型。你可以在工作区内通过wmill resource-type list --schema查看可用的资源类型及其 schema,从而为 DuckDB 准备合适的连接资源。这种ATTACH形式属于被 worker 的 transform 阶段统一重写的受管 ATTACH('$res:、'ducklake:等前缀),在 backend/parsers/windmill-parser/src/duckdb_macros.rs 的is_managed_attach中有完整清单。
S3 文件操作:read_csv / read_parquet / read_json
DuckDB 的 S3 读取使用其原生 reader 函数,路径遵循 Windmill 的 S3 命名约定:
-- Default storage SELECT * FROM read_csv('s3:///path/to/file.csv'); -- Named storage SELECT * FROM read_csv('s3://storage_name/path/to/file.csv'); -- Parquet files SELECT * FROM read_parquet('s3:///path/to/file.parquet'); -- JSON files SELECT * FROM read_json('s3:///path/to/file.json');注意双斜杠与单斜杠的区别:s3:///(三个斜杠)表示默认存储桶,s3://storage_name/表示命名存储。支持read_csv、read_parquet、read_json等全部 DuckDB reader 函数,可根据数据格式自由选择。
以 S3Object 作为脚本参数:UI 文件选择器
当脚本需要接收一个 S3 文件时,把参数类型声明为(s3object)。Windmill 会在 UI 中为它渲染一个 S3 文件选择器,并在运行时把参数绑定为裸的s3://storage/keyURI——DuckDB 的 reader 函数可以直接消费这个 URI:
-- $file (s3object) SELECT * FROM read_parquet($file);这适用于任何 DuckDB reader:read_csv($file)、read_json($file)等均可用。
查询结果写回 S3:COPY ... TO
DuckDB 通过原生COPY ... TO写回 S3,无需额外扩展:
COPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET);务必注意:SKILL.md 明确指出,应使用COPY ... TO代替其他 SQL 方言支持的-- s3流式指令——该指令在 DuckDB 中不可用。
深入底层:DuckDB 宏库与 worker 侧的注入机制
虽然 SKILL.md 聚焦于参数与数据源,但仓库源码揭示了 DuckDB 脚本在 Windmill 中更深层的能力:工作区级宏库。一个// macros标注的脚本可以包含CREATE [OR REPLACE] [TEMP] MACRO语句及ATTACH/INSTALL/LOAD/SET/PRAGMA等 setup 语句,部署时被解析进宏注册表,运行时 worker 将(传递闭包内)被调用的宏以CREATE OR REPLACE TEMP MACRO块注入消费脚本——宏体以**逐字(verbatim)**方式存取,不做 AST 往返,任何 DuckDB 接受的表达式都能原样存活。
由于 DuckDB 在 CREATE 时即对宏体做绑定检查,注入定义必须按依赖顺序输出,因此实现了topo_order_macros(Kahn 算法,名称排序保证确定性,循环依赖报错)。同时,为避免工作区宏静默遮蔽 DuckDB 内置函数,backend/parsers/windmill-parser/src/duckdb_builtins.rs 维护了完整的内置函数名表做校验,宏名被限定为[A-Za-z_][A-Za-z0-9_]*的简单标识符。CLI 侧还提供了等价的 TypeScript 移植版(cli/src/commands/pipeline/duckdbMacros.ts),与后端保持逐行对齐。此外,DuckDB 也作为 dbt 的已知适配器被支持(backend/windmill-worker/src/dbt_profiles.rs),脚本内可通过-- use <lib>强制引入整库宏。
完整工作流:从编写到部署的推荐顺序
综合 SKILL.md 与源码实现,一个标准的 DuckDB 脚本迭代流程如下:
- 编写脚本:将
.sql(或.py、.ts等)文件放入同步仓库的脚本目录,用注释声明参数、用$name引用,按需加入ATTACH(Ducklake / 外部数据库 / S3)语句。 - 本地验证:运行
wmill script preview <script_path> -d '<args>'直接执行本地文件;若想用可视化方式在开发页打开脚本预览(而非运行打印结果),使用preview技能。 - 同步元数据:编辑了 import 或
main参数后,运行wmill generate-metadata(必要时先用--dry-run评估影响范围),并 diff 锁文件确认没有意外的依赖版本升级;仅需刷新哈希时用wmill generate-metadata rehash。 - 征得同意后部署:当用户明确要求发布/推送时,按仓库的接线方式通过
git push或wmill sync push部署到工作区——部署章节的细节以仓库中 AGENTS.md 与 cli/AGENTS.md 为准。
这条链路保证了:本地编辑永不污染工作区已部署版本,.script.yaml输入 schema 与锁文件始终与代码一致,git-sync 与 CI 不会产生虚假 diff,而真正的远端变更只发生在用户明确授权部署的那一刻。
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考