C-API-V2:第一部分 - #24702
#24702
开启
Maxxen
此 PR 添加了 V2 C-API 前半部分的初始草稿,以及构建在其之上的稳定“C++ API”。
警告:这是一个草稿
此处任何内容都不应被视为已决定或稳定——我们仍然有兴趣接收反馈,但请暂时不要基于此进行构建,也不要因为某些功能缺失而恐慌。此外,大量文档仍是从初始原型阶段由 clanker 生成的杂乱内容。未来几周事情将发生显著变化。
这前半部分涵盖以下章节:
- 错误处理(新)
- 环境(新)
- 数据库和选项
- 连接
- 上下文(新)
- 扩展和新入口点(新)
- SQL语句(新)
- 查询结果(流式,新)
- 逻辑类型
- 值
- 数据块
- 向量(新的非平坦向量类型)
- 向量视图(新)
此 PR 主要侧重于添加足够的接口来支持“客户端”用例,例如打开数据库、执行一些查询、获取一些结果——而不是“用新功能扩展 DuckDB”。我们已经在单独的分支中原型设计了另一半(标量/表/转换/复制函数、文件系统、自定义类型、Arrow 互操作、日志记录等等),这些将很快跟进。
这个 PR 很大,但由于规范文件和测试添加了大量行数,它实际上比初看起来要小。我认为主要值得关注的文件是 duckdb_v2.h 和 duckdb_cpp.hpp。此外,整个 API 表面中近 60% 目前专用于不同的 duckdb::Value 构造函数/访问器。下面会详细介绍。
我将尝试逐节介绍,并突出一些动机、设计决策、想法、未来工作以及与 V1 API 的显著差异。
错误处理
与 V1 API 可能最大的区别在于,我们现在有了一个相对连贯的错误处理和报告机制。
我们使用的约定是,基本上所有 API 函数现在都返回一个 DUCKDB_V2_ERROR 代码,并且总是接收一个可选的 duckdb_v2_error_info_handle 指针作为其最后一个参数。实际的返回值通过输出指针(out-pointer)返回。
此规则有两个例外:错误“检查”函数(因为试图从另一个错误中获取错误消息时,很快就会陷入荒谬的循环);以及句柄类型的“析构”函数(它们只返回一个错误代码)。
以下是实践中可能的样子:
// 示例函数:获取块大小,否则退出
idx_t try_get_chunk_size(duckdb_v2_data_chunk_handle chunk) {
// 设置错误句柄
duckdb_v2_error_info_handle err = NULL;
// 设置返回值
idx_t out_size = 0;
// 执行 API 调用,并检查状态码
if(duckdb_v2_data_chunk_get_size(chunk, &out_size, &err) != DUCKDB_V2_ERROR_NONE) {
// 获取消息
duckdb_v2_str msg;
duckdb_v2_error_info_get_text(err, &msg);
// 打印并退出
fprintf(stderr, "Error!: %.*s\n", (int)msg.len, msg.ptr);
// 释放错误信息
duckdb_v2_error_info_destroy(&err);
exit(-1);
}
// 全部正常!返回
return out_size;
}
请注意,最后一个 duckdb_v2_error_info_handle 参数始终是可选的,并且仅在实际发生错误时才会被设置。因此,如果你不关心额外的错误细节,只需检查返回的错误代码即可。
这意味着 V2 API 中的几乎所有操作都是可能失败的——因为它们确实可能失败。DuckDB 自身在内部大量使用 C++ 异常,使得很难甚至知道什么会失败。此外,即使在我们的控制之外,也有无数原因可能导致操作意外失败(例如 std::bad_alloc)。因此,确保每个 API 调用都能在调用点报告错误(而不是藏在你可能会忘记查看的其他上下文对象中)似乎是处理这个问题的最健壮方式。
由于 API 的大部分操作都是基于不透明的“句柄”,我们最初考虑将错误存储在句柄本身中,可以在操作之后的任何时候检索,但最终决定不这样做。这需要大量的额外函数来检查每种句柄类型,并且仍然要求每个常规函数返回一个错误代码(否则你必须在每次操作后调用另一个函数来检查是否发生了错误)。这还要求我们更改支持这些句柄的内部数据结构,以便现在有空间容纳错误。此外,对于一个操作多个句柄的 API 调用,哪个句柄应该接收或“拥有”该错误也并不总是明确的。
我们也考虑过使用全局线程本地存储来存放“最后一个报告的错误”,类似于 errno。我们也决定不这样做,因为 TLS 在动态链接时并不总是工作良好,并且将错误保持在线程本地要求消费者在回调(因为 DuckDB 有时可能在不同线程上执行某些回调)以及他们自己的程序中处理错误。这在基于“绿色线程”(例如 Go,甚至 async-rust)的多任务环境中尤其烦人,因为这些环境并不总是清楚一段代码何时会让出并在单独的系统线程上继续执行。
相反,拥有显式的错误信息句柄也使得跨回调传播错误变得简单直接。其思路是,任何由 DuckDB 调用的回调函数也将提供一个 duckdb_v2_error_info_handle,用户代码可以将其传递给其他 API 调用。不幸的是,此 PR 未提供任何接受回调的 API,但以下来自我们标量函数接口原型的示例希望能说明这种模式:
typedef struct { bool some_condition } my_struct;
void my_bind_callback(duckdb_v2_scalar_function_bind_info_handle info, duckdb_v2_context_handle context,
duckdb_v2_error_info_handle *err) {
// 执行一些逻辑
// ...
// 尝试获取“用户数据”
void* out_data;
// 从回调转发错误信息句柄。
if(duckdb_v2_scalar_function_bind_get_user_data(info, &out_data, err) != DUCKDB_V2_ERROR_NONE) {
// 如果操作失败,错误将被设置在提供的错误信息中,
// 因此我们可以直接返回,DuckDB 将在重新获得控制权后自行处理错误!
return;
}
// 更多代码
// ...
if(!((my_struct*)out_data)->some_condition) {
// 我们也可以自己设置错误!
// 非零错误代码是 DuckDB 在内部引发异常所需的全部
duckdb_v2_error_info_set_code(*err, DUCKDB_V2_ERROR_INPUT_INVALID);
// 但如果需要,我们也可以提供更多细节
duckdb_v2_str msg = { .ptr = "some error!", .len = 11};
duckdb_v2_error_info_set_text(*err, msg);
return;
}
// ...
}
我们也希望这个约定能方便地包装 API 调用,以便将错误传播到主机系统/环境。例如,C++ API 可以轻松地将错误信息转换回异常(反之亦然),我们也尝试了 Rust 绑定,这个模型很好地适配了 Rust 的 Result<T, Err> 约定。
环境
C-API-V2 为希望与 DuckDB 交互的客户端提供了一个新的“根”对象:环境(Environment)。
环境本质上是打开数据库的顶级容器/缓存。通过要求使用环境来打开数据库,我们可以例如检测并在用户尝试打开同一进程中已经打开的数据库时抛出错误,这在 V1-C-API 中目前可能导致意外问题。
由于 DuckDB 还可以操作多个数据库(通过 ATTACH),在打开任何数据库之前,或在单个数据库上下文之外,提供某种句柄来与 DuckDB 交互似乎是个好主意。到目前为止,Environment 主要能告诉你通过它打开了多少个数据库,但我们将来可能会添加额外的函数来管理其他类型的跨数据库状态。
现在与 DuckDB 交互的初始步骤看起来像:
int main() {
duckdb_v2_error_info_handle err = NULL;
duckdb_v2_environment_handle env = NULL;
if(duckdb_v2_create_environment(&env, &err) != DUCKDB_V2_ERROR_NONE) {
// ... 处理错误
}
duckdb_v2_database_handle db = NULL;
if(duckdb_v2_open(env, duckdb_v2_str {NULL, 0}, NULL, 0, &db, &err) != DUCKDB_V2_ERROR_NONE) {
// ... 处理错误
};
duckdb_v2_connection_handle conn = NULL;
if(duckdb_v2_connect(db, &conn, &err) != DUCKDB_V2_ERROR_NONE) {
// ... 处理错误
}
// 我们现在有了一个可以使用的连接!
}
duckdb_v2_str
V2-C-API 不再在 API 边界传递 const char*,而是引入了一个专用的 duckdb_v2_str “字符串视图”/“长度分隔”结构体。
struct duckdb_v2_str {
const char *ptr;
idx_t len;
};
这使得在 API 之间双向传递字符串更加高效(也更安全!),因为大多数其他语言(除了 C)也在内部将字符串表示为 {ptr, len} 对(例如 Rust、Go、C++)。你不再需要为了以空字符结尾而单独复制你想要传递的每个字符串。
另一个好处是 duckdb_v2_str 从不拥有所有权,这使得在 API 中接口交互时更容易推理所有权。你总是可以传递一个 duckdb_v2_str,DuckDB 会在内部复制它——而且你知道,如果你收到一个 duckdb_v2_str,并且想要延长其生命周期,你需要自己复制一份。
数据库和 DatabaseOptions
待办
连接 vs 上下文
我们希望通过新的 V2-C-API 解决的主要问题之一是更好地跟踪资源使用情况并提高与扩展的互操作性。为此,我们尽量避免使用“无上下文构造函数”,即“凭空”创建内部对象句柄的函数。
例如,现在创建 LogicalType 需要一个 Connection,因为可创建的逻辑类型可能因加载了哪些扩展或当前作用域内的数据库设置而有所不同。类似地,如果你的代码是从 DuckDB 内部调用的——例如在函数回调期间——可能存在其他(可能是事务本地的)状态,这些状态可能影响某些 API 调用的行为。
因此,API 基本上被“拆分”为可在以下两种情况使用的函数:
- DuckDB 外部:需要
duckdb_v2_connection_handle- 大多数客户端驱动程序,或嵌入 DuckDB 的应用程序
- DuckDB 内部:接收
duckdb_v2_context_handle- 例如,扩展代码,或作为回调提供的用户代码(如 UDF)
与连接不同,你不能创建 duckdb_v2_context,但它几乎总是作为第一个参数传递给 API 中的每个回调函数以及扩展入口点函数。它本质上捕获了你的代码正被 DuckDB 作为当前正在运行的查询/事务的一部分调用的事实——你可以使用它来访问诸如连接本地状态、设置、当前目录、当前加载的文件系统,以及可能其他作用域内的“服务”(如内存分配器等)。
这也防止了 API 的误用。例如,你不能使用 duckdb_v2_context 来运行查询,因为在另一个查询中运行查询是没有意义的(不受支持,而且如果你打开一个新连接,你会失去事务性)。
V2 扩展入口点
此 PR 未提供任何用于执行通常在扩展中会做的操作(例如定义新函数)的接口,但它仍然实现了加载 V2-C-API 扩展的基础设施。
V1 和 V2 之间的一个直接区别是 V2-C-API 扩展的“入口点”函数不同。
入口点函数的签名现在变为:
void <NAME>_init_c_api_v2_internal(duckdb_v2_extension_handle extension, duckdb_v2_context_handle context, duckdb_v2_error_info_handle *err)
duckdb_v2_extension_handle基本上是 C++ 扩展使用的 ExtensionLoader 的 C 语言等价物。这将是将来用于注册新扩展函数/类型等的工具。它仅在初始扩展加载期间传递给入口点,不应存储或使生命周期超出入口点回调。duckdb_v2_context_handle,如上所述,是当前正在执行的语句(例如LOAD <extension>)的上下文句柄,或者如果此扩展是静态链接的,则是一个临时的虚拟连接。这允许你例如访问执行当前正在加载扩展的LOAD语句的连接所设置的连接本地状态/设置。不幸的是,扩展加载不是事务性的,但这是我们在未来最终可能使其变得更加事务性的方式之一。duckdb_v2_error_info_handle,同样如“错误处理”部分所述,是你在扩展加载期间报告失败的方式,可以通过手动报告错误,或通过传播你在加载期间进行的其他 API 调用(例如尝试注册一个已存在的函数)的错误。
一个关键的观点是,扩展入口点函数本质上就像 C-API 调用的任何其他回调一样。
在 2.0 发布之前,我们可能会更多次地更改此签名,例如也传递某种全局内存分配器句柄,扩展可以保存并在其整个生命周期内使用。
SQL 语句展开
V2-C-API 提供了对如何执行 SQL 的更细粒度控制。不仅仅是提供一个既解析又执行单个 SQL 语句的 query(const char* sql) 函数等效项,V2 API 将解析和执行逐语句拆分。这是通过两个新概念实现的:duckdb_v2_statement_iterator_handle 和 duckdb_v2_sql_statement_handle。流程如下所示:
// 解析一个 SQL 字符串(可能产生多个 SQL 语句!),并返回一个迭代器
// (为简洁起见省略了错误处理)
duckdb_v2_statement_iterator_handle iter = NULL;
duckdb_v2_parse_sql(conn, "SELECT 1 + 2; SELECT 4", &iter, NULL)
// 循环遍历所有语句
duckdb_v2_sql_statement_handle stmt = NULL;
while(true) {
// 从迭代器中提取下一个语句(语句被惰性解析)
duckdb_v2_statement_iterator_next(iter, &stmt, NULL);
if(!stmt) {
break; // 没有更多语句了!
}
duckdb_v2_result_handle res = NULL;
duckdb_v2_statement_execute(conn, stmt, nullptr, nullptr, 0, &res, NULL);
// ... 对结果 `res` 做些什么
// 清理
duckdb_v2_result_destroy(&res);
duckdb_v2_sql_statement_destroy(&stmt);
}
duckdb_v2_statement_iterator_destroy(&iter);
这解决了两个问题:
- 某些 DuckDB 语句实际上在内部扩展为多个语句(例如
PIVOT),因此以前通过这些语句通过 C-API 执行有点问题。 - 语句被惰性解析,这意味着你可以例如加载一个解析器扩展,然后立即在同一个 SQL 字符串中使用新语法。
因此,SQL 字符串不再需要是单个语句,如果你愿意,你可以解析和执行整个 SQL 程序,而无需自己进行任何语句拆分。
将 SQL 解析与绑定和执行分离,也使我们能够最终支持诸如“解析此 SQL 语句并将其转换为某种解析树/JSON,而不绑定它”之类的功能,通过添加更多作用于 duckdb_v2_sql_statement_handle 的函数。
流式查询结果
V2-C-API 的另一个变化是,所有查询结果现在都是流式的,并且被惰性获取,而不是完全缓冲和物化。
duckdb_v2_result_handle 基本上是一个小型状态机,使用它看起来像这样:
// ... 从某处获取要执行的语句
duckdb_v2_result_handle res = NULL;
duckdb_v2_statement_execute(conn, stmt, nullptr, nullptr, 0, &res, NULL);
bool is_done = false;
while (!is_done) {
duckdb_v2_data_chunk_handle chunk = NULL;
DUCKDB_V2_RESULT_STEP_STATUS status = DUCKDB_V2_RESULT_STEP_STATUS_WAITING;
// “步进”查询结果一次
duckdb_v2_result_step(res, &chunk, &status, NULL)
switch (status) {
case DUCKDB_V2_RESULT_STEP_STATUS_CHUNK:
// 我们得到了一个 DataChunk!
// 用它做点什么!
// ...
do_something_with_the_data_chunk(chunk);
// 清理
duckdB_v2_data_chunk_destroy(&chunk);
break;
case DUCKDB_V2_RESULT_STEP_STATUS_FINISHED:
// 结果已完成,我们完成了!
is_done = true;
break;
case DUCKDB_V2_RESULT_STEP_STATUS_CANCELLED:
// 查询被中断了!
is_done = true;
break;
case DUCKDB_V2_RESULT_STEP_STATUS_WAITING:
// DuckDB 尚未准备好生成另一个块。我们可以:
// - 让出并稍后回来
// - 要求 DuckDB “等待”一段时间,直到我们可能能够取得进展
// 让我们等待...
// 这将阻塞一段有限的时间或直到更多块可用。
duckdb_v2_result_wait(res, NULL);
break;
default:
break;
}
}
这有两个好处:
- 返回非常大的查询结果现在可以增量进行,而无需整个结果都适合内存。
- 当等待更多数据时,DuckDB 返回
DUCKDB_V2_RESULT_STEP_STATUS_WAITING,使得当将 DuckDB 嵌入到另一种语言时,可以将结果获取 API 与各种“异步”运行时集成,因为它有效地指示了当前协程/任务让出的好时机。
虽然此 PR 未包含,但我们将支持通过 V2-C-API 创建 ColumnDataCollection,这将使你能缓冲返回的 DataChunk(支持大于内存的处理),以创建你自己的“物化结果”,如果你希望将处理结果延迟到查询完成之后。在这个意义上,默认流式的结果是一个比默认物化(如 V1-C-API 中所提供)严格更强大的接口。
上下文作用域内的类型和值
这基本上遵循了“连接 vs 上下文”中解释的相同原则——我们希望能够在 C-API 中跟踪所有创建的对象,并更好地支持扩展定义的语义。
与创建 duckdb_v2_values 和 duckdb_v2_logical_types 相关的函数非常突出地展示了这一后果。因为我们需要能够在 DuckDB 外部和内部创建和管理这些对象,现在有两组基本相同的函数,唯一的区别是它们接收 duckdb_v2_connection 还是 duckdb_v2_context。(事实上,此 PR 中近 60% 是由值构造函数函数组成的)。
这可能感觉有些繁琐,但好处是它允许我们例如缓存/去重/集中分配从连接/上下文创建的所有类型/值。它还支持创建自定义类型和使用由用户/扩展/设置定义或更改的自定义转换规则。
然而,如果我要向客户端维护者提供一个建议,那就是不要直接在本机对象中包装值和类型的句柄。相反,在宿主语言中定义你自己的类型/值对象,并且只在需要跨越 API 边界时才与 DuckDB 的等价物进行转换。
非平坦向量和 VectorView
V1-C-API 只支持“平坦”向量,这意味着没有基于 C-API 的扩展能够利用 DuckDB 的“压缩执行”,因为所有传递给 C-API 的向量都将在内部被展平。这使得 API 消费者更容易处理向量,但也使得调用 C-API 定义的函数性能降低。
在 V2-C-API 中,情况不再如此,我们现在默认不再展平向量。但是,你可以通过调用 duckdb_v2_vector_flatten 自己展平——但如果你不这样做,你可以使用新的 duckdb_v2_vector_get_view 来获取一个统一的“向量视图”,以便轻松处理 FLAT、CONSTANT 和 DICTIONARY 向量。其工作方式如下:
// 向量视图定义如下:
struct duckdb_v2_vector_view {
const void *data; // 指向数据缓冲区的指针
const uint64_t *validity; // 标记哪些行为 null 的有效性掩码(基本上是一个位集)
const duckdb_v2_sel_t *sel; // 用于字典压缩向量的“选择”向量
idx_t count; // 向量中的元素数量
};
//...
// 从某处获取一个向量
duckdb_v2_vector_handle vec = get_vector_from_some_function(...);
// 设置一个向量视图
duckdb_v2_vector_view view;
duckdb_v2_vector_get_view(vec, &view, NULL);
// 转换数据指针
int32_t* data_ptr = (int32_t*)(view.data)
printf("About to print vector of %llu elements:", view.count);
// 循环遍历向量中的所有“行”
for(idx_t raw_idx = 0; raw_idx < view.count; raw_idx++) {
// 如果提供了选择向量,则使用它来获取此行的物理索引
idx_t row_idx = view.sel ? view.sel[raw_idx] : raw_idx;
if(!view.validity || (view.validity[row_idx / 64] & (uint64_t(1) << (row_idx % 64))) != 0)) {
// 两者之一:
// - 没有有效性掩码 => 所有行都有效
// - 对应的位被设置 => 此行为有效
int32_t value = data_ptr[row_idx];
// 对该值做些什么!
printf("Value at row %d: %d", raw_idx, value)
} else {
// 值为 null!对此做些什么
printf("Value at row %d: is null!", raw_idx)
}
}
printf("Done!");

290

被折叠的 条评论
为什么被折叠?



