Windmill 数据指标层(Pipeline Metrics Layer):在 DuckLake 物化脚本中声明度量和维度
【免费下载链接】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
导读
data_metric(度量目录)是 Windmill 中为 DuckLake 物化表声明"权威聚合口径"的一种轻量机制:直接在物化脚本的注释头里写下// measure与// dimension,部署时将其收录进目录表,再交由脚本编辑器抽屉与 AI Agent 读取,让写查询的人(无论人还是模型)在动手聚合之前先看到这张表的"标准答案"。本指南以 docs/pipeline-metrics-layer.md 为主线,结合 data_metrics.rs、迁移脚本、读取端点 等源码,完整讲解声明语法、目录存储、访问模式、授权模型、两个消费端(编辑器抽屉与 MCP Agent 工具)以及部署期的安全校验,帮助你在自己的流水线中落地一套"先声明、后查询"的指标口径实践。
这个功能是什么
在 Windmill 中,DuckLake 表由 DuckDB 脚本通过// materialize注释物化(参见 ducklake-materialization.md)。指标层允许你在同一段注释头里,把这张表"规范的聚合方式"声明出来:
-- materialize ducklake://sales/orders -- measure revenue = sum(amount) where not is_refund -- measure order_count = count(*) -- dimension region = region -- dimension month = date_trunc('month', ordered_at) SELECT ...这些声明在部署(deploy)时被解析、收录进data_metric目录,随后被两类消费方读取:
- 脚本编辑器抽屉(drawer):根据度量/维度选择在客户端拼装出一条普通
SELECT,供用户复制、在 REPL 中执行或插入当前脚本; - Agent 工具(MCP tool):列出某张表或某个文件夹下声明了什么,让 Agent 直接使用声明的
expr/filter,而不是自己臆造聚合。
关键在于——该功能只负责记录定义并把定义交给写查询的人,它不重写、不编译、也不执行任何 SQL。正如 data_metrics.rs 模块注释所写的:"Nothing here rewrites or executes SQL: the catalog is read by the script editor and by agents, which compose their own queries."
它刻意不做什么
文档明确划出了三条边界,理解它们才能正确使用这个功能:
它不强制任何东西。Agent 或人可以无视已声明的 measure,直接写不带退款过滤的sum(amount),没有任何机制能拦住。设计赌注是:大多数错误数字源于"不知道规则"而非"故意违反规则",让写查询的人知情,就已经实现了绝大部分价值。
它不是语义层(semantic layer)。声明是单表的:没有 join planner,也没有跨粒度(grain)的扇出安全聚合。要跨表组合,开发者需要用流水线步骤物化一张 mart 表,并在该表上声明度量。
没有查询时间接层。部署什么就跑什么。若在部署时把{{ metric(...) }}这类 token 编译成冻结 SQL、再由 worker 替换,会导致编辑器中的脚本不再是实际执行的脚本,任务日志里也会出现源码中不存在的 SQL——这正是该设计坚决回避的。
为什么没有编译器
文档给出了一个务实的权衡分析:编译器相比"把定义交给调用方"多买到两样东西——无 LLM 参与的逐字节一致 SQL,以及强制力。而这两者只有在一个前提下才有价值:多个相互独立的消费方依赖同一个数字可复现。当前代码库中还没有出现这种情况的证据。在那之前,编译器意味着 token 语法、按脚本哈希做部署期冻结、worker 替换路径,以及"编辑器与运行时不一致"的第二个来源,纯属额外成本。
如果未来证据出现,目录(catalog)正是建造它的正确地基:声明已经是结构化且经过校验的。
编写声明:注释头语法
两条注解都写在脚本开头的注释头里,紧挨着// materialize:
// measure <name> = <aggregate> [where <predicate>]// dimension <name> = <expression>
其中expr和filter是作者自己的 SQL,原样存储(verbatim)。谓词刻意与聚合分开存放、而不是折叠进聚合里,是因为读取方会把它渲染成expr FILTER (WHERE filter)——这正是让两个带不同谓词的 measure 能共存于同一个GROUP BY之下的原因,共享的WHERE表达不了这一点。
声明挂在脚本所物化的表上,因此没有 ducklake// materialize目标的脚本不产生任何目录行——没有表可以挂载它们。这一点在 sync_metric_catalog 中实现:解析注解后,若materialize目标缺失或不是AssetKind::Ducklake,直接返回。
目录(The Catalog)
表结构与维护策略
data_metric每行存放一条声明,部署时按脚本路径整体替换(先 DELETE 该路径旧行、再 INSERT 新行),因此它永远描述"已部署状态",与asset表的维护方式一致。迁移脚本 20260720050649_data_metric_catalog.up.sql 定义了表结构:
CREATE TABLE data_metric ( workspace_id VARCHAR(50) NOT NULL REFERENCES workspace (id) ON UPDATE CASCADE ON DELETE CASCADE, script_path VARCHAR(510) NOT NULL, -- 声明方脚本,也是权限锚点 table_path VARCHAR(510) NOT NULL, -- 规范化后的 ducklake 路径 <lake>/<schema>.<table> kind VARCHAR(16) NOT NULL CHECK (kind IN ('measure', 'dimension')), name VARCHAR(255) NOT NULL, expr TEXT NOT NULL, filter TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (workspace_id, script_path, kind, name) );"持久化而非按需解析"是文件夹级列表查询便宜的关键:读单张表的声明无论如何都是点查,但"列出f/analytics下声明的所有指标"若按需解析,则每次 Agent 调用都要抓取并解析文件夹内每个脚本的正文。持久化后,两种访问模式都是单次索引扫描,靠三个索引支撑:
idx_data_metric_table (workspace_id, table_path)——编辑器抽屉的"这张表声明了什么";idx_data_metric_page (workspace_id, table_path, kind, name, script_path)——列表端点的排序键,让 keyset 分页每页都是有序索引范围扫描,而不是每次请求都重新排序整个目录;idx_data_metric_folder (workspace_id, script_path text_pattern_ops)——Agent 工具的文件夹前缀查询。这里特意使用text_pattern_ops:数据库的 collation 不是C,在 locale collation 下,规划器不会为前缀匹配使用默认 opclass 的索引。文档记录了一个 50k 行的实测:默认 opclass 下是 seq scan,改用text_pattern_ops后才是索引范围扫描。
部署时,sync_metric_catalog会同时清理old_path与script_path两处的旧行,确保脚本重命名不会在其不再占用的路径上留下孤儿声明(见 data_metrics.rs)。
规范化表路径(Canonical table paths)
生产者与消费者对同一张表的写法不同:生产者写// materialize ducklake://sales/orders(省略 schema),而消费者读到的资产名可能是sales.main.orders。两者必须归一化到同一个目录键,否则消费者永远解析不到它读的那张表的声明。canonical_table_path统一转换为<lake>/<schema>.<table>、默认 schema 为main(data_metrics.rs):
canonical_table_path("ducklake://sales/orders") == "sales/main.orders" canonical_table_path("sales/orders") == "sales/main.orders" canonical_table_path("sales/analytics.orders") == "sales/analytics.orders" -- 显式 schema 保留测试 data_metrics.rs 测试模块 验证了这三条路径规则。
授权模型
data_metric表没有 RLS。读取时通过 authed 连接上的EXISTS子查询过滤script表,从而由script表既有的文件夹(folder)、组(group)、用户(user)策略决定调用方能看到什么(见 list_metrics 的 SQL 片段)。因此声明方脚本路径是目录键的一部分:DuckLake 路径没有可供授权的文件夹。
此外,带 path scope 的 token 会被单独过滤:build_scope_path_filter(&authed, "data_metrics", "read")使用独立的data_metricsscope 域(而非scripts的别名,否则会放行所有/scripts路由),并把 scope 过滤推进 SQL 里而不是取回后再过滤——这样 keyset 游标(最后一行的位置)永远不会指向调用方看不到的行,且每页大小不会泄露越权声明的数量。
消费方一:脚本编辑器抽屉
每个 DuckDB 脚本的编辑器栏都有一个Metrics trigger,在窄宽度下它收纳进紧凑的 Helpers 菜单。抽屉的功能流程(实现在 MetricsDrawer.svelte):
- 列出声明了指标的 DuckLake 表供浏览;
- 根据度量/维度选择,在客户端拼装一条普通
SELECT; - 提供三种去向:
- 复制:一条完整的独立查询,自带
ATTACH把 lake 挂到dl别名下; - 在嵌入式 REPL 里运行:直接对着 lake 执行预览;
- 追加到脚本:复用脚本已挂的别名——因为重复执行
ATTACH不会生效,所以插入版会去掉自己的ATTACH(源码第 92/107-115 行的注释说明了这一逻辑)。
- 复制:一条完整的独立查询,自带
输出是普通的可编辑 SQL,与目录没有任何回链——用户改完即为己有,这正是"不重写、不编译、不执行"哲学在前端的体现。
抽屉的 REPL 每次执行都会跑一个 preview job,所以它是"验证一个指标"的地方,而不是仪表盘:没有图表、没有过滤器构造器。
消费方二:Agent 工具(MCP)
同一个列表端点以x-mcp-tool: true暴露为 MCP 工具listDataMetrics(见 openapi.yaml 与 auto_generated_endpoints.rs 中的注册)。Agent 可以问"这张表声明了什么"或"这个文件夹下声明了什么",并使用声明的expr/filter而不是自己发明聚合。OpenAPI 的 tool 描述本身就是给 Agent 的使用手册:
Call this before writing any aggregate query over a DuckLake table. A declared measure is the canonical definition of that number… Use each returned
exprverbatim, and when a measure has afilterwrite it asexpr FILTER (WHERE filter)so measures with different predicates can share one GROUP BY. If a number you need has no declared measure, write your own aggregate as usual.
端点的分页是 keyset(游标)分页:排序键为(table_path, kind, name, script_path),四个cursor_*参数必须一起传(部分游标会被拒绝),返回next_cursor表示还有更多。总工作量对全目录是线性的——offset 分页会重复读取之前所有页。per_page上限 1000。path_prefix匹配路径本身及其所有后代,但对%、_做了转义、并按/边界锚定,因此f/analytics不会误匹配f/analytics2(见 descendants_of)。
部署期的校验:硬拒绝 vs 建议性告警
因为读取方(REPL、Agent 拼装的查询)会执行存储的文本,部署时有三类输入被硬性拒绝(都在 sync_metric_catalog 中实现):
- 不安全的 lake/table 路径:lake 名会被插进读取方执行的
ATTACH 'ducklake://<lake>' …字符串,引号或分号就是存储型 SQL 注入。is_safe_table_path只允许字母、数字与_ - . /。 - 不是单一 SQL 表达式的 measure/dimension 正文:
count(*) FROM t; DELETE …若被存下,会在任何打开抽屉的人身份下执行。校验用windmill_parser_sql_asset::is_single_sql_expression(实现见 lib.rs)。注意尾随注释也会被拒绝——解析器跳过注释后,sum(amount) --rest无法被确认是单表达式。 - 带
where过滤的 measure 不是单一聚合调用:sum(a)/count(b) where …编译为FILTER (WHERE …),而 SQL 会把FILTER绑定到其中一个聚合上,只过滤表达式的一部分、静默产生错误数字。因此带过滤的 measure 必须是sum(amount)这类单一聚合调用(is_single_function_call)。
这三类拒绝的理由在单元测试里都有对应用例,例如 data_metrics.rs 测试模块:
assert!(!is_single_sql_expression("1; DROP TABLE t")); assert!(!is_single_sql_expression("sum(amount) --rest")); assert!(!is_single_sql_expression("sum(amount) /* c */")); assert!(!is_single_function_call("sum(amount) / count(*)")); assert!(!is_safe_table_path("dl';SELECT(1);--/main.orders"));其余一切都是建议性的:缺列(missing-column)与非聚合 measure 的警告来自独立的check_schema_contracts端点(scripts.rs),编辑器在保存后 fire-and-forget 调用它,警告永不阻塞部署。CLI 或直接 API 部署会跳过这些警告——一条 SQL 本身错误但过了语法关的声明,只有真正运行它时才会被发现。
另外两处边界值得注意:列宽超限(name最长 255、table_path最长 510 字符)也会在部署时以清晰消息拒绝,而不是抛 Postgres 的 "value too long";sync_metric_catalog对每个语言都会执行(不只是 DuckDB),因为"脚本删掉了声明、改了语言、或在同路径被替换"都必须清掉旧行。
局限与后续方向
- 声明只存在于物化后的 DuckLake 表上;目前无法为 Postgres 表或 API 结果声明 measure。
- 抽屉 REPL 每次执行一个 preview job,是验证指标的场所而非仪表盘;没有图表或过滤器构造器。
- 抽屉在 TypeScript 里拼 SQL,Agent 自己拼 SQL,二者因读取同一份声明而语义一致,但不是逐字节一致;只有未来要求完全相同的 SQL(即上文说的编译器场景)时才会成为问题。
- 硬拒绝针对三类存储型风险;其余质量问题靠
check_schema_contracts的建议性警告兜底,而 CLI/直连 API 部署会跳过这些警告。
需要深入代码的读者可继续阅读:目录写路径 sync_metric_catalog、目录读路径 list_metrics、建表迁移、部署集成点、OpenAPI 端点定义、前端抽屉实现。指标层的声明与缺列告警也属于 schema_contracts.rs 负责的资产契约检查的一部分,可与 pipelines-vs-dbt.md 对照阅读。
【免费下载链接】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),仅供参考