SpacetimeDB 旧版本数据兼容性测试:testdata 夹具目录的用途、结构与实现原理
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
导读
SpacetimeDB 是一个以"开发速度如光速"为目标的关系型实时数据库,其数据以 commitlog(提交日志)与快照(snapshot)两种形态持久化到磁盘。随着版本迭代,磁盘上的持久化格式、系统表结构都可能发生变化,因此数据库必须具备"读取旧版本写入的数据"的能力。本文以仓库中crates/engine/testdata/目录的说明文档为线索,深入解析 SpacetimeDB 如何通过"固化旧版本真实数据"的测试夹具,构建起向后兼容性回归测试体系——读完你将掌握该夹具目录的完整结构、对应单元测试的运行逻辑,以及 commitlog 格式版本、BSATN 快照存储等底层实现细节。
一、testdata 目录存在的意义:让单元测试检查版本兼容性
仓库根目录下 crates/engine/testdata/README.md 对该目录的定位只有一句话:
This has some data written by older versions of spacetimedb, so our unit tests can check compatibility.
(这里存放着由旧版本 spacetimedb 写入的一些数据,以便我们的单元测试能够检查兼容性。)
这句话概括了测试夹具(test fixture)的经典用途:把某个历史版本运行后真实产生的磁盘数据"固化"进代码仓库,当引擎后续迭代时,单元测试直接用这份"陈年数据"打开数据库,验证:
- 旧数据可以被当前代码正确读取(前向读取兼容);
- 旧数据可以被升级/迁移到新结构(如新增系统表后仍可读写);
- 回归防护——任何破坏旧格式读取能力的代码变更,都会在
cargo test阶段立即暴露,而不是等到用户升级时才发现。
换句话说,testdata 是 SpacetimeDB 向后兼容承诺的"可执行证据"。
二、夹具目录结构逐层解析
crates/engine/testdata/下的实际数据布局如下:
crates/engine/testdata/ └── v1.2/ └── replicas/ └── 22000001/ # 数据库 replica_id = 22000001 ├── clog/ │ └── 00000000000000000000.stdb.ofs # commitlog 日志段(offsets 文件) ├── module_logs/ # 模块日志目录(当前为空) ├── db.lock # 数据库目录锁 └── snapshots/ └── 00000000000000000000.snapshot_dir/ # 偏移 0 处的快照目录 ├── 00000000000000000000.snapshot_bsatn # BSATN 编码的快照主体 └── objects/ ├── 19/30ce81246a4cdc25e9024ae0065d053adb2efbe1b5b7af457331d330e481e8 ├── 41/bb11b6d2cdc488192ee70d8175307d6f205756ed163f4237c6cba2936798dc ├── 45/4d2e2c62ff5d46c5b3e6de72d6277eb285fc2d6b0a5ac6f92498e08a9e5ecc ├── 62/22df0e5ca93d3fb22762e12161246a1d5917c61ada5d81b8dcce12fd5780b3 ├── 79/4dced5633eca2ffee784d471f5203209169321083ef99de254ad24af0f6d5a ├── 95/74dd6d2857fa771a1cd16be31fdef38f83c2fd3bcc05f4934e53bdbfa21f10 └── 9a/b95f5aaed7541289faa8bc4de886ce0281f11037c3424494e58fee92411241这份布局与 crates/paths/src/lib.rs 中定义的服务器数据目录规范完全一致:每个数据库副本以replicas/<replica_id>组织,内部包含clog(CommitLog 文件)、module_logs(模块日志)、snapshots(数据库快照)。对应地,crates/paths/src/server.rs 的ServerDataDir::replica(replica_id)与 ReplicaDir 的路径访问器 分别提供了snapshots()与commit_log()等类型安全路径构造方法。
各组成部分的含义:
| 路径 | 含义 |
|---|---|
clog/00000000000000000000.stdb.ofs | commitlog 的日志段文件(.ofs后缀),记录从事务偏移 0 开始的所有已提交事务 |
snapshots/00000000000000000000.snapshot_dir/ | 在事务偏移 0 处生成的一个快照目录,snapshot_dir(tx_offset)的命名规则为{tx_offset:0>20}.snapshot_dir(见 server.rs) |
00000000000000000000.snapshot_bsatn | 快照主体,采用 SpacetimeDB 的 BSATN 二进制序列化格式编码 |
objects/ | 对象存储目录树(按哈希前两字节分片),存放表中实际数据的不可变对象文件 |
db.lock | 数据库目录互斥锁,防止同一副本被并发打开 |
module_logs/ | 模块(业务逻辑)运行日志目录,当前夹具中为空 |
objects/采用内容寻址(content-addressed)存储:每个数据对象以内容哈希命名(如19/30ce8124...),前缀两字符作为分片目录,形成两级目录树。这种设计让快照天然支持去重与硬链接——下文会看到压缩逻辑如何利用这一点。
三、四个兼容性单元测试:验证了什么
测试数据不是摆设,crates/engine/src/relational_db.rs 的测试模块中有 4 个用例直接消费这份 v1.2 夹具,核心逻辑收敛在两个函数中。
3.1 load_1_2_data:读旧数据 + 建新表
load_1_2_data(use_snapshot: bool)的流程(源码):
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/v1.2/replicas"); let tempdir = copy_fixture_dir(&data_dir); // 先复制到临时目录,绝不直接改动仓库内的夹具 let dir = ReplicaDir::from_path_unchecked(tempdir.path().join("replicas/22000001")); // ... 使用固定 identity 打开已存在的持久化数据库 ... let (db, _durability_handle) = TestDB::open_existing_durable(&dir, ...)?; // 1) 断言旧数据表存在 let schemas = db.get_all_tables_mut(&tx)?; let expected_table_names = vec!["message", "user"]; // v1.2 时代的数据表 assert_eq!(user_table_names, expected_table_names); // 2) 在旧数据之上新建一张表并提交 let table_id = db.create_table(&mut tx, my_table(AlgebraicType::I32))?; db.commit_tx(tx)?; // 3) 断言新表读写正常 assert_eq!(table_id, db.table_id_from_name_mut(&tx, "MyTable")?.unwrap());关键点:
env!("CARGO_MANIFEST_DIR")在编译期定位 crate 根目录,从而找到 testdata 夹具;copy_fixture_dir先把夹具整体复制进临时目录(定义在 relational_db.rs#L4023-L4037),保证测试对夹具文件本身零污染;- 断言夹具中确实含有
message、user两张 v1.2 时代的数据表,先确认"没有打开空目录",再验证旧数据可读、新表可建。
3.2 load_1_2_data_and_migrate:系统表迁移的完整闭环
load_1_2_data_and_migrate(use_snapshot)(源码)更进一步,模拟了一次真实的升级迁移场景:
This tests adding a new system table st_connection_credentials, which was not in 1.2.
- 打开 v1.2 旧数据,再次断言
message、user存在; - 通过
insert_st_client(Identity::ZERO, ConnectionId::ZERO, "invalid_jwt")向1.2 版本中不存在的系统表st_connection_credentials写入数据——触发当前代码自动创建缺失系统表并完成迁移; rt.block_on(db.shutdown())关闭并drop(db)后重新打开数据库;- 用
get_jwt_payload(ConnectionId::ZERO)读回刚才写入的 JWT 字符串,断言jwt == "invalid_jwt"。
这个用例验证了迁移的持久性:新系统表不仅能在旧数据上被创建,而且写入的数据在关闭重开后依然能正确读取。这正是用户从旧版本升级后不会丢身份凭据的关键保证。
3.3 测试矩阵
4 个测试分别以"有无快照"两种路径执行(测试声明):
| 测试函数 | 覆盖场景 | 快照路径 |
|---|---|---|
load_1_2_quickstart_from_snapshot_test | 读旧数据 + 建新表 | 从快照恢复 |
load_1_2_quickstart_without_snapshot_test | 读旧数据 + 建新表 | 纯 commitlog 重放 |
load_1_2_data_and_migrate_with_snapshot | 系统表迁移 + 重开读回 | 从快照恢复 |
load_1_2_data_and_migrate_without_snapshot | 系统表迁移 + 重开读回 | 纯 commitlog 重放 |
use_snapshot布尔值通过TestDB::open_existing_durable(..., want_snapshot_repo)控制(signature),因此同一个夹具覆盖了两条完全不同的恢复链路:快照 + 增量日志 vs 全量日志重放。这是"一份数据,两条路径"的高性价比测试设计。
四、底层原理:快照与 commitlog 的格式版本
4.1 快照:BSATN 序列化 + 内容寻址对象存储
快照的抓取由 crates/engine/src/snapshot.rs 中的SnapshotWorker后台任务负责。它是一个可克隆的句柄,通过mpsc无界通道接收Request::TakeSnapshot/Request::ReplaceState消息(Request 枚举),在 tokio runtime 上用spawn_blocking调用Locking::take_snapshot_internal完成内存状态到磁盘的落盘(take_snapshot),最后fsync并发布快照对应的事务偏移(TxOffset)。
快照主体00000000000000000000.snapshot_bsatn使用 SpacetimeDB 自研的BSATN(Binary Spacetime Algebraic Type Notation)格式编码,表中数据对象则存进objects/内容寻址目录。SnapshotWorker还支持Compression::Enabled压缩模式:对早于最新快照的历史快照执行压缩,统计跳过数、压缩对象数、硬链接对象数(CompressionMetrics)——"硬链接"正是利用内容寻址哈希去重,让多个快照共享同一份对象文件,从而节省磁盘。
4.2 commitlog:带版本号的日志格式
与快照平行的持久化通道是 commitlog。日志段文件(.stdb.ofs)在解码事务时会读取log_format_version字段,crates/commitlog/src/commit.rs 中的逻辑表明:格式版本0对应Version::V0,其余版本走Version::V1解码路径。
该版本号可通过服务器配置覆盖,crates/engine/src/persistence.rs 的CommitlogConfig暴露了log_format_version、max_segment_size、offset-index-interval-bytes、preallocate-segments、write-buffer-size等 kebab-case 配置项。这解释了兼容性测试的意义:同一份 commitlog 可能由不同格式版本的引擎写入,解码端必须按段内自带的版本信息选择正确的解析逻辑。
4.3 持久化装配:LocalPersistenceProvider
生产环境中,crates/engine/src/persistence.rs 的LocalPersistenceProvider会把上面两条链路装配起来:为每个副本打开快照仓库并创建启用了压缩的SnapshotWorker,构建本地Durability(commitlog),再后台运行"快照驱动的 commitlog 压缩器"——每当新快照产生,就压缩对应的旧日志段。测试夹具中同时出现的.snapshot_bsatn与.stdb.ofs文件,正是这套装配的磁盘产物。
五、实战指引:如何运行与扩展这些测试
5.1 运行兼容性测试
在仓库根目录执行(需要 Rust 工具链,工具链版本见根目录 rust-toolchain.toml):
# 只运行 v1.2 兼容性相关测试 cargo test -p spacetimedb-engine load_1_2 # 运行 engine crate 全部测试 cargo test -p spacetimedb-engine由于夹具会先复制到临时目录,测试可重复执行且不会污染crates/engine/testdata/中的原始数据。
5.2 为新版本添加夹具(基于现有模式的推断)
从当前仓库的约定可以推断,维护者为新版本(如 v1.3)添加兼容性夹具时,遵循的流程会是:
- 用对应旧版本引擎实际创建并写入一个数据库(含
message、user等代表性业务表); - 正常关闭(确保 commitlog 与快照落盘),将整个
replicas/<id>目录固化为testdata/vX.Y/replicas/; - 在 relational_db.rs 测试模块中仿照
load_1_2_data新增读取与迁移断言。
这种"真实数据固化 + 双重打开验证"的模式,保证了每一条持久化格式变更都有对应的历史回归用例。
六、结语
crates/engine/testdata/虽然只有一行说明,却承载着 SpacetimeDB 最核心的工程承诺——向前兼容。通过固化 v1.2 时代的真实磁盘数据(commitlog 日志段、BSATN 快照、内容寻址对象存储),配合四个覆盖"快照/日志重放 × 读取/迁移"矩阵的单元测试,任何破坏旧格式读取或系统表迁移的改动都无法通过 CI。对于数据库类项目,这套"以历史真实数据为测试基准"的方法论本身,就值得借鉴。
延伸阅读
- 测试实现:crates/engine/src/relational_db.rs#L4039-L4174
- 数据目录布局规范:crates/paths/src/lib.rs#L129-L154
- 快照后台任务:crates/engine/src/snapshot.rs
- 持久化配置(含 commitlog 格式版本):crates/engine/src/persistence.rs
- commitlog 解码与格式版本:crates/commitlog/src/commit.rs#L302-L314
- 夹具数据本体:crates/engine/testdata/v1.2/replicas/22000001
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考