跳到主要内容

文件系统访问

DuckDB 的 C API 只在部分回调里交出客户端上下文:标量函数在 bind / init 回调里能拿到, 表函数有 duckdb_table_function_get_client_context,COPY TO 的四个回调各有一个入口; 聚合函数一个都没有 —— 既没有 bind 回调,也没有 duckdb_aggregate_function_get_client_context。行处理函数与 finalize 拿不到 ClientContext,没有它也就没有 FileSystem。

注册期是唯一的窗口,而 DuckDB 交给入口点的那条连接是借来的:注册一返回就被断开。 保存那个句柄 —— 或者由它派生的 ClientContext / FileSystem —— 只会得到一个悬空引用: 这三者在 C API 里都是引用语义,duckdb_destroy_client_context / duckdb_destroy_file_system 只是 delete 掉包装对象。

duckfn 因此在注册期打开自己的一条长连接,把其余细节藏在三个函数后面,于是任何回调都能 访问 DuckDB 的文件系统 —— s3:// 与 http(s)://(走 httpfs)、内存文件系统与本地磁盘都是 同一条通路,而不是退化成只能看见本地磁盘的 std::fs。

DuckDB 1.5.0+

文件系统访问来自 DuckDB 1.5.0 的 C API,所以需要 DuckDB 1.5 的头文件 —— 以及 duckfn 的 owned-connection feature(下面那条连接的捕获就在它里面)。duckdb-1-5 只带来 1.5 的类型面, owned-connection 依赖它:

duckfn = { version = "0.0.18", features = ["owned-connection"] }

duckfn 做了什么​

扩展加载时,duckfn::register_all_duckfn(duckfn_entrypoint! 指向的那个函数)先记下拿到的 database 句柄,并由它打开一条 OwnedConnection,把结果存进进程级静态变量。这条连接持有数据库 实例的 shared_ptr,所以能活过扩展加载;而静态变量永不析构 —— 这正是由它派生的句柄一直有效的 原因。

之后每次取用都会从这条连接现造一对 ClientContext → FileSystem,并在 guard 释放时一起收尾。 没有句柄逸出,也不需要为每个函数单独注册:宏写的聚合与手写适配器用的是同一个调用。

捕获是 best-effort 的:打开连接失败时注册照样成功,只是之后取用会报出记录下来的原因。

代价​

留着这条连接不是没有代价,开启这个 feature 前值得先看一眼:

  • 它跟着进程活着。 静态变量持有数据库实例的 shared_ptr,只要进程在跑,这条扩展连接就一直 占着实例上的一个连接位。
  • 取用会串行。 guard 持有那条连接上的互斥锁:同一线程里嵌套取用会死锁,并发取用要排队。 要读多个文件就把读取放进同一次 with_file_system,或者取一次值缓存进自己的状态。
  • 进程级只有一份,首个完成注册的实例胜出 —— 同一进程里有多个数据库实例(或多次 LOAD)时, 用的是先注册完那个实例的 VFS。
  • 不能用「同一个线程里同步执行 DuckDB」的运行时。 注册期打开连接会在当前线程上重入引擎, 而没有别的线程能推进的运行时就此死锁:扩展 LOAD 卡住且不报任何错误。已知的唯一一处是 DuckDB-Wasm 的 Node blocking 绑定(duckdb-node-blocking.cjs)。浏览器、原生命令行与 Node 的 worker 模式(duckdb-node.cjs)加载都不会卡住。
  • 文件系统只在“真的是文件系统”的地方才忠实。 原生磁盘上,下面的读写与 POSIX 一致。浏览器 / DuckDB-Wasm 构建下不然(用本地自建的 wasm_eh 扩展实测):任意不存在的路径都会返回一个 1 字节 \0 的幻影条目 —— 连 DuckDB 自带的 glob / read_text / file_size 都把它报成存在,所以 exists 恒真、没有任何 FS 原语能区分“不存在”;duckfn 的裸写偏移也错(文件多一字节 / 错位)。这是平台 限制而非 duckfn 的 bug,所以文档站把文件系统相关的示例保持为普通(非可运行)块。DuckDB 的 COPY … TO 只能把查询结果按格式(CSV / JSON / parquet)导出 —— 能往返,但存不了一个任意长 字符串(如 HTML 报告)的原样字节(CSV 会加引号/换行),所以浏览器上根本没有通用的文件写出 路子。因此 duck_vfs 已从 all feature 移除 —— native 下确需时再显式开 owned-connection。本站在 CI 里跑的可运行 SQL 是在真实浏览器里执行的(duckfn-sql-verify)。

