集成测试#
为确保 Arrow 的不同实现之间可以互操作,Arrow 项目包含了跨语言集成测试,这些测试会作为持续集成任务定期运行。
集成测试检验了对几个 Arrow 规范的遵循情况:IPC 格式、Flight RPC 协议以及 C 数据接口。
策略#
我们对 Arrow 实现之间进行集成测试的策略是:
测试数据集采用一种自定义的、人类可读的、基于 JSON 的格式来指定,该格式专为 Arrow 的集成测试设计。
JSON 文件由集成测试工具生成。不同的文件用于表示不同的数据类型和特性,例如数值、列表、字典编码等。这样,相比于将所有数据类型都放在一个文件中,能更容易地定位不兼容问题。
每种实现都提供了能够将 JSON 格式与 Arrow 内存表示进行相互转换的入口点,并且能够使用指定的格式暴露 Arrow 内存数据。
每种格式(无论是 Arrow IPC、Flight 还是 C 数据接口)都会对所有支持的(生产者、消费者)实现组合进行测试。生产者通常读取一个 JSON 文件,将其转换为内存中的 Arrow 数据,并使用待测格式暴露这些数据。消费者则读取该格式的数据并将其转换回内存中的 Arrow 数据;它还会读取与生产者相同的 JSON 文件,并验证两个数据集是否完全相同。
示例:IPC 格式#
假设我们正在测试 Arrow C++ 作为生产者、Arrow Java 作为消费者的 Arrow IPC 格式。测试一个 JSON 文件的流程如下:
一个 C++ 可执行文件读取 JSON 文件,将其转换为 Arrow 内存数据,并写入一个 Arrow IPC 文件(文件路径通常通过命令行参数给出)。
一个 Java 可执行文件读取 JSON 文件,将其转换为 Arrow 内存数据;它同时也会读取由 C++ 生成的 Arrow IPC 文件。最后,它会验证两个 Arrow 内存数据集是否相等。
示例:C 数据接口#
现在,假设我们正在测试 Arrow Go 作为生产者、Arrow C# 作为消费者的 Arrow C 数据接口。
集成测试工具会在堆上分配一个 C ArrowArray 结构体。
一个 Go 进程内入口点(例如一个与 C 兼容的函数调用)读取一个 JSON 文件,并将其中的一个记录批次导出到
ArrowArray结构体中。一个 C# 进程内入口点读取同一个 JSON 文件,将同一个记录批次转换为 Arrow 内存数据;它还会导入由 Arrow Go 导出到
ArrowArray结构体中的记录批次。它验证两个记录批次是否相等,然后释放导入的记录批次。根据实现语言的能力,集成测试工具可能会断言内存消耗保持不变(即导出的记录批次没有发生内存泄漏)。
最后,集成测试工具会释放
ArrowArray结构体。
运行集成测试#
集成测试数据生成器和运行器实现在 Archery 工具中。你需要安装 archery 的 integration 组件:
$ pip install -e "dev/archery[integration]"
集成测试通过 archery integration 命令运行。
$ archery integration --help
为了运行集成测试,你首先需要构建你想要包含的每个组件。请参考 C++、Java 等各自的开发者文档,了解如何构建它们。
某些语言可能需要额外的构建选项来启用集成测试。例如,对于 C++,你需要在 cmake 命令中添加 -DARROW_BUILD_INTEGRATION=ON。
根据你已构建的组件,你可以启用它们并将它们添加到 archery 测试运行中。例如,如果你只构建了 C++ 项目并想运行 Arrow IPC 集成测试,请运行:
archery integration --run-ipc --with-cpp=1
对于 Java,命令可能如下所示:
VERSION=14.0.0-SNAPSHOT
export ARROW_JAVA_INTEGRATION_JAR=$JAVA_DIR/tools/target/arrow-tools-$VERSION-jar-with-dependencies.jar
archery integration --run-ipc --with-cpp=1 --with-java=1
要运行所有测试,包括 Flight 和 C 数据接口的集成测试,请执行:
archery integration --with-all --run-flight --run-ipc --run-c-data
请注意,我们在持续集成中运行这些测试,CI 作业使用 Docker Compose。你也可以在本地运行 Docker Compose 作业,或者如果你对如何构建其他语言或启用某些测试有疑问,可以参考它。
有关该项目 docker compose 配置的更多信息,请参见运行 Docker 构建。
JSON 测试数据格式#
为跨语言集成测试提供了一种 Arrow 列式数据的 JSON 表示。这种表示并非官方标准,但它提供了一种人类可读的方式来验证语言实现。
有关此 JSON 数据的一些示例,请参见此处。
一个 JSON 集成测试文件的高层结构如下:
数据文件
{
"schema": /*Schema*/,
"batches": [ /*RecordBatch*/ ],
"dictionaries": [ /*DictionaryBatch*/ ],
}
所有文件都包含 schema 和 batches,而 dictionaries 仅在 schema 中存在字典类型字段时才会出现。
Schema
{
"fields" : [
/* Field */
],
"metadata" : /* Metadata */
}
Field
{
"name" : "name_of_the_field",
"nullable" : /* boolean */,
"type" : /* Type */,
"children" : [ /* Field */ ],
"dictionary": {
"id": /* integer */,
"indexType": /* Type */,
"isOrdered": /* boolean */
},
"metadata" : /* Metadata */
}
当且仅当 Field 对应于字典类型时,才会出现 dictionary 属性,并且其 id 映射到 DictionaryBatch 中的一列。在这种情况下,type 属性描述了字典的值类型。
对于原生类型,children 是一个空数组。
元数据
null |
[ {
"key": /* string */,
"value": /* string */
} ]
一个键值对形式的自定义元数据映射。它可以被省略或为 null,在这种情况下,它被视为等同于 [](无元数据)。这里不禁止重复的键。
类型:
{
"name" : "null|struct|list|largelist|listview|largelistview|fixedsizelist|union|int|floatingpoint|utf8|largeutf8|binary|largebinary|utf8view|binaryview|fixedsizebinary|bool|decimal|date|time|timestamp|interval|duration|map|runendencoded"
}
一个 Type 会根据其名称拥有在 Schema.fbs 中定义的其他字段。
整数
{
"name" : "int",
"bitWidth" : /* integer */,
"isSigned" : /* boolean */
}
浮点数
{
"name" : "floatingpoint",
"precision" : "HALF|SINGLE|DOUBLE"
}
定长二进制
{
"name" : "fixedsizebinary",
"byteWidth" : /* byte width */
}
Decimal
{
"name" : "decimal",
"precision" : /* integer */,
"scale" : /* integer */
}
Timestamp
{
"name" : "timestamp",
"unit" : "$TIME_UNIT",
"timezone": "$timezone"
}
$TIME_UNIT 是 "SECOND|MILLISECOND|MICROSECOND|NANOSECOND" 之一。
“timezone” 是一个可选的字符串。
持续时间
{
"name" : "duration",
"unit" : "$TIME_UNIT"
}
日期型 (Date)
{
"name" : "date",
"unit" : "DAY|MILLISECOND"
}
时间
{
"name" : "time",
"unit" : "$TIME_UNIT",
"bitWidth": /* integer: 32 or 64 */
}
间隔
{
"name" : "interval",
"unit" : "YEAR_MONTH|DAY_TIME"
}
联合
{
"name" : "union",
"mode" : "SPARSE|DENSE",
"typeIds" : [ /* integer */ ]
}
Union 中的 typeIds 字段是用来表示在每个数组槽位中联合体的哪个成员是活跃的代码。请注意,这些判别符通常与相应子数组的索引不相同。
列表
{
"name": "list"
}
列表所“包含”的元素类型将作为单个 Field 包含在 Field 的 “children” 成员中。例如,对于一个 int32 类型的列表:
{
"name": "list_nullable",
"type": {
"name": "list"
},
"nullable": true,
"children": [
{
"name": "item",
"type": {
"name": "int",
"isSigned": true,
"bitWidth": 32
},
"nullable": true,
"children": []
}
]
}
定长列表
{
"name": "fixedsizelist",
"listSize": /* integer */
}
此类型同样带有一个长度为1的“children”数组。
结构体
{
"name": "struct"
}
Field 的 “children” 包含一个由具有有意义名称和类型的 Fields 组成的数组。
Map
{
"name": "map",
"keysSorted": /* boolean */
}
Field 的 “children” 包含一个单独的 struct 字段,该字段本身包含两个子字段,名为 “key” 和 “value”。
Null
{
"name": "null"
}
行程编码
{
"name": "runendencoded"
}
Field 的 “children” 应该恰好是两个子字段。第一个子字段必须命名为 “run_ends”,不可为空,并且是 int16、int32 或 int64 类型的字段。第二个子字段必须命名为 “values”,但可以是任何类型。
扩展类型与 IPC 格式中一样,由其底层的存储类型加上一些专用的字段元数据来表示,以便重建扩展类型。例如,假设一个“rational”(有理数)扩展类型,其底层存储为 struct<numer: int32, denom: int32>,那么一个“rational”字段的表示如下:
{
"name" : "name_of_the_field",
"nullable" : /* boolean */,
"type" : {
"name" : "struct"
},
"children" : [
{
"name": "numer",
"type": {
"name": "int",
"bitWidth": 32,
"isSigned": true
}
},
{
"name": "denom",
"type": {
"name": "int",
"bitWidth": 32,
"isSigned": true
}
}
],
"metadata" : [
{"key": "ARROW:extension:name", "value": "rational"},
{"key": "ARROW:extension:metadata", "value": "rational-serialized"}
]
}
RecordBatch:
{
"count": /* integer number of rows */,
"columns": [ /* FieldData */ ]
}
字典批次:
{
"id": /* integer */,
"data": [ /* RecordBatch */ ]
}
字段数据:
{
"name": "field_name",
"count" "field_length",
"$BUFFER_TYPE": /* BufferData */
...
"$BUFFER_TYPE": /* BufferData */
"children": [ /* FieldData */ ]
}
Schema 中 Field 的 “name” 成员对应于 RecordBatch 的 “columns” 中包含的 FieldData 的 “name”。对于嵌套类型(list, struct 等),Field 的 “children” 中的每个 “name” 对应于该 FieldData 的 “children” 内部的 FieldData 的 “name”。对于 DictionaryBatch 内部的 FieldData,“name” 字段不对应任何东西。
这里 $BUFFER_TYPE 是 VALIDITY、OFFSET(用于可变长度类型,如字符串和列表)、TYPE_ID(用于联合体)或 DATA 之一。
BufferData 根据缓冲区的类型进行编码:
VALIDITY:一个由 1(有效)和 0(空值)组成的 JSON 数组。对于不可为空的Field,其数据仍然有一个VALIDITY数组,即使所有值都是 1。OFFSET:对于 32 位偏移量,是一个 JSON 整数数组;对于 64 位偏移量,是一个字符串格式的整数数组。TYPE_ID:一个 JSON 整数数组。DATA:一个编码值的 JSON 数组。VARIADIC_DATA_BUFFERS:一个由十六进制编码字符串表示的数据缓冲区组成的 JSON 数组。VIEWS:一个编码视图的 JSON 数组,每个视图是一个 JSON 对象,包含:SIZE:一个表示视图大小的整数,INLINED:一个编码值(如果SIZE小于 12,则此字段会出现,否则接下来的三个字段会出现),PREFIX_HEX:视图的前四个字节,以十六进制编码,BUFFER_INDEX:所查看缓冲区在VARIADIC_DATA_BUFFERS中的索引,OFFSET:在所查看缓冲区中的偏移量。
DATA 的值编码因逻辑类型而异:
对于布尔类型:一个由 1 (true) 和 0 (false) 组成的数组。
对于基于整数的类型(包括时间戳):一个 JSON 数字数组。
对于 64 位整数:一个格式化为 JSON 字符串的整数数组,以避免精度损失。
对于浮点类型:一个 JSON 数字数组。值被限制在小数点后 3 位以避免精度损失。
对于二进制类型:一个大写十六进制编码的字符串数组,以便表示任意二进制数据。
对于 UTF-8 字符串类型:一个 JSON 字符串数组。
对于“list”和“largelist”类型,BufferData 具有 VALIDITY 和 OFFSET,其余数据位于“children”内部。这些子 FieldData 包含与非子数据相同的所有属性,因此在一个 int32 列表的示例中,子数据具有 VALIDITY 和 DATA。
对于“fixedsizelist”,没有 OFFSET 成员,因为偏移量由字段的“listSize”隐含。
请注意,这些子数据的“count”可能与父级的“count”不匹配。例如,如果一个 RecordBatch 有 7 行,并包含一个 FixedSizeList,其 listSize 为 4,那么该 FieldData 的“children”内部的数据计数将为 28。
对于“null”类型,BufferData 不包含任何缓冲区。
Archery 集成测试用例#
通过了解自动化集成测试实际测试了哪些用例,这份列表可以帮助我们更容易地理解未来 Arrow 格式发生任何变化时,可能需要进行哪些手动测试。
集成测试用例有两种类型:一种是由 Archery 工具中的数据生成器动态生成的,另一种是存在于 arrow-testing 仓库中的“黄金”文件。
数据生成器测试#
这是使用 archery integration 命令生成和测试的用例的高级描述(参见 datagen.py 中的 get_generated_json_files):
原生类型 - 无批次 - 各种原生值 - 零长度批次 - 字符串和二进制大偏移量用例
空类型 * 简单的空值批次
Decimal128
十进制数256
不同单位的日期时间
不同单位的时长
时间间隔 - MonthDayNano 间隔是一个单独的用例
映射类型 - 非规范映射
嵌套类型 - 列表 - 结构体 - 大偏移量列表
联合体
自定义元数据
包含重复字段名的 Schema
字典类型 - 有符号索引 - 无符号索引 - 嵌套字典
行程长度编码
二进制视图和字符串视图
列表视图和大型列表视图
扩展类型
黄金文件集成测试#
预生成的 JSON 和 Arrow IPC 文件(包括文件和流格式)存在于 arrow-testing 仓库的 data/arrow-ipc-stream/integration 目录中。这些文件作为被假定为正确的“黄金”文件用于测试。Archery 工具代码中的 runner.py 引用了它们。以下是它们所覆盖的测试用例:
向后兼容性
以下用例使用 0.14.1 格式进行测试:
日期时间
小数
字典
时间间隔
映射
嵌套类型(列表、结构体)
原生类型
无批次的原生类型
零长度批次的原生类型
以下用例针对 0.17.1 格式进行测试:
联合体
字节序
以下用例同时测试小端序和大端序版本,以实现自动转换:
自定义元数据
日期时间
小数
decimal256
字典
带无符号索引的字典
带重复字段名的记录批次
扩展类型
时间间隔类型
映射类型
非规范的映射数据
嵌套类型(列表、结构体)
嵌套字典
嵌套大偏移量类型
空值
原生数据
大偏移量二进制和字符串
不包含批次的原生类型
零长度的原生批次
递归嵌套类型
联合体类型
压缩测试
LZ4
ZSTD
带有共享字典的批次
生成新的黄金文件#
有时,需要添加新的黄金文件,例如当列式格式或 IPC 规范更新时。Archery 为此提供了一个专门的选项。
建议使用一个知名版本的 Arrow 实现来生成黄金文件。例如,如果 Arrow C++ 的构建版本存在于 ./build/release/ 中,可以使用以下命令在 /tmp/gold-files 目录中生成新的黄金文件:
export ARROW_CPP_EXE_PATH=./build/release/
archery integration --with-cpp 1 --write-gold-files=/tmp/gold-files