集成测试#

为确保 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 文件的流程如下:

  1. 一个 C++ 可执行文件读取 JSON 文件,将其转换为 Arrow 内存数据,并写入一个 Arrow IPC 文件(文件路径通常通过命令行参数给出)。

  2. 一个 Java 可执行文件读取 JSON 文件,将其转换为 Arrow 内存数据;它同时也会读取由 C++ 生成的 Arrow IPC 文件。最后,它会验证两个 Arrow 内存数据集是否相等。

示例:C 数据接口#

现在,假设我们正在测试 Arrow Go 作为生产者、Arrow C# 作为消费者的 Arrow C 数据接口。

  1. 集成测试工具会在堆上分配一个 C ArrowArray 结构体。

  2. 一个 Go 进程内入口点(例如一个与 C 兼容的函数调用)读取一个 JSON 文件,并将其中的一个记录批次导出到 ArrowArray 结构体中。

  3. 一个 C# 进程内入口点读取同一个 JSON 文件,将同一个记录批次转换为 Arrow 内存数据;它还会导入由 Arrow Go 导出到 ArrowArray 结构体中的记录批次。它验证两个记录批次是否相等,然后释放导入的记录批次。

  4. 根据实现语言的能力,集成测试工具可能会断言内存消耗保持不变(即导出的记录批次没有发生内存泄漏)。

  5. 最后,集成测试工具会释放 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*/ ],
}

所有文件都包含 schemabatches,而 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”,不可为空,并且是 int16int32int64 类型的字段。第二个子字段必须命名为 “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 */ ]
}

SchemaField 的 “name” 成员对应于 RecordBatch 的 “columns” 中包含的 FieldData 的 “name”。对于嵌套类型(list, struct 等),Field 的 “children” 中的每个 “name” 对应于该 FieldData 的 “children” 内部的 FieldData 的 “name”。对于 DictionaryBatch 内部的 FieldData,“name” 字段不对应任何东西。

这里 $BUFFER_TYPEVALIDITYOFFSET(用于可变长度类型,如字符串和列表)、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 具有 VALIDITYOFFSET,其余数据位于“children”内部。这些子 FieldData 包含与非子数据相同的所有属性,因此在一个 int32 列表的示例中,子数据具有 VALIDITYDATA

对于“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