App::DuckETL — TUI-ETL-design-analysis
基于 DB::DuckDB 的 TUI ETL 工具设计分析
分析对象:
DB::DuckDB0.8.3(提交e0b1670) 运行时:Rakudo 2026.09 / DuckDBv2.0.0-dev1(WSL Ubuntu) 日期:2026-10-30 结论性质:设计建议。标注[实测]的条目有本机运行记录,见附录 A;未标注的是基于代码/文档的推断。
0. 结论先行
基于 DB::DuckDB 做 TUI ETL,有三个硬约束决定架构:
数据平面必须留在 DuckDB 里。 Raku 只负责编排 SQL 和渲染界面。仓库实测
COPY写 Parquet 约 0.03 µs/行,而execute-many/ appender 约 11.3 µs/行(约 400 倍差距);逐行进 Raku 还会触发 GC,TUI 会卡。DuckDB 文件库是跨进程独占的。
[实测]进程 A 打开/tmp/lock.duckdb后,进程 B 用DB::DuckDB.new(path => ...)直接失败;手写duckdb_open_ext(..., access_mode=READ_ONLY)在 writer 持锁时也返回失败。 所以 v1 不要设计成“TUI 进程 + 独立 worker 进程同时开同一个库”。TUI 线程不能碰慢查询。 所有可能超过 ~50 ms 的操作(schema 推断、预览、执行)走后台线程 + 独立 connection,UI 只消费事件和快照。
推荐 v1 形态:
单进程 TUI + 每个任务一条独立 connection + SQL-first 执行引擎 +
Terminal::UI做界面基座。
等需要崩溃隔离时,再加“worker 进程独占数据库、TUI 不打开数据文件”的模式。
1. 产品边界与交互设计
1.1 范围
先做“本机单用户、文件 / DuckDB 为源、Parquet / CSV / 表为目标”的 ETL:
不做调度平台
不做多租户
不做远程编排
不做通用工作流 DAG(v1 线性步骤;后续再加
depends_on)
核心闭环:
选源 → 推断 schema → 预览 → 定义步骤 → 试跑 → 看进度/日志 → 落表/落文件 → 存 pipeline1.2 五屏
| 屏 | 内容 | 关键操作 |
|---|---|---|
| Dashboard | 最近运行、库/表统计、快速入口 | r 重跑、Enter 进详情 |
| Sources | 文件浏览、格式识别、schema 推断、样本 | Enter 作为 source 加入 pipeline |
| Pipeline | 步骤列表、每步表单、SQL | a/d 增删、e 外置编辑器、F2 运行 |
| Run | 步骤状态、行数/速率、日志、取消 | F3 取消、l 看完整日志 |
| Data | 目录树、表结构、分页网格、SQL 控制台 | p 预览、x 导出 |
| Settings | threads/memory、主题、键位 | — |
键位用 vim 风格 + 功能键;: 命令面板;破坏性操作(覆盖表、删除 pipeline)必须 ui.alert 二次确认。
1.3 主布局
┌ ETL ─ nightly_orders ─────────────────────────── ▶ running 4/7 ─┐
│ Pipelines │ Steps │ Run │
│ ▸ nightly │ 1 extract csv raw/*.csv ok │ 00:01:23 │
│ ▸ daily │ 2 clean sql ok │ 1.24M rows │
│ Sources │ 3 join customers run │ 12.3k rows/s │
│ ▸ raw/orders │ 4 load parquet · │ │
│ ▸ mart/orders │ │ Preview │
│ │ SQL select id, amount ... │ id amount │
│ │ from extract where ... │ 1 12.50 │
├───────────────┴────────────────────────────────┴────────────────┤
│ F1 help F2 run F3 cancel F5 edit p preview : cmd q quit │
└─────────────────────────────────────────────────────────────────┘最小终端建议 100×28;低于阈值降级为单栏堆叠。
2. 总体架构
关键点:
一个
DB::DuckDB实例打开数据文件;$db.connect给每个 job 一条 connection。[实测]同一进程 4 条 connection 并行查询正常,session 连接不受影响。UI 自己的 session connection 只做 catalog / 小预览;不要和 job 共用。
history 用另一个
.duckdb文件(如~/.local/share/ducketl/history.duckdb),避免和数据库抢锁,也避免运行历史污染业务库。任务线程:
start { ... }+Channel事件。写目标串行化(每个目标表一把Lock::Async),读可以并行;DuckDB 同进程多连接支持一写多读。可选隔离模式:
--isolated时 worker 进程独占数据文件,TUI 只通过 JSONL 收事件;此时 TUI 不能同时浏览数据库。作为后续能力,不进 v1 关键路径。
2.1 为什么不是“UI 进程 + worker 进程”
[实测] 跨进程打开直接被文件锁拒绝。可行的多进程方案只有两种:
worker 独占数据库,TUI 完全不开数据文件(通过 IPC 查元数据/预览)——实时浏览体验差。
数据文件拆成两个(只读快照 + 写入库)——同步复杂,v1 不值得。
所以 v1 用单进程;等 M3 再评估隔离模式。
3. 数据模型与执行引擎
3.1 Pipeline 文件格式
建议 JSON 作为 canonical(JSON::Fast 可读写);YAML 只做导入。
原因:Raku 生态有 YAML::Parser::LibYAML,但 [实测] zef 生态中没有可用的 YAML::Dumper;TUI 内保存 YAML 需要自己写受限 emitter。JSON 更省事。
{
"name": "nightly_orders",
"defaults": { "threads": 8, "memory_limit": "2GB" },
"steps": [
{
"id": "extract",
"type": "source",
"format": "csv",
"path": "raw/orders_*.csv",
"options": { "header": true, "delim": ",", "sample_size": 100000 }
},
{
"id": "clean",
"type": "sql",
"sql": "select id, customer_id, cast(amount as decimal(18,2)) amount, cast(created_at as timestamp) created_at from extract where amount is not null"
},
{
"id": "load",
"type": "sink",
"target": "mart.orders",
"mode": "upsert",
"key": ["id"],
"watermark": "created_at"
}
]
}步骤类型先做四种:
| 类型 | 作用 | 关键字段 |
|---|---|---|
source | 读 CSV / Parquet / JSON / 表 / SQL | format、path、options |
sql | 任意 SELECT,引用前一步 id | sql |
assert | 数据质量断言 | sql、expect |
sink | 写 table / Parquet / CSV | target、mode、key、watermark |
3.2 SQL 生成
source:
create or replace temp table _etl_extract as
select * from read_csv_auto(?, header=true, delim=',', sample_size=100000);路径必须走参数绑定(read_csv_auto(?)),不要拼字符串。COPY TO 的目标路径才需要字面量,复用仓库 DB::DuckDB::Parquet 里的 literal / identifier 转义逻辑。
中间步骤默认物化到 temp table,这样每步可预览、可计数、可断点调试:
create or replace temp table _etl_clean as
select ... from _etl_extract where ...;对性能敏感的 pipeline 提供 "materialize": false,把整条链压成一个 CTE / 单条 CREATE TABLE AS SELECT。
默认策略:source / sink 物化,中间 sql 步骤可配置。
sink:
-- 全量
create or replace table mart.orders as select * from _etl_clean;
-- upsert(本机 v2.0.0-dev1 实测 ON CONFLICT 可用)
insert into mart.orders select * from _etl_clean
on conflict (id) do update set ...;Parquet 直接用驱动现成方法:
$db.write-parquet('out/orders.parquet', 'select * from _etl_clean',
:sql, :compression<zstd>);CSV 用 SQL:
copy (select * from _etl_clean) to 'out.csv' (header, delimiter ',')文件 sink 一律写临时文件再原子 rename,避免半成品。
3.3 运行元数据
用 DuckDB 自己存:
_etl.runs(run_id, pipeline, started_at, finished_at, status, rows, error)
_etl.steps(run_id, step_id, status, started_at, finished_at, rows, sql, error)
_etl.watermarks(pipeline, step_id, column, value)状态机:
pending → running → ok | failed | canceled | skipped失败步骤保留已物化的 temp 表,方便在 UI 里查看;取消时 rollback 当前事务并 drop temp 表。
3.4 取消与进度
取消:
duckdb_interrupt已经在lib/DB/DuckDB/Native.rakumod绑定(第 333 行),但没有公开方法。加Connection.interrupt,从 UI 线程对 job connection 调用;这是 v1 取消按钮的唯一正确实现。进程级 kill 只作为兜底。进度:
duckdb_query_progress存在于 libduckdb 2.0(duckdb.h第 3716 行),但返回结构体 by value;驱动刻意回避 by-value 结构体返回(README Design notes),建议照duckdb-result-shim.c写一个小 shim 返回指针。没有 progress 的语句(如
COPY)用“心跳 + 已完成步骤 + 行数”展示,不要假装有精确百分比。事件协议(进程内
Channel,或隔离模式下的 JSONL):
{"run":"20261030-1201","step":"load","phase":"progress","rows":1240000,"elapsed_ms":83000}3.5 增量与校验
水位:
where created_at > (select value from _etl.watermarks where ...);成功提交后再更新水位。upsert:
ON CONFLICT已实测可用;MERGE未验证,不要先承诺。校验:
SUMMARIZE SELECT * FROM _etl_clean已实测可用,能直接给出 min / max / approx_unique / null_percentage,适合做“数据质量”面板。dry run:
EXPLAIN+SELECT * FROM (<sql>) LIMIT 0,不执行数据。
4. TUI 实现方案
4.1 选型
| 方案 | 判断 |
|---|---|
Terminal::UI | 推荐 v1。0.1.13(fez)/ 0.1.14(仓库),screen / frame / pane、滚动、选中、键位绑定、ui.alert、pause-and-do,README 明确“thread safe”。它是行式 pane,不是 widget 树,所以表单 / 表格 / SQL 编辑器要自己薄薄一层。 |
Terminal::Widgets | 0.1.5,作者自己写“very early、API 一定会变”。除非愿意 vendor,不建议作为产品基座。 |
Notcurses::Native | 0.8.0,渲染能力最强,但基本从零搭 UI,v1 不划算。 |
| 裸 ANSI + termios | 仅在 Terminal::UI 卡住关键需求时考虑;事件循环、resize、滚动都要自己写。 |
4.2 关键做法
纯函数渲染:每个 widget 提供
render($state, $width, $height --> List[Str]),输出直接$pane.put(@lines)。这样 80×24 / 120×40 / CJK 都能做 golden test,不需要 PTY。虚拟表格:只渲染视口。预览查询固定
LIMIT 100(或 keyset 分页);流式预览用:stream读第一块(README 实测首块 12–20 ms)后立即free,绝不对全量结果调.hashes。SQL 编辑器:Raku 没有成熟的 TUI 代码编辑器。短 SQL 用单行 buffer + 自绘光标;长 SQL 用
ui.pause-and-do调$EDITOR/$VISUAL,回来重新解析。这是最省事且可靠的做法。输入框:
Terminal::UI的Input只提供原始按键,没有行编辑;自己实现一个 50 行的单行编辑器(插入 / 删除 / Home / End / 粘贴)即可。不要把Linenoise/Readline塞进全屏 TUI,它们会抢 tty。重绘节奏:10–20 Hz;事件合并,只有 dirty pane 重绘;日志 pane 用 5k 行环形缓冲。
异常安全:
LEAVE/CX::Done里ui.shutdown,否则崩溃后终端留在 raw mode。宽度:CJK 宽字符会让表格错位。
Terminal::UI声称 Unicode 兼容,但先用中文列名 / 中文值做验收;不行就自带一张wcwidth表(约 100 行)。兼容性:
NO_COLOR、Terminal::Capabilities判断真彩色 / 256 色;边框提供 ASCII 回退。
4.3 线程模型
UI 线程:按键 → 状态机 → 渲染快照;只做 <16ms 的工作
Job 线程:独立 connection;执行 SQL;向 Channel 发事件
Preview 线程:LIMIT 查询;可取消;结果只回传 ≤200 行DB::DuckDB::Result / Chunk 不要跨线程传递;chunk 只在下一次 pull 前有效。事件里只传普通 Hash / List。
5. 库清单
5.1 必需
| 用途 | 库 | 版本 / 状态 | 说明 |
|---|---|---|---|
| 数据库 | DB + DB::DuckDB + Alien::Libduckdb | 本仓库 0.8.3 / libduckdb 2.x | 驱动已固定 ABI,别随手升级 libduckdb |
| TUI 基座 | Terminal::UI | 0.1.13(fez)/ 0.1.14(仓库) | pane / 滚动 / 键位 / alert;依赖 Terminal::ANSI、Terminal::ANSIParser、Unicode::UTF8-Parser、Log::Async |
| 颜色 / 尺寸 | Terminal::ANSIColor 0.14、Terminal::Size | 生态可用 | 颜色与终端尺寸 |
| 终端能力 | Terminal::Capabilities | 0.0.17 | 真彩色 / 256 色检测 |
| JSON | JSON::Fast | 0.21.0 | pipeline、事件、配置 |
| 日志 | Log::Async | 0.0.14 | 异步日志,避免阻塞 UI |
| 并发 | 核心 Supply / Channel / Promise / Proc::Async | Rakudo 内置 | job 线程、外部编辑器、隔离 worker |
5.2 按需
| 用途 | 库 | 说明 |
|---|---|---|
| YAML 导入 | YAML::Parser::LibYAML | 只读解析;回写用 JSON |
| 静态表格 | Text::Table::Simple / Terminal::Table 0.0.5 | 仅 CLI --list、报表;不要用于可滚动网格 |
| CSV 预校验 | CSV::Parser | 一般不需要,DuckDB 直接读 |
| 非全屏 CLI | Linenoise / Readline | 仅 etl repl 之类的独立模式 |
| 阶段指示 | Terminal::Spinners | 非全屏命令 |
| HTTP / XML 源 | Cro::HTTP / HTTP::UserAgent / XML | 后续插件,不进 v1 |
| 测试 | Test | widget 纯函数 golden test;PTY 冒烟用外部 tmux / script |
5.3 不推荐 / 不可用
| 库 | 结论 |
|---|---|
YAML::Dumper | [实测] zef 生态无可用版本;pipeline 回写用 JSON |
TOML::Parser | [实测] 未找到;配置统一 JSON / YAML |
Unicode::GCString / Text::Width | [实测] 未找到;CJK 宽度自己处理 |
Terminal::Widgets | 作者明确 very early;产品基座不建议 |
Terminal::Plot / Terminal::Graph | 未找到;图表需求用简单 sparkline 自绘 |
5.4 依赖声明
建议 独立发行版 App::DuckETL 依赖 DB::DuckDB,不要把 TUI 依赖塞进驱动本体。
"depends": [
"DB", "DB::DuckDB", "Alien::Libduckdb",
"Terminal::UI", "Terminal::ANSIColor", "Terminal::Size",
"Terminal::Capabilities", "JSON::Fast", "Log::Async"
]6. 需要给驱动补的 API
| 缺口 | 现状 | 用途 |
|---|---|---|
Connection.interrupt | duckdb_interrupt 已绑定,无公开方法 | 取消按钮 |
duckdb_query_progress | 未绑定;返回 by-value 结构体 | 真实进度 |
duckdb_open_ext + config | 未绑定 | access_mode=READ_ONLY、open 时设置 threads / memory |
Result.rows | README 写了,实际没有方法([实测] 报 No such method 'rows') | 计数;可先 .arrays.elems |
| CSV / JSON 读取 | 不需要驱动改动 | read_csv_auto / read_json_auto 已实测可用 |
SUMMARIZE / ON CONFLICT / COPY TO | 不需要驱动改动 | [实测] v2.0.0-dev1 可用 |
扩展情况([实测] duckdb_extensions()):已装 core_functions, icu, json, parquet;没有 httpfs / sqlite / postgres / excel。
远程 HTTP、SQLite、Postgres、Excel 源需要 INSTALL/LOAD 扩展,先按“可选插件”设计,并单独验证网络与扩展加载,不要写进 v1 的核心路径。
7. 推荐目录结构
ducketl/
├── META6.json
├── bin/ducketl
├── lib/App/DuckETL.rakumod
├── lib/App/DuckETL/CLI.rakumod
├── lib/App/DuckETL/Config.rakumod
├── lib/App/DuckETL/Pipeline.rakumod
├── lib/App/DuckETL/Planner.rakumod
├── lib/App/DuckETL/Executor.rakumod
├── lib/App/DuckETL/Duck.rakumod
├── lib/App/DuckETL/Run.rakumod
├── lib/App/DuckETL/Store.rakumod
├── lib/App/DuckETL/TUI.rakumod
├── lib/App/DuckETL/TUI/Screen/Dashboard.rakumod
├── lib/App/DuckETL/TUI/Screen/Sources.rakumod
├── lib/App/DuckETL/TUI/Screen/Pipeline.rakumod
├── lib/App/DuckETL/TUI/Screen/Run.rakumod
├── lib/App/DuckETL/TUI/Screen/Data.rakumod
├── lib/App/DuckETL/TUI/Widget/Grid.rakumod
├── lib/App/DuckETL/TUI/Widget/Form.rakumod
├── lib/App/DuckETL/TUI/Widget/Editor.rakumod
├── lib/App/DuckETL/TUI/Widget/LogPane.rakumod
├── t/
└── examples/pipelines/nightly_orders.json职责边界:
Pipeline:JSON 模型 + 校验 + 步骤引用解析Planner:步骤 → SQL(纯函数,golden test 的重点)Executor:事务、取消、进度、事件Duck:连接管理、catalog、schema 推断、分页预览Store:history / watermarksTUI:只依赖Run事件和Duck的只读快照
8. 里程碑
| 阶段 | 交付 | 验收 |
|---|---|---|
| M0 引擎先行 | ducketl run pipeline.json(无 TUI)、SQL 生成器、history 表、失败回滚 | 1M 行 CSV → Parquet;故意让第 3 步失败,目标表无残留 |
| M1 只读 TUI | Dashboard + Sources + Run 屏;catalog / 预览;运行 / 取消 | 1M 行任务运行时 UI 仍可切换屏;取消 1 秒内生效 |
| M2 编辑闭环 | Pipeline 屏、表单、$EDITOR 写 SQL、断言、保存 JSON | 从零在 TUI 建 3 步 pipeline 并跑通 |
| M3 生产化 | 增量水位、upsert、分区写、隔离 worker、插件 | 重复运行幂等;worker 崩溃不拖垮 TUI |
9. 主要风险与对策
| 风险 | 证据 | 对策 |
|---|---|---|
| 跨进程锁 | [实测] 第二个进程打开失败;read-only 也失败 | v1 单进程;隔离模式让 worker 独占文件 |
| Raku GC 拖慢 TUI | 驱动解码 / 装箱有成本 | 数据留在 SQL;预览 LIMIT / :stream;禁止全量 .hashes |
Terminal::UI 是 0.1.x | 版本与 API 都还早 | 锁版本;渲染层纯函数化,便于换基座 |
| CJK 列宽错位 | 未验证宽字符宽度处理 | 先做中文列名验收;必要时自带 wcwidth |
| 扩展源不可用 | [实测] 只装了 4 个扩展 | 核心只做文件 + DuckDB;扩展源做插件并探测 |
| DuckDB 2.x ABI 固定 | README 明确 pin Alien::Libduckdb:ver<1.1.0> | 不随系统 libduckdb 漂移;升级走 t/ 全量回归 |
| SQL / 路径注入 | pipeline 本身是可信代码 | 路径全部参数绑定;标识符 / 字面量走驱动转义;密钥走环境变量 |
| 单写者冲突 | DuckDB 同进程也是单写事务 | 每个目标表串行化;读任务并行 |
10. 待拍板的问题
| # | 选择 | 推荐 | 理由 |
|---|---|---|---|
| 1 | 单进程 vs worker 隔离 | 单进程 | 跨进程文件锁实测不可行;隔离模式作为 M3 选项 |
| 2 | Terminal::UI vs Terminal::Widgets | Terminal::UI | 前者可用且线程安全;后者作者自述 very early |
| 3 | JSON vs YAML 作为 canonical | JSON | 无 YAML dumper;JSON 可稳定回写;YAML 仅导入 |
| 4 | v1 是否支持远程 DB / HTTP / 调度 | 不支持 | 扩展未装;先把本地文件 ETL 闭环做稳 |
附录 A:本机实测记录
环境:Rakudo 2026.09,DuckDB v2.0.0-dev1,WSL Ubuntu,DB::DuckDB 0.8.3 源码树。
A.1 SQL 能力
| 能力 | 语句 | 结果 |
|---|---|---|
| CSV 读取 | select count(*), sum(amt) from read_csv_auto(?) | 2 行,sum=4 |
| JSON 读取 | select count(*) from read_json_auto(?) | 2 行 |
| 数据剖析 | summarize select * from read_csv_auto(?) | 3 行结果,12 列(column_name … null_percentage) |
| CTAS | create table t as select * from read_csv_auto(?) | 2 行 |
| Upsert | insert ... on conflict (id) do update ... | 成功 |
| Parquet 写 | copy t to 'out.parquet' (format parquet) | 文件生成,回读 2 行 |
| 扩展 | select * from duckdb_extensions() where installed | core_functions, icu, json, parquet |
A.2 并发与锁
| 场景 | 操作 | 结果 |
|---|---|---|
| 同进程多连接 | $db.connect × 4,并行 select count(*) | 4 个结果均为 1000;session 查询仍为 42 |
| 跨进程写 | A 打开文件库并 sleep;B DB::DuckDB.new(path=>...) | B 失败 |
| 跨进程只读 | A 持锁;B duckdb_open_ext(..., access_mode=READ_ONLY) | 返回 DuckDBError(错误串未成功取出) |
A.3 驱动 API
| 检查 | 结果 |
|---|---|
Result.rows | 不存在,报 No such method 'rows' |
duckdb_interrupt | 符号已绑定,无公开方法 |
duckdb_query_progress | 库中存在,驱动未绑定 |
duckdb_open_ext / duckdb_create_config | 库中存在,驱动未绑定 |
read_csv_auto / read_json_auto | 无需驱动改动 |
A.4 库生态检索
zef search 结果(2026-10-30):
| 模块 | 结果 |
|---|---|
Terminal::UI | 0.1.13(fez)/ 0.1.14(仓库 META) |
Terminal::Widgets | 0.1.5,自述 very early |
Notcurses::Native | 0.8.0 |
Terminal::ANSIColor | 0.14 |
Terminal::Capabilities | 0.0.17 |
Log::Async | 0.0.14 |
JSON::Fast | 0.21.0 |
Terminal::Table | 0.0.5 |
Text::Table::Simple | 可用 |
YAML::Parser::LibYAML | 可用 |
CSV::Parser | 可用 |
YAML::Dumper | 未找到 |
TOML::Parser | 未找到 |
Unicode::GCString / Text::Width | 未找到 |
附录 B:复现实验的最小脚本
use DB::DuckDB;
my $db = DB::DuckDB.new;
say $db.query('select version()').value;
# CSV / JSON / SUMMARIZE / CTAS / ON CONFLICT / COPY TO
$db.execute("create table t as select * from read_csv_auto(?)", 'a.csv');
say $db.query("summarize select * from t").arrays.elems;
$db.execute("copy t to '/tmp/out.parquet' (format parquet)");
say $db.query("select count(*) from read_parquet('/tmp/out.parquet')").value;跨进程锁复现:
# A:打开文件库并持有
raku -Ilib -e 'use DB::DuckDB; my $db = DB::DuckDB.new(path => "/tmp/lock.duckdb"); $db.execute("create table if not exists t(x int)"); sleep 6'
# B:同时打开
raku -Ilib -e 'use DB::DuckDB; my $ok = try { DB::DuckDB.new(path => "/tmp/lock.duckdb").query("select 1"); True } // False; say $ok'
# => False附录 C:一句话落地顺序
先写 M0 的 SQL 生成器 + ducketl run,用真实 CSV / Parquet 跑通;再套 Terminal::UI 的三栏壳;最后补编辑器、进度和增量。
TUI 不是难点,**锁模型和“数据不过 Raku”**才是这个项目的生死线。