读一个文件​

use std::ffi::CString;

use duckfn::duck_vfs::{ErrorData, FileOpenOptions};
use duckfn::{DuckAggregateState, DuckResult, duck_aggregate_function, duck_error};

#[derive(Default, Debug, Clone)]
struct FileSizeState {
total: i64,
}

impl DuckAggregateState for FileSizeState {
type Output = i64;

fn simple_combine(&mut self, other: &Self) {
self.total += other.total;
}

fn simple_result(&self) -> Self::Output {
self.total
}
}

#[duck_aggregate_function]
fn dfn_agg_file_size(path: String, state: &mut FileSizeState) -> DuckResult<()> {
let path = CString::new(path)
.map_err(|_| duck_error("dfn_agg_file_size: path contains a NUL byte"))?;
state.total += duckfn::duck_vfs::with_file_system(|fs| {
let handle = fs
.open(&path, &FileOpenOptions::read_only())
.map_err(file_error)?;
let size = handle.size().map_err(file_error)?;
i64::try_from(size).map_err(|_| duck_error("file is larger than BIGINT"))
})?;
Ok(())
}

fn file_error(error: ErrorData) -> quack_rs::error::ExtensionError {
duck_error(error.message().unwrap_or_else(|| "file system error".to_string()))
}
-- 块自己写出待统计的两个文件,可反复执行
COPY (SELECT 1 AS i) TO 'a.csv' (FORMAT csv);
COPY (SELECT 1 AS i) TO 'b.csv' (FORMAT csv);
SELECT dfn_agg_file_size(path) FROM (VALUES ('a.csv'), ('b.csv')) t(path);

一共三个入口:

入口什么时候用
duckfn::duck_vfs::with_file_system(|fs| …)在一个闭包里读、什么都不逸出 —— 最不容易写错
duckfn::duck_vfs::file_system()你想自己持有 guard;它实现了 Deref<Target = FileSystem>,可以直接 open()
duckfn::duck_vfs::client_context()需要连接级配置或连接 ID(注意 catalog 需要活跃事务,空闲连接上返回 None)

FileSystem::open 收 &CStr,返回的 FileHandle 提供 read / read_exact / read_to_end / write / write_all / seek / tell / size / sync / close,drop 时自动关闭。duckfn 已 duckfn::duck_vfs 再导出 FileSystem / FileHandle / FileOpenOptions / FileFlag / ClientContext / ErrorData,因此下游不必直接依赖 quack-rs。

便捷读写(duckfn::duck_vfs)​

上面那套是底层形态:自己挑打开选项、自己持有句柄、自己推字节。日常用起来啰嗦,所以 duckfn::duck_vfs 提供了一套 Hutool FileUtil 风格的一行式接口:

调用作用
duck_vfs::read(path)整个文件读成 Vec<u8>
duck_vfs::read_string(path)按 UTF-8 读成 String(非法字节报错)
duck_vfs::read_string_lossy(path)同上,非法字节换成 U+FFFD
duck_vfs::read_lines(path)按行读(\n 分行、行尾 \r 去掉、末尾换行不产生空行)
duck_vfs::write(path, bytes) / duck_vfs::write_string(path, text)用这些字节替换文件内容
duck_vfs::write_with(path, bytes, mode) / duck_vfs::write_string_with(path, text, mode)同上,显式指定 WriteMode
duck_vfs::append(path, bytes) / duck_vfs::append_string(path, text)追加;文件不存在则创建
duck_vfs::size(path) / duck_vfs::exists(path)字节数 / 是否存在

