Turso Serverless 差分测试操作规范:嵌入式驱动与 Serverless 驱动行为一致性验证
【免费下载链接】tursoA SQL database in Rust: SQLite-compatible, now also speaking Postgres (experimental). The LLVM of databases.项目地址: https://gitcode.com/GitHub_Trending/tu/turso
本指南深入讲解 Turso 仓库中 serverless/conformance/differential/operations.md 所定义的差分测试(differential test)操作规范:它以一份跨语言共享的机器可读规范 spec/ops.json 为单一事实源,驱动 JavaScript、Rust 等各语言 harness 生成相同的随机操作序列,同时压测嵌入式驱动与 Serverless(SQL over HTTP)驱动,逐一断言两者在成功与否、结果形状、类型标签与单元格值上完全一致。读完本文,你将掌握这套操作词汇表的完整构成、结果比较的七类断言、对抗性取值集合的设计动机,以及encryption_header等必选属性如何在本地 stub 服务器上无条件运行。
背景:为什么需要差分测试
Turso 的核心承诺是:@tursodatabase/serverless这类 Serverless 驱动与嵌入式驱动行为完全一致,只是把 SQL 执行搬到了 HTTP 之上。作为 SQLite 兼容的 Rust 数据库,Turso 的嵌入式驱动(如 bindings/rust 的tursocrate)直接以内存数据库执行 SQL,而 Serverless 驱动(如 serverless/rust 的turso_serverless)把语句通过POST /v3/pipeline与POST /v3/cursor发送给远程数据库(协议细节见 serverless/PROTOCOL.md)。
手写针对性的用例永远覆盖不到所有边界。差分测试的思路是:生成随机操作序列,把同一条序列分别跑在嵌入式驱动和 Serverless 驱动上,任何结果分歧都意味着契约被打破。正如 serverless/conformance/differential/README.md 所述,这套测试能发现"没人想到要去写测试"的差异:类型映射漂移、跨语句泄漏的事务状态、参数绑定边界情况、把连接卡死的错误路径。由于工作负载是生成的,fast-check(JS)与 hegel(Rust)还支持把失败用例收缩(shrink)为最小的可复现操作序列。
统一操作词汇表:一份规范,多语言共享
所有语言 harness 共享同一个操作词汇表,即 serverless/conformance/differential/spec/ops.json。它是机器可读的单一事实源:
- JavaScript harness 在 serverless/javascript/differential/parity.test.mjs 中通过
readFileSync加载ops.json; - Rust harness 在 serverless/rust/differential/lib.rs 中通过
env!("CARGO_MANIFEST_DIR")定位并解析同一份 JSON,并把其中的常量(num_tables、max_ops_per_case、max_dynamic_cols、error_sqls)、值生成器与操作定义加载为Op枚举。
这意味着无论哪种语言编写新的 Serverless 驱动 harness,只要加载这份规范,就能产生与其他语言完全相同的操作词汇。规范由顶层constants、values、unicode_options、ops、tests五部分组成,下面逐一展开。
比较目标:双方必须在七个维度上达成一致
对每个操作,嵌入式驱动与 Serverless 驱动必须同时满足:
| 维度 | 含义 |
|---|---|
success | 双方要么同时成功、要么同时失败 |
column_count/column_names | 结果形状(列数、列名)一致 |
row_count | 返回行数一致 |
value_types | 逐行逐列的类型标签一致(如双方都返回integer) |
values | 单元格实际值一致,浮点采用 epsilon 容差 |
affected_rows | 报告受影响行数的操作必须一致 |
last_insert_rowid | 报告该值的操作必须一致 |
关键原则:当双方success都为false时,不比较错误消息——嵌入式引擎的错误文本与 HTTP 服务器的错误文本天然不同(协议层错误对象见 serverless/PROTOCOL.md 第 9.1 节),比较它们只会产生无意义的噪音。这一点在 JS harness 的error_check分支中体现为只比较成功/失败,Rust harness 的Op::ErrorCheck也仅断言conn.query(sql, ()).await.is_ok()。
从 JS 实现看,单元格比较的 epsilon 容差实现位于 serverless/javascript/differential/parity.test.mjs 的cellsEqual:对两个 number 类型,先取绝对值的最大值,若为 0 则视为相等,否则要求相对误差< 1e-12;同时处理了整数/浮点跨界(number 与 bigint 互转)、bigint 精确相等、Buffer/Uint8Array 字节相等三种特殊情况。Rust 侧 serverless/rust/differential/tests/parity.rs 的values_match实现了同样的语义,并额外为 SQLite 可能发生的Integer(1) <-> Real(1.0)强制类型转换提供容差。
批量结果比较:以顺序执行作为 Serverless 批处理的 oracle
param_batch操作要求双方对一次batch()调用的整体逐语句结果达成一致,而不只是单个结果:
- 成功时:每条语句各有一个结果,逐一按上述字段比较;
- 失败时:失败语句的零基索引必须一致,且错误携带的逐语句结果必须逐条对应——哪些语句已完成(结果相等)、哪些语句被报告为失败或从未执行,都必须一致。
设计依据:嵌入式驱动把批处理实现为遇到第一个错误就停止的顺序循环。因此这份比较同时充当了 Serverless 驱动服务端批处理的 oracle——一次 pipeline 请求必须表现得与"顺序执行并在首次失败处停止"完全等价。这里有一个容易混淆的点值得强调:批处理并不等价于不停止的顺序执行——十条语句中第四条失败时,顺序执行会继续执行第五条及以后,而batch()会跳过它们。
Rust 侧的实现印证了这一点:serverless/rust/differential/tests/parity.rs 用BatchOutcome { failed_index, entries }结构保存失败索引与每条语句的Option<OpResult>,失败时匹配turso::Error::BatchStatementFailed { index, results, .. }取回部分结果;mode为immediate/deferred时调用transactional_batch走原子批处理路径。
明确的排除项:服务端执行统计rows_read、rows_written、query_duration_ms被排除在比较之外——Serverless 驱动会报告这些指标(见 serverless/PROTOCOL.md 第 8.4 节),而嵌入式驱动按设计不报告,比较它们只会制造伪差异。JS harness 的normalizeBatchResultSet只保留列、行、类型与受影响行数等比较字段,同样不携带统计信息。
操作词汇表全览:28 个操作的分类与 SQL 模板
spec/ops.json的ops数组定义了全部操作,按语义可归为六类(下面给出每个操作的id、SQL 模板与result类型,字段名如{table}、{placeholders}在生成时被替换):
DDL
| id | SQL 模板 | result |
|---|---|---|
create | CREATE TABLE IF NOT EXISTS {table} (a INTEGER, b TEXT) | exec |
create_dynamic | CREATE TABLE IF NOT EXISTS {table} ({col_defs})(1-5 列,列名c0..c4,类型从INTEGER/TEXT/REAL/BLOB/NUMERIC随机选取) | exec |
create_trigger | CREATE TRIGGER IF NOT EXISTS tr_{table}_ins AFTER INSERT ON {table} BEGIN ... END(审计表写入NEW.a与NEW.a * 2) | trigger |
create_trigger在 harness 中并非只建触发器:JS 与 Rust 两侧都会先建{table}_audit审计表,再建触发器、插入(42, 'trigger_test'),最后SELECT * FROM {audit} ORDER BY rowid验证触发器的级联写入。
DML
| id | SQL 模板 | result |
|---|---|---|
insert | INSERT INTO {table} VALUES ({placeholders}) | exec_rows |
insert_returning | INSERT INTO {table} VALUES ({placeholders}) RETURNING * | query |
insert_affected | INSERT INTO {table} VALUES ({placeholders}) | affected |
insert_rowid | INSERT INTO {table} VALUES ({placeholders}) | rowid |
update_returning | UPDATE {table} SET a = ? RETURNING * | query |
update_affected | UPDATE {table} SET a = ? | affected |
delete_returning | DELETE FROM {table} RETURNING * | query |
delete_affected | DELETE FROM {table} | affected |
insert_rowid的验证方式值得一提:Rust harness 在插入成功后直接读取conn.last_insert_rowid(),JS harness 则通过SELECT last_insert_rowid()查询——两种方式都必须与对侧结果一致。
查询
| id | SQL 模板 | result |
|---|---|---|
select | SELECT * FROM {table} | query |
select_value | SELECT {expr}(expr为 -1000..1000 的随机整数) | query |
select_limit | SELECT * FROM {table} LIMIT 1 | query |
select_count | SELECT COUNT(*), SUM(a) FROM {table} | query |
select_expr | SELECT 1+1, 'hello'||'world', NULL, CAST(3.14 AS INTEGER), typeof(?) | query |
select_expr一个操作就覆盖了算术、字符串拼接、NULL、类型转换与typeof类型探测五种表达式语义,是类型系统一致性的高密度探针。
参数绑定
| id | SQL 模板 | result |
|---|---|---|
param | SELECT ?, ?(两个位置参数) | query |
named_param | SELECT :foo, :bar(命名参数named_params: ["foo", "bar"]) | query |
numbered_param | SELECT ?1, ?2(编号参数) | query |
prepared_reuse | SELECT ?, ?(一条语句、三组绑定) | prepared_reuse |
prepared_reuse专门验证预备语句复用:JS 侧stmt.raw(true)后用三组参数依次执行stmt.all(params),Rust 侧conn.prepare("SELECT ?, ?")后循环stmt.query(p),行结果全部累积后整体比较——这能暴露把预备语句状态错误缓存的实现。
事务
| id | SQL 模板 | result |
|---|---|---|
begin/commit/rollback | BEGIN/COMMIT/ROLLBACK | exec |
transaction_workflow | BEGIN→ 1-5 个 DML/查询操作 →COMMIT或ROLLBACK | exec |
error_in_transaction | BEGIN→ 正常操作 → 错误 SQL → 恢复操作 →ROLLBACK | exec |
transaction_workflow的内部操作从_DML_OP_IDS集合(create/insert/select/各种 returning 与 affected 变体)中抽取,确保事务体内不使用BEGIN/COMMIT/ROLLBACK嵌套;error_in_transaction则验证错误恢复:事务中途失败后连接仍能执行后续恢复操作并完成回滚。
错误与批处理
| id | SQL 模板 | result |
|---|---|---|
invalid | SELECT foobar_nonexistent(恒失败) | query |
error_check | {error_sql}(从error_sqls常量抽取,仅比较成败) | error_check |
batch | CREATE TABLE ...; INSERT INTO ...(多语句 SQL) | exec |
param_batch | batch()传入 1-4 条语句:参数化INSERT INTO t_{prefix}_{tbl} VALUES (?, ?)、SELECT a, b FROM t_{prefix}_{tbl}或error_sql,mode可选deferred/immediate原子模式 | batch |
规范中的error_sqls常量是一个精心挑选的失败语句集(serverless/conformance/differential/spec/ops.json):查询不存在的表nonexistent_table_xyz/abc/zzz,以及一条故意列数不匹配的插入INSERT INTO t_{prefix}_0 VALUES (1, 2, 3)——后者在表存在时会因列数错误而失败,表不存在时也会失败,两种情况下都是合法的错误用例。
值生成策略:常规随机值 + 对抗性边界集
spec/ops.json的values数组定义了 20 种值生成器,既有常规随机值,也包含一组刻意刁难的对抗性取值:
- 常规:
null、-10000..10000 随机整数、±1000 除以 10 的随机浮点、0-50 字符 ASCII 字符串、0-32 字节随机 blob; - 64 位整数极值:
9223372036854775807、-9223372036854775808、0(int_extreme); - 浮点极值:
±1.7976931348623157e+308与0.0(float_extreme,JS 实现因无法可靠经 HTTP 往返 f64::MAX 而只保留 0.0,参见 harness 中的注释); - 空与极长:空字符串、空 blob、4096 字符的
'a'重复串(large_string)、4096 字节的 0xAB blob(large_blob); - Unicode 与方向性:emoji、CJK、RTL 阿拉伯文三种
unicode_options,以及 256 字节 0xAB blob 或 emoji/CJK/RTL 串二选一的large_or_unicode; - 注入与转义:含 NUL 字节的
hello\0world、SQL 元字符it's a "test"; DROP TABLE--、反斜杠路径path\to\file、仅空白字符\t\n\r; - 编码边界:带 BOM 前缀的字符串、
-0.0负零、全 0 字节 blob、全 0xFF 字节 blob。
这类值专门用来击穿类型映射与序列化实现:整数以十进制字符串在 HTTP 上传输(serverless/PROTOCOL.md),blob 以无填充 base64 编码,NUL 字节与 SQL 元字符考验文本与转义处理,负零考验浮点编码保真。任一环节两侧结果不一致,测试就会失败并给出可收缩的最小序列。
表命名与并发隔离:随机前缀机制
每个生成的测试用例获得一个随机数字前缀(JS 侧来自fc.integer({ min: 0, max: 65535 }),Rust 侧同样),并在t_<prefix>_0到t_<prefix>_5(共num_tables: 6张表)上操作。这样:
- 并发运行互不干扰:并行执行的不同用例使用不同前缀;
- 重放互不干扰:fast-check/hegel 收缩重放同一用例时,不会与上次运行的残留数据冲突;
- 前向清理:用例开始时显式
DROP TABLE IF EXISTS t_<prefix>_<n>(以及_audit审计表、tr_t_<prefix>_<n>_ins触发器),确保独立于历史遗留数据。
JS harness 的applyPrefix函数负责把操作字段与 SQL 中的t_N表名统一改写为t_{prefix}_N,同时把error_sqls中的字面{prefix}占位符替换为真实前缀。Rust 侧生成器gen_ops遵循同一约定。值得注意,每个测试迭代都会新建嵌入式连接(:memory:)与新的 Serverless 连接,从源头避免陈旧事务状态污染。
结果形状:统一的 OpResult 结构
所有 harness 把两侧驱动归一化到同一个结果结构,operations.md给出了规范形态:
OpResult { success: bool, column_count: Option<usize>, column_names: Option<Vec<String>>, row_count: Option<usize>, value_types: Option<Vec<Vec<String>>>, // per-row, per-col type tag values: Option<Vec<Vec<Value>>>, }类型标签限定为五种:"null"、"integer"、"real"、"text"、"blob"。JS harness 的typeTag把bigint归一为integer、整数 number 归为integer、非整数 number 归为real,Buffer/Uint8Array/ArrayBuffer 归为blob;Rust 侧 serverless/rust/differential/tests/parity.rs 定义了等价的NormalizedValue枚举与value_type_tag。Rust harness 还额外比较声明列类型(column_decltypes,来自结果列的decltype),比operations.md的基线多一层校验。
JS 侧一个关键设计是:嵌入式驱动与 Serverless 驱动共用同一个执行适配器(executeRemote = executeLocal),因为 Serverless 驱动镜像了嵌入式驱动的公开 API(prepare、columns、raw、all、run)——任何 API 分歧都会在这里以测试失败的形式暴露,这正是 serverless/conformance/differential/README.md 所说的"行为契约的强制执行机制"。
必选属性:每个新 harness 都必须覆盖的tests项
规范中的tests部分列出了每个 harness必须实现的属性。其中大部分将两个驱动与一个实时 Turso Cloud 数据库对比(serverless/rust/differential/tests/parity.rs 的api_parity属性测试即为其 Rust 实现),并各自指定了互不重叠的前缀区间(prefix_range)与示例数,从 50 到 100 不等:
| 属性 | prefix_range | num_examples | 验证重点 |
|---|---|---|---|
api_parity | [0, 65535] | 100 | 随机操作序列(DDL、DML、查询、参数、事务、批处理、触发器、错误)产生完全一致的结果 |
error_recovery | [300000, 365535] | 50 | 失败语句后连接必须仍能执行下一条语句,错误永不卡死流 |
ddl_in_transaction | [100000, 165535] | 50 | 事务内的CREATE TABLE对同事务后续语句可见 |
ddl_prepare_in_transaction | [200000, 265535] | 50 | 事务内prepare()能看见同事务先建的表(Serverless 驱动的describe涉及同流上的服务器往返,曾修复过相关 bug,见 JS harness 注释) |
protocol_properties | [400000, 465535] | 100 | 协议级属性 |
encryption_header | — | 20 | 远程加密密钥头(详见下节) |
JS harness 中的这些属性测试与ops.json的tests条目一一对应:error recovery测试在发送各类错误 SQL(缺表查询、参数个数错误、SELECT length(1, 2, 3)类型不匹配、SELECT 1/0除零)之后,断言随后的SELECT 1必须成功且返回一行1;ddl in transaction与ddl prepare in transaction则分别在两侧执行BEGIN → CREATE → INSERT → SELECT → COMMIT与BEGIN → CREATE → prepare → run → SELECT → COMMIT序列。
一个 harness 只有在覆盖全部条目之后才算完整——新增 API 时也要扩展操作词汇表。
encryption_header:唯一不需要云数据库、永不跳过的属性
encryption_header是例外中的例外。它验证的是 serverless/PROTOCOL.md 第 3.1 节的远程加密密钥约定:配置了密钥K的驱动必须在每一个HTTP 请求(pipeline 与 cursor 端点都算)上附带x-turso-encryption-key: K,未配置密钥的驱动则从不发送该头。
规范参数:
header:"x-turso-encryption-key"key_alphabet: base64 字母表A-Za-z0-9+/key_min_len: 1,key_max_len: 64- 可选追加 0-2 个 base64
=填充 num_examples: 20
它运行在一个本地 stub HTTP 服务器上——该服务器记录收到的每个请求头,并仅实现足以让驱动完成一条语句的最小协议应答(JS 实现见 serverless/javascript/differential/encryption-header.test.mjs:/v3/pipeline返回按请求类型应答的 JSON,/v3/cursor返回换行分隔的step_begin/row/step_end条目流)。两个方向都要断言:
- 配置密钥时,每个请求(包括
exec()触发的 pipeline、all()触发的 cursor、close()的收尾请求)都携带与密钥逐字节相等的头值; - 未配置密钥时,所有请求都不得携带该头。
JS 测试还顺带断言驱动导出的ENCRYPTION_KEY_HEADER常量与规范的header完全一致。由于不依赖任何远程数据库,该测试必须无条件运行、永不因缺少环境配置而跳过——这正是"每个新 Serverless 驱动 harness 未覆盖它就不算完成"的原因。Rust 侧 serverless/rust/differential/tests/encryption_header.rs 用 tokio 手写了一个记录请求头的 TCP stub 服务器,密钥从规范的字母表与长度边界中抽取,padding 由padding % 3决定。
实战:如何运行这套差分测试
准备一个专用 scratch 数据库
测试会创建、填充并删除t_<prefix>_<n>命名的表,务必指向专用数据库,绝不要用你珍惜的库:
$ turso db create serverless-differential $ export TURSO_DATABASE_URL="$(turso db show --url serverless-differential)" $ export TURSO_AUTH_TOKEN="$(turso db tokens create serverless-differential)"构建两侧驱动(JavaScript 为例)
嵌入式侧是原生@tursodatabase/database包,需要 Rust 工具链:
$ cd bindings/javascript $ npm install $ npm run build:nativeServerless 侧从serverless/javascript构建:
$ cd serverless/javascript $ npm install $ npm run build运行套件
$ cd serverless/javascript/differential $ npm install $ npm test未设置TURSO_DATABASE_URL与TURSO_AUTH_TOKEN时,测试会自我跳过(JS 侧通过test.serial.skip,Rust 侧通过config_or_skip()),因此在无凭据的环境中运行是安全的。
调优迭代次数
每个属性测试默认运行 10 个生成用例,这是针对远程数据库网络延迟的量身定做值。想要彻底验证时提高迭代数:
$ HEGEL_NUM_RUNS=100 npm test每个用例可能发出数十次 HTTP 往返,因此墙钟时间同时随迭代数与到你数据库区域间的延迟增长。
Rust harness
Rust 侧对比运行在内存数据库上的嵌入式tursocrate 与访问同一 scratch 数据库的turso_serverless,读取同样的环境变量,未设置时自我跳过:
$ export TURSO_DATABASE_URL="$(turso db show --url serverless-differential)" $ export TURSO_AUTH_TOKEN="$(turso db tokens create serverless-differential)" $ cargo test -p turso_serverless_differentialHEGEL_NUM_RUNS在这里同样控制迭代次数。属性由 hegel 驱动,失败时收缩为最小操作序列并本地存储,下次运行会先重放它。
阅读失败
失败的用例会打印:发生分歧的操作、两侧驱动的结果、该用例的完整操作追踪,以及 fast-check 的 seed 与反例。用相同 seed 重跑会精确复现该用例;fast-check 会先把失败收缩为最小操作序列,所以从它报告的最后一个(最小的)反例开始排查。
小结
差分测试操作规范的核心是一个思想:用一份机器可读的 JSON 定义跨语言的随机化操作词汇,让所有驱动 harness 讲同一种语言,再以嵌入式引擎为参照系,强制 Serverless 驱动在结果、形状、类型与错误语义上与它逐项对齐。从 28 个操作模板到 20 种对抗性取值、从批处理的顺序执行 oracle 到无条件运行的加密头属性,这套规范既是驱动开发的验收标准,也是快速定位类型映射、事务状态与参数绑定缺陷的高效探针。新增 Serverless 语言驱动时,加载 spec/ops.json、覆盖全部tests属性、扩展操作词汇表,即可获得与其他语言完全对等的契约保障。
【免费下载链接】tursoA SQL database in Rust: SQLite-compatible, now also speaking Postgres (experimental). The LLVM of databases.项目地址: https://gitcode.com/GitHub_Trending/tu/turso
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考