Grafana Loki 列式计算测试 DSL 详解:pkg/compute 领域特定语言与选择向量测试机制
【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki
Grafana Loki 的pkg/compute是一个无状态列式数据计算包,它通过一组纯函数对列式数据(columnar.Datum)执行等于、比较、逻辑运算、子串匹配、集合成员判断等操作。为了对这些计算函数进行系统、紧凑、可读的测试,Loki 在 pkg/compute/testdata 下定义了一套专属的领域特定语言(DSL),把一组计算操作与期望结果写成一行一行的测试用例。本文以 pkg/compute/testdata/README.md 为骨架,完整讲解这套 DSL 的语法、数据类型、测试用例组织方式,并结合compute_test.go、computetest/parser.go等源码揭示其解析与执行原理。读完本文,你将能够阅读、编写和扩展这类 DSL 测试文件,理解选择向量(selection vector)如何参与计算并影响结果,以及如何在新增计算函数时把 DSL 测试接入测试框架。
一、背景:compute 包与列式测试 DSL 的定位
pkg/compute是 Loki 内部的列式计算包,其包注释(pkg/compute/doc.go)明确说明:
- 它实现"无状态"的计算操作,作用对象是一个或多个
columnar.Datum——一种不透明表示,既可以是一个数组(array),也可以是一个标量(scalar)值,并带有特定的值类型(kind)。 - 除非特别说明,所有 compute 函数返回的数据都使用调用方传入的
memory.Allocator分配。 - 该包目前标注为EXPERIMENTAL(实验性),当前仅被 pkg/dataobj 使用。
compute 包内部按功能拆分为多个源码文件:equality.go(等于/不等于/大小比较)、logical.go(NOT/AND/OR)、filter.go(FILTER)、set.go(IsMember)、utf8.go(子串与正则匹配),外加compat.go、stats.go、shape.go、assertions.go、results_cache.go等外围文件。
围绕这些计算函数,pkg/compute/testdata/目录存放了用 DSL 编写的测试文件,它们与 Go 测试互补:DSL 擅长表达"输入参数组合 + 期望输出"这种高密度的数据驱动用例,Go 测试则负责 DSL 难以表达的复杂场景(如期望报错、record batch、struct 操作等)。
二、DSL 语法规范
pkg/compute/testdata/README.md给出了完整的 DSL 文法(grammar):
Cases := Case* Case := Function Datum* Selection? "->" Datum TERMINATOR Datum := TypedValue Selection := "select" ":" "[" Scalar* "]" TypedValue := Type ":" Value Type := "bool" | "int32" | "int64" | "uint32" | "uint64" | "utf8" | "null" Value := Scalar | Array Scalar := <literal> | "null" | "_" Array := "[" Scalar* "]" TERMINATOR := "\n"逐条拆解:
- Cases:一个测试文件由零个或多个 Case 组成。
- Case:一行测试用例,结构为
函数名 + 若干 Datum 参数 + 可选的选择向量 + "->" + 期望结果 Datum + 行结束符。例如EQ int32:5 int32:5 -> bool:true。 - Datum:带类型的值,形如
类型:值,例如int32:[1 2 3]、utf8:"hello"、null:[null null]。 - Selection:可选的选择向量,形如
select:[true false true],必须是布尔数组,用于指示哪些行参与计算、哪些行在结果中被标记为"未定义"(见第四节)。 - TERMINATOR:一条用例以换行符结束。
- 注释:以
#开头,持续到行尾。所有空白字符都被忽略,因此排版比较自由(例如函数名与参数之间可以用多个空格对齐,便于阅读)。
2.1 从文法到解析器实现
这套文法在 pkg/compute/internal/computetest/parser.go 中有逐一的实现对应:
parseCases()(parser.go 第 54 行)循环调用parseCase(),直至遇到tokenEOF;parseCase()(第 70 行)先读取函数名,然后循环解析 Datum 参数,直到遇到->(tokenArrow)或select(tokenSelect);接着解析可选的选择向量,再解析->后的期望结果,最后要求一个TERMINATOR收尾;parseDatum()(第 117 行)根据类型标识分发到parseBoolDatum、parseInt32Datum、parseInt64Datum、parseUint32Datum、parseUint64Datum、parseUTF8Datum、parseNullDatum;parseSelection()(第 596 行)解析select:后的布尔数组,并且校验选择向量中不能包含 null 值,否则报错。
底层还依赖词法分析器scanner.go与 token 定义token.go:整数字面量支持可选负号(-42),字符串字面量必须加引号并支持转义序列,标识符用于函数名、类型名以及true/false/null/_等字面量。
三、数据类型详解
DSL 支持 7 种类型(对应 parser.go 中parseDatum的分发逻辑),以及一个特殊的"未定义"占位符:
| 类型 | 含义 | 示例 |
|---|---|---|
bool | 布尔值 | true、false、null |
int32 | 有符号 32 位整数 | -42、123 |
int64 | 有符号 64 位整数 | -42、123 |
uint32 | 无符号 32 位整数 | 0、456 |
uint64 | 无符号 64 位整数 | 0、456 |
utf8 | UTF-8 字符串,必须加引号 | "hello"、"test string",支持转义序列 |
null | 显式的空值类型,用于纯 null 值 | null:[null null null] |
_ | 未定义值(undefined) | 表示某个位置的值是"未定义"的,例如选择向量中未被选中的行 |
关于_有一个重要细节:它不是一个独立的"类型",而是值层面的占位符。从 parser.go 可以看到,_在标量位置会被解析为对应类型的空标量(如&columnar.BoolScalar{}、&columnar.NumberScalar[int32]{}、&columnar.UTF8Scalar{}),在数组位置(如bool:[true _ true])则向 builder 追加一个占位值。因此_可以出现在任意类型的值位置上,表示"该槽位无有效值",通常用来与选择向量配合,指示未选中的行。
解析器还校验数值的字面量范围:int32使用strconv.ParseInt(..., 32),uint64使用strconv.ParseUint(..., 64),超范围会在解析阶段直接报错,而不是等到运行时。
四、选择向量(Selection Vector)语义
选择向量是这套测试 DSL 最具特色的机制,也是 pkg/compute/ARCHITECTURE.md 详细阐述的核心设计。它让计算操作能够高效地做"行级过滤"——只对满足条件的行执行计算,而不必物化出过滤后的子集。
4.1 表示方式与约定
选择向量在运行时用 memory.Bitmap 表示,bitmap 中的每一位对应输入数据的一行:
- true bit:该行被选中,参与计算;
- false bit:该行未被选中,在结果中按 null/未定义处理。
关键约定(摘自 ARCHITECTURE.md):
- 全部选中:当 selection 参数
Len() == 0时,表示所有行都被选中。这是默认行为,且相比不支持选择向量的操作零额外性能开销:
allSelected := memory.Bitmap{} // Len() == 0 result := compute.Equals(mem, left, right, allSelected) // all rows selected- 部分选中:当 selection bitmap 的
Len() > 0时,只有对应位为 true 的行参与计算,其余行在结果中被置为未定义:
selection := memory.NewBitmap(mem, 10) // 10 rows selection.Set(0) // select first row selection.Set(5) // select sixth row result := compute.Equals(mem, left, right, selection) // only rows 0 and 5 are computed, others are null4.2 调度模式(Dispatch Pattern)
compute 函数针对输入组合采用统一的调度模式(见 ARCHITECTURE.md 的 "Dispatch Pattern" 一节),选择向量只作用于数组结果:
| 输入组合 | 缩写 | 选择向量处理 |
|---|---|---|
| 标量-标量 | SS | 不应用选择向量(结果是标量) |
| 标量-数组 | SA | 对结果数组应用选择向量 |
| 数组-标量 | AS | 对结果数组应用选择向量 |
| 数组-数组 | AA | 对结果数组应用选择向量 |
设计动机包括:懒求值(被过滤的数据不复制、不物化)、内存高效(未选中的行原地保留)、可组合(选择向量可通过位运算合并)、一致的空值处理(未选中的行按 null 处理)。
4.3 DSL 中的选择向量写法
在 DSL 中,选择向量写作select:[true false true false]这种形式,位于参数之后、->之前。以 pkg/compute/testdata/selection.test 中的用例为例:
EQ int32:[1 2 3 4] int32:[1 0 3 0] select:[true false true false] -> bool:[true _ true _]解读:左右两个数组逐行比较,只有第 0 行和第 2 行被选中,因此这两行产生真实比较结果true、true;第 1、3 行未被选中,结果槽位写为_(未定义)。所以期望输出为bool:[true _ true _]。
选择向量还有几个值得注意的边界行为(均可在 selection.test 与 logical.test 中看到对应用例):
- 全不选中:
select:[false false false false]时输出全部为_(例如AND ... select:[false false false false] -> bool:[_ _ _ _]); - 单选:只选中一行时,只有该行有计算结果,其余为
_; - 带 null 的输入 + 选择向量:选中行中如果有 null,结果为 null,未选中行仍为
_,二者语义不同但可共存(例如AND bool:[true null true false] bool:[true true false null] select:[true true false false] -> bool:[true null _ _])。
五、测试文件组织与运行方式
5.1 目录结构
pkg/compute/testdata 下共有 6 个.test文件,按计算函数分组:
| 文件 | 覆盖的函数 | 说明 |
|---|---|---|
| equality.test | EQ / NEQ / LT / LTE / GT / GTE | 等于、不等于、小于、小于等于、大于、大于等于 |
| logical.test | NOT / AND / OR | 逻辑非、与、或 |
| filter.test | FILTER | 按掩码过滤数组 |
| set.test | ISMEMBER | 集合成员判断 |
| utf8.test | SUBSTR / SUBSTRI / REGEXP | 子串、大小写不敏感子串、正则匹配 |
| selection.test | 混合函数 | 专门演示选择向量的遮蔽行为 |
5.2 运行入口:TestCompute
所有这些.test文件由 pkg/compute/compute_test.go 中的TestCompute函数统一驱动(第 20 行起):
func TestCompute(t *testing.T) { require.NoError(t, filepath.WalkDir("testdata", func(path string, d fs.DirEntry, walkErr error) error { // 只处理 *.test 文件 cases, err := computetest.ParseCases(f) ... for _, tc := range cases { t.Run(fmt.Sprintf("%s/source=%s:%d", tc.Function, d.Name(), tc.Line), func(t *testing.T) { var alloc memory.Allocator result, err := evalCaseFunction(t, &alloc, tc) require.NoError(t, err) mask := tc.Selection if tc.Function == "FILTER" { // FILTER 会把掩码物化,因此不再把 mask 传给 RequireDatumsEqual mask = memory.Bitmap{} } columnartest.RequireDatumsEqual(t, tc.Expect, result, mask) }) } ... })) }运行方式:
cd pkg/compute && go test -run TestCompute ./...TestCompute会递归遍历testdata目录,读取所有.test后缀文件,用computetest.ParseCases解析出全部用例,然后逐条调用evalCaseFunction执行对应的 compute 函数,最后用columnartest.RequireDatumsEqual对比实际结果与期望结果(并考虑选择向量掩码)。每个用例的测试名包含函数名、源文件名与行号(tc.Line),失败时可以精确定位到具体某一行 DSL。
其中FILTER有特殊处理:FILTER 会物化掩码、真正压缩出结果数组,所以校验阶段不再回传 mask,避免重复遮蔽。
六、核心计算函数与 DSL 用例解析
evalCaseFunction(compute_test.go 第 58 行)维护着 DSL 函数名到 compute 包函数的映射表,这是理解 DSL 的关键索引:
| DSL 函数名 | 底层实现(pkg/compute) | 参数 | 特殊处理 |
|---|---|---|---|
EQ | Equals | 2 | 要求两侧类型一致 |
NEQ | NotEquals | 2 | 同上 |
LT | LessThan | 2 | 要求可排序类型 |
LTE | LessOrEqual | 2 | 同上 |
GT | GreaterThan | 2 | 同上 |
GTE | GreaterOrEqual | 2 | 同上 |
NOT | Not | 1 | 仅布尔 |
AND | And | 2 | 仅布尔 |
OR | Or | 2 | 仅布尔 |
SUBSTR | Substr | 2 | 大小写敏感子串 |
SUBSTRI | SubstrInsensitive | 2 | 大小写不敏感子串 |
REGEXP | RegexpMatch | 2 | 第二个参数必须是 utf8 标量,被编译为正则 |
FILTER | Filter | 1 | 用选择向量做掩码物化过滤 |
ISMEMBER | IsMember | 2 | 第二个参数(数组)被转换为columnar.Set |
下面按文件逐一说明各类函数在 DSL 中的用例写法。
6.1 等值与比较运算:equality.test
equality.test是最大的一个测试文件,系统覆盖了 EQ / NEQ / LT / LTE / GT / GTE 六种比较函数,每种函数都覆盖:
- 标量-标量(scalar, scalar):
EQ bool:true bool:false -> bool:false EQ int32:5 int32:5 -> bool:true LT utf8:"a" utf8:"b" -> bool:true- 标量-数组(scalar, array):
EQ bool:true bool:[true false null] -> bool:[true false null] LT int32:5 int32:[3 5 10 null] -> bool:[false false true null]- 数组-标量(array, scalar):
NEQ int32:[5 10 null] int32:10 -> bool:[true false null] GTE utf8:["apple" "banana" "cherry" null] utf8:"banana" -> bool:[false true true null]- 数组-数组(array, array):
EQ int32:[1 2 3 4 null null null] int32:[1 3 3 5 1 2 null] -> bool:[true false true false null null null] GT int32:[5 5 10 10 null null null] int32:[1 5 5 15 1 2 null] -> bool:[true false true false null null null]- 带选择向量:
EQ int32:[10 20 30 40] int32:[10 21 30 41] select:[true false true false] -> bool:[true _ true _] LTE int32:[10 20 10] int32:[10 15 10] select:[true false true] -> bool:[true _ true]从 equality.go 的Equals实现可以看到一个明确的规则:两侧类型不一致直接报错(both inputs must be the same kind),且任何一侧出现 null,结果就是 null。LessThan等比较运算要求类型"可排序"(ordered),对null同样传播空值。
值得注意的用例细节:在标量-数组和数组-标量组合中,null的传播行为与标量-标量一致——只要参与比较的对应位置出现 null,结果就是 null,例如EQ int32:null int32:[5 10 null] -> bool:[null null null]。
6.2 逻辑运算:logical.test
logical.test覆盖 NOT / AND / OR 三种逻辑运算。核心语义同样遵循"三值逻辑"(true / false / null):
NOT bool:true -> bool:false NOT bool:null -> bool:null AND bool:true bool:null -> bool:null OR bool:false bool:null -> bool:null AND bool:[true false null] bool:false -> bool:[false false null] OR bool:[true false null] bool:true -> bool:[true true null]带选择向量的逻辑运算用例也覆盖了四种组合(数组-数组、标量-数组、数组-标量):
AND bool:[true false true false] bool:[true true false false] select:[true false true false] -> bool:[true _ false _] AND bool:true bool:[true false true false] select:[true false true false] -> bool:[true _ true _] OR bool:[true false true false] bool:false select:[false true false true] -> bool:[_ false _ false]从源码看,Not(logical.go)直接忽略选择向量参数(参数名为_ memory.Bitmap),因为 NOT 是单目运算,选择向量的遮蔽由上层统一处理;And/Or(第 72、83 行)则接收并应用选择向量。
6.3 过滤运算:filter.test
FILTER与其它函数不同,它的作用不是生成布尔结果,而是根据布尔掩码真正压缩出子数组——只保留掩码为 true 的行:
FILTER bool:[true false true false] select:[true false true false] -> bool:[true true] FILTER int32:[1 2 3 4 5] select:[true false true false true] -> int32:[1 3 5] FILTER int32:[10 20 30] select:[false false false] -> int32:[] FILTER utf8:[null "hello" "world"] select:[true true false] -> utf8:[null "hello"] FILTER null:[null null null] select:[true false true] -> null:[null null]几个要点:
- 全不选中时结果为空数组
[](而不是全_数组); - 输入中的 null 值在选中时会原样保留(
int32:[10 null 30] -> int32:[10 null]); - 这就是
compute_test.go中对 FILTER 特殊处理(mask = memory.Bitmap{})的原因——掩码已经被物化消费掉了。
从 filter.go 的实现看,Filter接收input columnar.Datum和mask memory.Bitmap,返回按掩码挑选后的新 Datum。
6.4 集合成员判断:set.test
ISMEMBER判断第一个数组中的每个值是否属于第二个数组(作为集合):
ISMEMBER utf8:["test1" "test2" "test3"] utf8:["test1" "test2" "test4"] -> bool:[true true false] ISMEMBER int32:[1 2 3] int32:[4 5 6] -> bool:[false false false] ISMEMBER int32:[] int32:[1 2 3] -> bool:[] ISMEMBER utf8:[null] utf8:["test1" "test2" "test3"] -> bool:[null]带选择向量的用例:
ISMEMBER utf8:["apple" null "cherry" null] utf8:["apple" "cherry"] select:[true true true true] -> bool:[true null true null] ISMEMBER utf8:["apple" null "cherry" null] utf8:["apple" "cherry"] select:[true false true false] -> bool:[true _ true _]在evalCaseFunction中,ISMEMBER的第二个参数(数组)会被转换为columnar.Set(NewUTF8Set或NewNumberSet),且集合不允许包含 null 值(require.Equal(t, 0, arr.Nulls(), ...));转换后调用 IsMember。注意集合语义是"去重后的成员判断",因此第二个数组即使有重复值也只影响集合构建,不影响结果。
6.5 字符串与正则:utf8.test
utf8.test覆盖三种字符串匹配函数:
- SUBSTR:大小写敏感的子串匹配。
utf8:"test"匹配"test"但不匹配"TEST"; - SUBSTRI:大小写不敏感的子串匹配,
"test"也能匹配"TEST"; - REGEXP:正则表达式匹配,第二个参数必须是标量(正则模式),且由测试框架编译为
*regexp.Regexp后传入 RegexpMatch。
基础用例:
SUBSTR utf8:["test"] utf8:"test" -> bool:[true] SUBSTR utf8:["test"] utf8:"TEST" -> bool:[false] SUBSTRI utf8:["test"] utf8:"TEST" -> bool:[true] REGEXP utf8:["test"] utf8:"test" -> bool:[true] REGEXP utf8:["test"] utf8:"NOTtest" -> bool:[false]空字符串与 null 的边界行为:
SUBSTR utf8:["test"] utf8:"" -> bool:[true] // 空串是任意字符串的子串 SUBSTR utf8:[null] utf8:"test" -> bool:[null] REGEXP utf8:["test"] utf8:null -> bool:[null]带选择向量的正则用例(utf8.test中给出了 full / partial / middle / single / none 五种遮蔽形态的完整矩阵):
REGEXP utf8:["foo" "bar" "baz" "qux" "test"] utf8:"ba." select:[true true true true true] -> bool:[false true true false false] # Full selection REGEXP utf8:["foo" "bar" "baz" "qux" "test"] utf8:"ba." select:[false true true true false] -> bool:[_ true true false _] # Partial (middle)REGEXP 的特殊处理逻辑位于 compute_test.go:第二参数必须断言为*columnar.UTF8Scalar(否则测试失败),非 null 时用regexp.Compile编译,编译失败即测试失败;若模式为 null,则传入 nil 正则(此时结果全为 null)。
七、如何新增一个 compute 函数并接入 DSL 测试
README 对扩展流程给出了明确指引,结合源码可整理为如下步骤:
实现 compute 函数:在
pkg/compute下新增函数,签名遵循统一约定func(alloc *memory.Allocator, args ..., selection memory.Bitmap) (columnar.Datum, error),返回数据使用传入的 allocator 分配(见 doc.go 的包级约定)。更新
evalCaseFunction:在 pkg/compute/compute_test.go 的switch tc.Function中新增分支,把 DSL 函数名映射到新函数,并用require.Len校验参数个数。处理特殊参数:如果新函数需要非常规参数(例如 REGEXP 需要把
columnar.Datum转成编译后的正则、ISMEMBER 需要把数组转成Set),需要像 compute_test.go 那样做类型断言与转换。编写 DSL 用例:在
testdata下新增或扩展.test文件,按第五节语法编写用例,覆盖标量/数组的四种组合以及选择向量场景。运行测试:
go test -run TestCompute ./pkg/compute/即可验证全部 DSL 用例。
八、DSL 的边界:错误测试与高级测试
README 明确划定了 DSL 的能力边界,这两类场景应放在 Go 测试文件中:
错误测试(Error testing):DSL无法表达"计算函数应当失败"的用例(例如类型不匹配、不可排序类型、非法参数)。这类用例应写在 Go 测试文件中直接断言错误返回值,例如
compute.Equals对两侧 kind 不一致会返回fmt.Errorf("both inputs must be the same kind, got %s and %s", ...)(equality.go)。高级测试(Advanced tests):作用于 record batch 或 struct 的更复杂计算函数(例如 equality_struct.go、filter_struct_test.go、equality_bench_test.go 所覆盖的场景),它们需要更复杂的构造和前置条件,更适合直接用 Go 测试代码搭建。
这一分工让 DSL 保持"一行一用例"的高密度表达力,同时把异常路径和复杂结构测试留在表达能力更强的 Go 层。
九、小结
pkg/compute/testdata下的 DSL 是 Loki 列式计算引擎测试体系中的一块"数据驱动拼图":
- 语法上,它用
函数名 + 类型化 Datum 参数 + 可选选择向量 -> 期望结果的紧凑文法,一行描述一个完整用例,注释与空白规则让它天然易读; - 语义上,它忠实反映了 compute 包的运行时约定——三值逻辑(null 传播)、类型一致性校验、四种输入组合的调度模式,以及选择向量的懒求值与未定义槽位标记;
- 工程上,
TestCompute+computetest解析器(scanner/parser/token)共同构成一个低摩擦的扩展通道:新增计算函数只需实现函数、在evalCaseFunction注册映射、在.test文件补充用例即可。
理解这套 DSL,等于同时理解了 Loki 列式数据模型(columnar.Datum、memory.Bitmap)与计算层的测试方法论——这对于阅读 pkg/dataobj 等使用 compute 包的上游代码,以及为 Loki 贡献新的列式计算能力,都是必要的一课。
【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考