App::DuckETL — TUI-ETL-design-analysis

基于 DB::DuckDB 的 TUI ETL 工具设计分析

分析对象:DB::DuckDB 0.8.3(提交 e0b1670) 运行时:Rakudo 2026.09 / DuckDB v2.0.0-dev1(WSL Ubuntu) 日期:2026-10-30 结论性质:设计建议。标注 [实测] 的条目有本机运行记录,见附录 A;未标注的是基于代码/文档的推断。

0. 结论先行

基于 DB::DuckDB 做 TUI ETL,有三个硬约束决定架构:

  1. 数据平面必须留在 DuckDB 里。 Raku 只负责编排 SQL 和渲染界面。仓库实测 COPY 写 Parquet 约 0.03 µs/行,而 execute-many / appender 约 11.3 µs/行(约 400 倍差距);逐行进 Raku 还会触发 GC,TUI 会卡。

  2. DuckDB 文件库是跨进程独占的。 [实测] 进程 A 打开 /tmp/lock.duckdb 后,进程 B 用 DB::DuckDB.new(path => ...) 直接失败;手写 duckdb_open_ext(..., access_mode=READ_ONLY) 在 writer 持锁时也返回失败。 所以 v1 不要设计成“TUI 进程 + 独立 worker 进程同时开同一个库”。

  3. 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 → 预览 → 定义步骤 → 试跑 → 看进度/日志 → 落表/落文件 → 存 pipeline

1.2 五屏

屏内容关键操作
Dashboard最近运行、库/表统计、快速入口r 重跑、Enter 进详情
Sources文件浏览、格式识别、schema 推断、样本Enter 作为 source 加入 pipeline
Pipeline步骤列表、每步表单、SQLa/d 增删、e 外置编辑器、F2 运行
Run步骤状态、行数/速率、日志、取消F3 取消、l 看完整日志
Data目录树、表结构、分页网格、SQL 控制台p 预览、x 导出
Settingsthreads/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 进程”

[实测] 跨进程打开直接被文件锁拒绝。可行的多进程方案只有两种:

  1. worker 独占数据库,TUI 完全不开数据文件(通过 IPC 查元数据/预览)——实时浏览体验差。

  2. 数据文件拆成两个(只读快照 + 写入库)——同步复杂,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 / 表 / SQLformat、path、options
sql任意 SELECT,引用前一步 idsql
assert数据质量断言sql、expect
sink写 table / Parquet / CSVtarget、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::Widgets0.1.5,作者自己写“very early、API 一定会变”。除非愿意 vendor,不建议作为产品基座。
Notcurses::Native0.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::UI0.1.13(fez)/ 0.1.14(仓库)pane / 滚动 / 键位 / alert;依赖 Terminal::ANSI、Terminal::ANSIParser、Unicode::UTF8-Parser、Log::Async
颜色 / 尺寸Terminal::ANSIColor 0.14、Terminal::Size生态可用颜色与终端尺寸
终端能力Terminal::Capabilities0.0.17真彩色 / 256 色检测
JSONJSON::Fast0.21.0pipeline、事件、配置
日志Log::Async0.0.14异步日志,避免阻塞 UI
并发核心 Supply / Channel / Promise / Proc::AsyncRakudo 内置job 线程、外部编辑器、隔离 worker

5.2 按需

用途库说明
YAML 导入YAML::Parser::LibYAML只读解析;回写用 JSON
静态表格Text::Table::Simple / Terminal::Table 0.0.5仅 CLI --list、报表;不要用于可滚动网格
CSV 预校验CSV::Parser一般不需要,DuckDB 直接读
非全屏 CLILinenoise / Readline仅 etl repl 之类的独立模式
阶段指示Terminal::Spinners非全屏命令
HTTP / XML 源Cro::HTTP / HTTP::UserAgent / XML后续插件,不进 v1
测试Testwidget 纯函数 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.interruptduckdb_interrupt 已绑定,无公开方法取消按钮
duckdb_query_progress未绑定;返回 by-value 结构体真实进度
duckdb_open_ext + config未绑定access_mode=READ_ONLY、open 时设置 threads / memory
Result.rowsREADME 写了,实际没有方法([实测] 报 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 / watermarks

  • TUI:只依赖 Run 事件和 Duck 的只读快照

8. 里程碑

阶段交付验收
M0 引擎先行ducketl run pipeline.json(无 TUI)、SQL 生成器、history 表、失败回滚1M 行 CSV → Parquet;故意让第 3 步失败,目标表无残留
M1 只读 TUIDashboard + 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 选项
2Terminal::UI vs Terminal::WidgetsTerminal::UI前者可用且线程安全;后者作者自述 very early
3JSON vs YAML 作为 canonicalJSON无 YAML dumper;JSON 可稳定回写;YAML 仅导入
4v1 是否支持远程 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)
CTAScreate table t as select * from read_csv_auto(?)2 行
Upsertinsert ... on conflict (id) do update ...成功
Parquet 写copy t to 'out.parquet' (format parquet)文件生成,回读 2 行
扩展select * from duckdb_extensions() where installedcore_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::UI0.1.13(fez)/ 0.1.14(仓库 META)
Terminal::Widgets0.1.5,自述 very early
Notcurses::Native0.8.0
Terminal::ANSIColor0.14
Terminal::Capabilities0.0.17
Log::Async0.0.14
JSON::Fast0.21.0
Terminal::Table0.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”**才是这个项目的生死线。

App::DuckETL v0.6.0

SQL-first, TUI-ready ETL on DuckDB

Authors

  • zef:skyter10086

License

MIT

Dependencies

DB:ver<0.5+>DB::DuckDB:ver<0.8.3+>Alien::Libduckdb:ver<1.1.0>JSON::Fast:ver<0.21+>Terminal::UI:ver<0.1.14+>Terminal::ANSI:ver<0.0.24+>

Test Dependencies

Provides

  • App::DuckETL
  • App::DuckETL::CLI
  • App::DuckETL::Config
  • App::DuckETL::Duck
  • App::DuckETL::Executor
  • App::DuckETL::IsolatedRun
  • App::DuckETL::Pipeline
  • App::DuckETL::Planner
  • App::DuckETL::Repo
  • App::DuckETL::Run
  • App::DuckETL::SQL
  • App::DuckETL::Store
  • App::DuckETL::TUI
  • App::DuckETL::TUI::Chart
  • App::DuckETL::TUI::Draft
  • App::DuckETL::TUI::Region
  • App::DuckETL::TUI::Render
  • App::DuckETL::TUI::Theme
  • App::DuckETL::X::Pipeline

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.