开发者用基于属性测试在 DuckDB 中发现 5 个 bug
来源标题:Finding Bugs in DuckDB with Property-based Testing
开发者通过基于属性的测试在 DuckDB 中发现了 5 个 bug,其中 4 个与 COPY ... TO 命令相关,另 1 个属于其他问题。所有 bug 均已报告并得到及时修复。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
DuckDB 是一款被广泛使用的数据库,也是我个人的心头好。我最近用基于属性的测试在 DuckDB 里发现了几个 bug,想分享一下这些 bug 是如何被找到的(所有 bug 均已上报并很快修复)。
我一共找到了 5 个 bug,其中 4 个与 COPY ... TO 命令相关,剩下 1 个是另一个问题。我们逐一来看。
DuckDB 有一个 COPY ... TO 命令,可以把查询结果写入文件。例如,它的模板大致如下:
COPY (SELECT 1 AS a) TO 'out.json' (..., ...); -- (..., ...) 是命令所接受选项的占位符
它会把 select 查询返回的内容复制出来,并使用最右侧括号中提供的选项写入 json 文件。至于最右侧括号里的这些选项,举个例子,可以运行下面这条命令:
COPY (SELECT 1 AS a) TO 'out.json' (FORMAT JSON, use_tmp_file);
FORMAT JSON 选项指定我们想要的文件格式,而 use_tmp_file 是一个布尔选项,开启后会把数据写入临时文件。写入完成后,该文件会被重命名为 out.json。
我们最早发现的 bug 之一,来自一条包含 use_tmp_file NULL 的 COPY ... TO 命令。当基于属性的测试运行它时,DuckDB 报错失败:
INTERNAL Error: Calling GetValueInternal on a value that is NULL
我们是怎么写出这个基于属性的测试的?过去几年我一直在用 Hypothesis,这是一个基于 Python 的属性测试框架。我发现它不仅非常适合测试那些输入组合极其繁多的系统,还能让代码以一种不那么晦涩的方式表达这些巧思。
思路是这样的:你写下必须始终成立的不变量,然后对你的代码尝试各种输入,确保这些不变量成立。代码主要有两个组件。第一个组件充当生成器,第二个组件充当管理器,负责报告所运行操作的成功或失败。生成器会产生大量 COPY ... TO 命令,把不同的输入填入命令的选项中,管理器则拿这些命令去跑 DuckDB,并报告成功或失败。非法输入本应产生一个正常的错误,但其中一个却触发了内部错误。
为了让大家有个概念,下面是可能生成这类数据的简化代码:
def copy_statement(draw): opt = draw(sampled_from(["use_tmp_file", "compression", "dateformat"])) val = draw(sampled_from(["NULL", "''", "42", "[1,2,3]", "TRUE"])) # odd on purpose return f"COPY (SELECT 1 AS a) TO 'out.json' (FORMAT JSON, {opt} {val})"
它会生成这样的语句:
COPY (SELECT 1 AS a) TO 'out.json' (FORMAT JSON, compression 42); COPY (SELECT 1 AS a) TO 'out.json' (FORMAT JSON, dateformat [1,2,3]); COPY (SELECT 1 AS a) TO 'out.json' (FORMAT JSON, use_tmp_file TRUE) COPY (SELECT 1 AS a) TO 'out.json' (FORMAT JSON, use_tmp_file NULL);
Hypothesis 中的 composite 装饰器让我们可以编写自定义生成器。sampled_from 策略从给定列表中选取一个值,draw 则从该策略中取出一个值。我们就是这样产生出许多不同的 COPY ... TO 命令的。
如你所见,生成器产生了一条包含 use_tmp_file NULL 的命令。当管理器运行它时,DuckDB 抛出了一个内部错误。随后我手动复现了它,并在 DuckDB 的 issue tracker 上做了报告(#22866)。同样的属性测试代码又在 COPY ... TO 命令上找到了另外三个 bug(#22867、#22868、#22869)。这些都被 DuckDB 团队迅速修复了。
我们发现的第二类 bug 很有意思。这个测试的思路很简单:生成一个浮点数,把它作为数值字面量写进 INSERT,插入到一个 DOUBLE 列中,再读回来,然后验证读回来的数字和最初的数字是否相同。在其中一个用例中,两者并不相同。我们插入 0.41000000000000003,读回来却是 0.40999999999999998。
对于这个 bug,我们使用的生成器代码简化后大致如下:
def doubles(draw): return draw(lists(one_of( floats(allow_nan=False, allow_infinity=False), sampled_from([0.1, 0.41000000000000003, 0.9999999999999999]), # hard cases )))
这段代码生成 Python 浮点数列表,喂给 DuckDB:
[0.1] [0.41000000000000003] [0.41000000000000003, 0.0] [0.1, 6.820642817941818e+16] [0.9999999999999999, -3.782657869065369e+16]
floats 策略基本上就是生成浮点数。sampled_from 策略从我们的值列表中选取,one_of 则允许每个元素来自任意一种策略。
接着代码会使用这些值,把它们写入数据库再读回来,通过比较来判定成功或失败:相同即成功,不同即失败。
例如,对于 0.41000000000000003 这个用例,它会发出下面这样的 SQL,并比较和读取值。
CREATE TABLE t (x DOUBLE); INSERT INTO t VALUES (0.41000000000000003); SELECT x FROM t;
我们插入的是 0.41000000000000003,读回来却是 0.40999999999999998。严格来说这并不是存储层面的 bug。DuckDB 把这个数值字面量解释成了 DECIMAL,然后在转换成 DOUBLE 时进行了不正确的舍入。同样的数字如果作为文本用在查询中,并用 CAST 直接转换为 DOUBLE,就会得到正确舍入的结果:
SELECT -- this outputs the wrong value: 0.40999999999999998 printf('%.17g', 0.41000000000000003), -- this outputs the correct value: 0.41000000000000003 printf('%.17g', CAST('0.41000000000000003' AS DOUBLE));
这个问题是在 DuckDB 1.5.3 上发现的,此后已被修复(#22837)。
DuckDB 的测试覆盖很广。它使用了 fuzzing,也会检查不变量。即便有这么多测试,Hypothesis 中的基于属性的测试仍然帮上了忙,并发现了几个问题。
最近我一直在帮助不少团队使用基于属性的测试,事实证明,它不仅能有效提升覆盖率,还能让代码更加健壮。在一些指导下,AI 也能帮助朝着这个目标推进。如果你正在考虑为你的团队引入基于属性的测试,我很乐意分享我学到的东西。
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。