有意思的是 WriteMode:

模式语义
Replace(默认)文件内容精确等于这次写进去的字节 —— 旧文件更长也一样。C API 没有 truncate 这件事就藏在这里:旧文件更长时先用一条零行 COPY ... TO 把它清零,再写正文。
FailIfExists文件已存在就报错,且不改动它。存在性由显式检查决定,另外叠上 EXCLUSIVE_CREATE 作为并发下的保险 —— DuckDB 只在 POSIX 本地文件系统上把它真正落成 O_EXCL,Windows 分支不处理它(缺文件时甚至会报「找不到文件」),所以光靠这个 flag 不够。
Append追加到末尾;文件不存在则创建。
use duckfn::duck_vfs::{self, WriteMode};

duck_vfs::write_string("report.html", render())?; // 覆盖
duck_vfs::append_string("report.log", "one more line\n")?; // 追加
duck_vfs::write_string_with("once.txt", "x", WriteMode::FailIfExists)?; // 已存在就报错

let text = duck_vfs::read_string("report.html")?;
let lines = duck_vfs::read_lines("report.log")?;
let bytes = duck_vfs::size("report.html")?;

这一层下面的一切 —— 共用连接、C 字符串转换、清零用的 COPY —— 都是实现细节;调用方依赖的是 行为:将来 DuckDB 的 C API 支持 truncate 了,也只需改 duckfn::duck_vfs。

没有 delete:C API 既没有 remove 也没有 move,DuckDB 也没有 remove_file 函数 —— 想「清空」 就用空内容覆盖一次。每次调用都会自己取一次共用连接,所以不要在 with_file_system 的闭包里调 duck_vfs::*(会死锁),并发调用之间也是串行的。

并发与开销​

guard 持有那条自有连接上的互斥锁:

  • 同一线程里不要嵌套取用 —— 在已经持有 file_system() 的闭包里再调 with_file_system 会 死锁。要读多个文件就放进同一次 with_file_system。
  • 取用之间是串行的。 并行聚合时每个工作线程都经过这一条连接,所以要放在便宜的位置:在 finalize 里(每组一次),或每组首行取一次并把结果缓存进自己的状态。逐行取用对 open + size 这种小操作没问题,但一次很慢的 S3 读应该放到 finalize。
  • 文件系统本身是 DuckDB 的实例级 VFS,多线程读是安全的;被串行化的只是「取用」这一步。 FileHandle 不可共享 —— 留在打开它的那个线程里,用完让它自己 drop。

错误​

FileSystem::open、FileHandle 的各操作与 FileSystem::error_data 都返回 quack-rs 的 ErrorData。用 duck_error(error.message()…) 把它转成查询错误;聚合的行处理函数返回 Err 会 让整条查询失败(适配层通过 AggregateFunctionInfo::set_error 报出),而不是静默跳过这一行。

局限​

  • 进程级只有一份,首个实例胜出。 同一进程存在多个数据库实例时,用的是先完成注册那个实例的 VFS。需要按实例隔离时,手写适配器并把自有连接挂在函数的附加数据里 (duckfn::DuckExtraInfo + quack_rs::query::OwnedConnection),框架不必参与。
  • 自有连接的 FileOpener 与查询连接不是同一个。 实例级配置与 secrets 生效,但连接级 SET 不保证等价。
  • 需要 DuckDB 1.5.0+ 与 owned-connection feature(它依赖 duckdb-1-5),否则这些函数不存在。

示例扩展在聚合函数里用了它(test/extension/functions/file_system.rs 的 dfn_agg_file_size), test/sql/functions/file_system.test 用 DuckDB 自己的 read_blob 对照校验结果;duckfn::duck_vfs 这一层(往返读写、覆盖更长的旧文件、追加、已存在就报错、非法 UTF-8、按行读)由 test/sql/functions/file_util.test 覆盖。