Skip to content

【Zig 日报】Marionette 一个专为 Zig 语言开发的确定性模拟测试(DST)库 #334

@jiacai2050

Description

@jiacai2050

Marionette 用于 Zig 的确定性 I/O 和仿真测试。

从长远来看,Marionette 的目标是成为 Zig 的确定性 std.Io 标准:生产环境库采用 std.Io,而在测试中则替换为 Marionette 的确定性实现。如今,Marionette 提供了仿真器、追踪(trace)、故障注入、磁盘和网络原语,使这一方向成为现实。Marionette 已经在确定性仿真环境下运行了真实的、未经修改的 Zig 代码:一个存储引擎 (xit-vcs/xitdb) 和一个协作式并发库 (g41797/mailbox),两者均支持基于种子(seed)的可复现回放,并在过程中发现了可复现的恢复问题及正确性反例。

针对 std.Io 以及 Marionette 实际需要的少量句柄(如 mar.Recordermar.Endpoint(Message))编写生产环境代码。在测试中,驱动控制逻辑以注入故障。对于所模拟的文件和本地端点接口,同一套应用程序逻辑既可以在仿真器上运行,也可以在生产适配器上运行。

fn writeAndRecover(io: std.Io, root: std.Io.Dir, recorder: mar.Recorder) !KVStore {
    var store = try KVStore.init(io, root, recorder);
    try store.put(1, 41, .sync);
    try store.put(2, 99, .no_sync);
    try store.recover(.strict);
    return store;
}

// 在仿真中:确定性、可注入故障、可基于种子回放。
const sim = try world.simulate(.{ .disk = .{ .sector_size = 16 } });
var sim_store = try writeAndRecover(sim.env.io(), std.Io.Dir.cwd(), sim.env.recorder());

// 在生产中:真实磁盘,相同的代码路径。
var production = try mar.Production.init(.{ .root_dir = tmp.dir, .io = std.testing.io });
const prod_env = production.env();
var prod_store = try writeAndRecover(prod_env.io(), tmp.dir, prod_env.recorder());

对于此类基于文件的代码,这种对等性正是关键所在。你无需编写“仿真版”代码。你只需在 Marionette 拥有的权威接口后编写业务逻辑,Marionette 便会为你提供一个运行它的确定性环境。

为什么选择它?

分布式和存储系统往往以难以复现的方式失败:崩溃时的碎片化写入、法定人数(quorum)期间的网络分区、两个计时器之间的竞态。当你拿到堆栈跟踪(stack trace)时,导致错误的条件早已消失。

确定性仿真测试将这些 bug 变成了种子(seed)。每次运行都是可复现的。每次失败都是可回放的。你可以将数周的模糊测试压缩至几秒钟,而当 CI 中出现故障时,仅凭种子就足以进行调试。

Marionette 将这一方法引入了 Zig。它的灵感源于 FoundationDB、TigerBeetle 和 Antithesis 背后的技术,但被设计为一个即插即用的库,而不是一个你需要围绕它构建系统的框架。

完整示例

这是一个 WAL 恢复测试,它在写入过程中使磁盘崩溃,损坏扇区,并断言已提交的记录能够存活,而未同步的记录则不会。

pub fn scenario(harness: *Harness) !void {
    try harness.store.put(committed_key, committed_value, .sync);
    try harness.control.disk.setFaults(.{ .crash_lost_write_rate = .always() });
    try harness.store.put(volatile_key, volatile_value, .no_sync);
    try harness.control.disk.crash();
    try harness.control.disk.restart();
    try harness.control.disk.corruptSector(wal_path, record_size);
    try harness.store.recover(.strict);
}

pub const checks = [_]mar.StateCheck(Harness){
    .{ .name = "同步记录恢复,非同步记录被拒绝", .check = recoveredStateIsSafe },
};

test "wal recovery" {
    try mar.expectPass(.{
        .allocator = std.testing.allocator,
        .seed = 0xC0FFEE,
        .init = Harness.init,
        .scenario = scenario,
        .checks = &checks,
    });
}

每个测试包含三个部分:

  1. init:设置你的测试工具(harness),包括待测代码和用于故障注入的控制句柄。
  2. scenario:驱动动作。通过 env 创建的句柄调用代码,并通过 control 调用仿真器。
  3. checks:断言最终状态的不变性。

两个层面:io 和 control

每个 Marionette 测试都有两个部分:

  • io:生产环境存储代码通常应看到的接口。在仿真中,sim.env.io() 返回 Marionette 的确定性 std.Io 后端。在生产中,production.env().io() 返回设置时提供的宿主 std.Io
  • control:测试代码用于注入故障的接口。它仅在仿真中可用。它镜像了 env 的结构:每个资源都有一个控制表面。

分布式仿真

网络仿真以同样的方式工作。以下是一个针对小型复制寄存器的分区测试:

try harness.control.network.partition(&isolated, &majority);
try harness.replicas.write(.{ .version = 1, .value = 41, .retry_limit = 2 });
try harness.control.network.heal();

协作式并发

Marionette 还拥有一个实验性的、基于调度器的 std.Io futex 路径,用于协作式 Mutex / Condition 代码。它支持计时接收、发送/唤醒、相同截止时间的超时排序以及字节级相同的种子回放。

追踪 (Traces)

每次运行都会产生结构化追踪。当检查失败时,你将获得导致违规的完整事件序列,以及用于复现它的种子。

状态

Marionette 尚处于早期阶段。这是一个 0.x 版本:在 1.0 之前没有 API 稳定性保证。

安装

zig fetch --save https://github.com/sb2bg/marionette/archive/<commit>.tar.gz
需要 Zig 0.16.x。

许可协议

MIT

加入我们

Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:

  1. 供稿,分享自己使用 Zig 的心得
  2. 改进 ZigCC 组织下的开源项目
  3. 加入微信群Telegram 群组

Metadata

Metadata

Assignees

No one assigned

    Labels

    日报daily report

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions