Beads 存储层 Schema 一致性守卫与连接池迁移修复实战:从 be-krza3 发布门禁看 CLI/Runtime 模式对齐工程
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
Beads 为编码 Agent 提供嵌入式 Dolt 数据库存储,其 schema 通过 SQL 迁移流演进,而 CLI 侧则内置同一套 schema 的 DDL 模板。为了确保"CLI 打包的 schema 与运行时实际提交的 schema 永远一致",项目引入了一个名为schema_cli_parity_integration_test.go的模式一致性 oracle(parity oracle)测试。本文以发布门禁 be-krza3-schema-parity-pool-heal-gate.md 为线索,深入剖析该 oracle 的工作原理、它曾暴露的两类假性不一致缺陷(ignored-stream 列误报、Go map 迭代顺序导致的随机空读),以及一个被逐字节 cherry-pick 进来的连接池迁移修复(be-itm5 的rebuildPoolAfterMigration)。读完本文,你将理解 Beads 如何用"双快照对比"守护数据库 schema 演进,如何消除测试中的非确定性,以及一个严谨的发布门禁(release gate)如何通过 8 项标准判定一个修复是否具备合入资格。
一、背景:Beads 的 Dolt 存储层与 schema 治理
Beads 的核心存储位于 internal/storage/dolt,其底层是嵌入式 Dolt(一个类 MySQL 的版本化数据库)。所有持久化对象——issue、wisp、lease、metadata、repo 元数据等——都落在 Dolt 数据库中,schema 的演进完全由迁移(migration)驱动。
schema 治理有两个关键约束:
- 运行时 schema:由
store.initSchema在打开数据库时执行迁移得到,迁移 SQL 由schema.AllMigrationsSQL()统一提供(见 internal/storage/schema)。 - CLI 侧 schema:CLI 内部预置了同一套 DDL 模板(如 internal/storage/schema/cli_prepared_ddl.go),供各种命令准备语句使用。
两套 schema 来源不同,却必须保持完全一致——如果 CLI 预置的 DDL 与运行时迁移出的真实 schema 出现漂移,会导致命令执行 SQL 报错、结果集字段不匹配等隐蔽故障。为此,项目用集成测试TestCLIBundleMatchesRuntimeCommittedSchema(位于 internal/storage/dolt/schema_cli_parity_integration_test.go)充当"oracle":分别从 CLI 侧和运行时侧采集 schema 快照,逐行对比。
二、Parity Oracle 的工作原理:双快照对比
oracle 的核心思想非常直观:对同一个 schema 分别从两个源头生成规范化文本快照,然后逐行比较。
2.1 快照查询集:committedSchemaSnapshotQueries
committedSchemaSnapshotQueries 定义了一组针对information_schema的查询,覆盖 5 个维度:
| 维度 | 查询内容 | 关键输出字段 |
|---|---|---|
tables | 表清单 | 表名 + 表类型 |
columns | 列定义 | 表名、序号、列名、类型、可空性、默认值、extra、生成表达式 |
indexes | 索引定义 | 索引名、列序号、非唯一标志、子分区、可空、索引类型 |
constraints | 约束定义 | 约束名、类型、键列序号、引用表/列、更新/删除规则 |
version | 迁移版本 | schema_migrations中的最大迁移号 |
每行输出都被拼成一条规范化文本,例如列维度:
SELECT CONCAT('column|', c.table_name, '|', LPAD(c.ordinal_position, 3, '0'), '|', c.column_name, '|', c.column_type, '|', c.is_nullable, '|', COALESCE(c.column_default, '<NULL>'), '|', c.extra, '|', COALESCE(c.generation_expression, '')) AS line FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema = c.table_schema AND t.table_name = c.table_name WHERE ...LPAD(..., 3, '0')与COALESCE(..., '<NULL>')都是为了让同一 schema 在两边产生逐字节一致的文本,从而可直接用sort.Strings排序后用 diff 比较。
2.2 两条采集路径
测试(TestCLIBundleMatchesRuntimeCommittedSchema)在临时目录中构造两个并行的 Dolt 环境:
- CLI 路径:
dolt init+ 执行schema.AllMigrationsSQL(),然后用 dolt CLI 跑同样的快照查询,得到cliCommittedSchemaSnapshot; - 运行时路径:通过
New()打开一个真实 DoltStore(CreateIfMissing: true,触发initSchema执行迁移),直接对store.db跑快照查询,得到runtimeCommittedSchemaSnapshot。
两者都调用sortedSnapshotQueryNames按固定顺序执行 5 类查询,最后sort.Strings后由firstSchemaSnapshotDiff找出第一处差异。若存在差异则t.Fatalf,测试失败——这就是"oracle 报警"。
三、缺陷剖析:oracle 为何产生假性不一致
be-krza3 门禁文档的 Scope 部分明确指出,这个 oracle 存在两个缺陷,会误报(false parity mismatch),而不是漏报:
3.1 缺陷一:ignored-stream 列无法表达
Beads 存在一个"被忽略的迁移流"(ignored migration series)。该流拥有的对象分为两个层次:
- 整张表:
wisps以及wisp_前缀的表,还有ignored_schema_migrations、local_metadata、repo_mtimes等元数据表; - 挂在主线表上的单列:例如
leases.granted_node(由migrations/ignored/0016_add_lease_granted_node.up.sql添加)。
而schema.AllMigrationsSQL()只遍历主线迁移序列,没有对应的 ignored 系列 bundle 可与 CLI 侧配对。如果 oracle 不过滤这些对象,它们会在运行时侧出现、而 CLI 侧缺失,被误读为"only in runtime"的假性漂移。oracle 的过滤谓词如下(以tables为例):
WHERE t.table_schema = DATABASE() AND t.table_name NOT IN ('ignored_schema_migrations', 'local_metadata', 'repo_mtimes', 'wisps') AND LEFT(t.table_name, 5) <> 'wisp_' AND LEFT(t.table_name, 5) <> 'dolt_'columns维度额外增加了一行针对单列的排除:
AND NOT (c.table_name = 'leases' AND c.column_name = 'granted_node')值得警惕的是,这种排除是有代价的:文档记录到,迁移 0065 的wisp_comments.text宽度漂移曾持续 6 天而 oracle 无法发现(ga-61ruw),因为该列属于 ignored 平面。因此文档强调:正确方向是给 CLI 侧补一个 ignored 系列 bundle,而不是删除这些谓词;在实现之前,cli_prepared_ddl.go中的源码级守卫(masking-proof guard)负责覆盖这类漏洞。
3.2 缺陷二:Go map 迭代顺序导致的随机空读
committedSchemaSnapshotQueries返回的是map[string]string。修复前,两个快照采集器直接for name := range queries迭代 map,而Go 的 map 迭代顺序是随机的。
文档与代码注释(sortedSnapshotQueryNames)解释了这个问题的微妙之处:运行时侧的快照查询经由一个 Dolt 会话读取,而该会话的 root 只在查询成功之后才前进(be-itm5 行为)。直接 range map 会让每次调用中"谁先执行"随机变化——先执行的那个类别可能读取到尚未就绪的状态,被记录为空。于是同一个类别的查询在每次运行时可能随机读到空结果,产生间歇性、不可复现的假性不一致。
3.3 修复方案:静态排除 + 确定性排序
修复包含两处,均为测试代码改动:
- 静态排除子句:在 5 个快照查询中加入上述
NOT IN/LEFT(...)/NOT (...)过滤,明确表达"oracle 只对比主线迁移流"; sortedSnapshotQueryNames辅助函数:把 map 的 key 先收集进 slice 再sort.Strings,保证两个采集器以固定顺序执行查询:
func sortedSnapshotQueryNames(queries map[string]string) []string { names := make([]string, 0, len(queries)) for name := range queries { names = append(names, name) } sort.Strings(names) return names }两个采集器(cliCommittedSchemaSnapshot与runtimeCommittedSchemaSnapshot)都改为遍历sortedSnapshotQueryNames(queries),彻底消除了对 map 迭代顺序的依赖。
四、关联修复:be-itm5 的连接池迁移修复(pool heal)
be-krza3 门禁的 Round-2 重做还包含了一个逐字节 cherry-pick的生产代码修复:be-itm5 的rebuildPoolAfterMigration(位于 internal/storage/dolt/store.go)。
4.1 问题本质:会话 root 未随迁移前进
bug 场景(be-itm5 / be-jjv2 复现)是:一个执行了迁移的 store 打开流程,让连接池store.db中的连接停留在了迁移前的 Dolt 会话 root上。第一次语句通过该过期连接读取时返回table not found;而失败的查询不会推进会话 root,所以错误不会在重试时自愈——只有某个无关的成功查询(比如information_schema探测)才会推进 root。这让故障表现为"打开后第一读必失败,且随机自愈",极难排查。
4.2 修复实现:迁移后重建连接池
func (s *DoltStore) rebuildPoolAfterMigration(ctx context.Context, applied int) error { if applied == 0 { return nil } newDB, err := sql.Open("mysql", s.connStr) if err != nil { return fmt.Errorf("rebuild pool after migration: %w", err) } applyPoolLimits(newDB, s.cfg) if err := newDB.PingContext(ctx); err != nil { _ = newDB.Close() return fmt.Errorf("rebuild pool after migration: %w", err) } old := s.db s.db = newDB return old.Close() }关键设计点:
applied == 0快速返回:非迁移打开(重新打开一个已迁移完成的库,是最常见路径)不支付任何重建代价,也不触碰s.db——这是 be-itm5 的 "Done-when" 守卫;- 迁移走独立连接池:
initSchema通过openMigrationDB这个一次性连接执行迁移(store.go),而 store 主池在打开早期已被启动 Ping 钉住一个连接; - 无锁的池交换是安全的:文档在 OWASP 审查一节专门论证了这一点——
rebuildPoolAfterMigration只有单一调用点(构造函数内部、发布前执行),不存在并发读者,因此s.db = newDB无需加锁。
4.3 为什么必须带上 store.go
门禁文档解释了一个重要的工程判断:Round-1 时有人主张 store.go 不属于本 diff 的授权范围(scope),但若把 store.go 还原到 be-itm5 之前的状态,本轮的测试文件根本无法编译——post_migration_pool_heal_integration_test.go与connection_pool_test.go直接调用rebuildPoolAfterMigration。于是原始验收标准中的"#3 非确定性消除"与"#4 不触碰生产文件"机械矛盾。Round-1 审查者裁定包含 store.go 是正确解法,Round-2 审查者则独立复核了这条授权链(authority chain),并用 RED/GREEN 复证"不含 store.go 即编译失败",而非照单全收。
五、验证体系:从纯 Go 单元测试到真实容器集成测试
5.1 连接池生命周期单元测试(无需 Dolt 服务器)
connection_pool_test.go 用进程内 mock driver(mockDriver统计 Open/Close 次数)钉住连接池不变量,可在go test -short下运行。它覆盖 5 个维度,恰好对应门禁中的 9 个 diff-owned 测试:
| 测试 | 断言的不变量 |
|---|---|
TestApplyPoolLimits_Defaults | 默认池参数:MaxOpenConns=10、MaxIdleConns=5、ConnMaxLifetime=1h |
TestApplyPoolLimits_Overrides | Config 非零字段覆盖默认值(如 3/2/15min) |
TestApplyPoolLimits_ClampsIdleToOpen | MaxIdleConns不得超过MaxOpenConns,否则database/sql会静默钳制 |
TestPool_SequentialQueriesReuseSingleConnection | 两次顺序查询只 Open 1 次、Close 0 次——池必须复用连接 |
TestPool_ConcurrentQueriesRespectMaxOpen | 8 个并发查询、池上限 2 时,底层连接数不超过 2 |
TestPool_CloseReleasesUnderlyingConnections | Close()后所有已打开连接全部释放(opens == closes) |
TestRebuildPoolAfterMigration_NoopWhenNotMigrated | applied=0时不得打开新连接、不得触碰s.db |
这些测试的来源是一个真实的线上故障报告:dolt-server.log 中出现无穷无尽的NewConnection/ConnectionClosed配对,说明守护进程实际上每条查询都在新建*sql.DB。单元测试把"连接复用"固化为可回归的不变量。
5.2 迁移后首读集成测试(需要真实 Dolt)
post_migration_pool_heal_integration_test.go 中的TestMigratingOpen_FirstReadSucceeds是 be-itm5 的复现测试,设计极其克制:在New()返回后只发出唯一一条查询(SELECT key FROM config),因为第二条无关查询会通过"推进会话 root"直接掩盖 bug。测试断言首次读返回迁移后的 config 种子数据(非 0 行)。
5.3 非确定性消除的复证
门禁记录到,Round-2 审查者独立运行TestCLIBundleMatchesRuntimeCommittedSchema -count=5共 5 轮,5/5 全 PASS,证明 map 迭代顺序的随机性已被彻底消除。
六、发布门禁(Release Gate)评估标准全解
be-krza3 门禁文档的核心是一张 8 行(#0–#7)的评估表,这是 Beads 项目"修复必须过门禁才能合入"的工程制度。以下完整继承并解释每一项:
| # | 标准 | 判定 | 证据要点 |
|---|---|---|---|
| 0 | 预检:是否已合入? | NO | gh pr list --search无结果;git merge-base --is-ancestor确认目标 commit 不在 main 上,继续评估 |
| 1 | 是否存在 Review PASS? | PASS | 审查 bead be-8xjjg,第 2 轮 verdictpass,以 reasonpass关闭 |
| 2 | 验收标准是否达成? | PASS | scope 授权链按文档化路径裁决而非"人说了算";uncovered_criteria: none;-count=5复证非确定性已消除 |
| 3 | diff 自有测试是否全过(SKIP=FAIL 规则)? | PASS | 9/9 PASS、0 FAIL、0 SKIP,19.002s,真实 Dolt 容器(rootless podman)执行,非 SKIP 替代品;rebase 后 deployer 独立复核gofmt -l/go build ./.../go vet ./...全干净 |
| 3a | 非 diff 自有的既有失败归因 | PASS | 全包运行(193 测试)有 14 个 FAIL,全部位于federation_test.go且非 diff 自有;13 个是包级并行槽竞争下的既有超时(约 45s 处),第 14 个(TestFederationDatabaseIsolation)是已跟踪的 P0 be-3c78s;base-ref 对照运行逐条复现(13 PASS + 1 FAIL),4 条归因子句全部满足 |
| 3b | 策略 / lint 通道 | PASS | golangci-lint run"0 issues"——这是该 diff 上第 3 次独立的 gofmt/vet/lint 干净验证 |
| 4 | 无未决 HIGH 级发现 | PASS | 显式 OWASP Top 10 走查(注入、认证、访问控制、XXE/SSRF/反序列化/XSS、错误配置、漏洞依赖、日志)无 blocker/major/minor;store.go 无锁池交换的安全性已验证(单调用点、发布前、构造函数内) |
| 5 | 分支是否干净 | PASS | 恢复 + rebase 后git status --short为空 |
| 6 | 是否与 main 干净分叉 | PASS | assert_deploy_ancestry_scoperc=0,无.claude/**路径,所有 commit 都引用已接受的 bead id;attempt_bounded_self_rebase0 冲突完成 rebase(BEFORE_SHA=e0c39aa25...→ AFTER_SHA=7d294b4b5...) |
| 7 | 单一功能主题 | PASS | be-0v3l(oracle 修复)+ 被授权的 be-itm5 cherry-pick(编译必需依赖),范围内无杂散 commit |
6.1 值得借鉴的三个工程细节
- "not caused by this diff" 必须实证而非假设:14 个既有失败通过 base-ref(merge-base
6ec78f3a2)对照运行逐一复现,且 4 条归因子句(非 diff 自有、已跟踪、base-ref 已存在、无路径重叠)全部满足——即使后来发现 be-3c78s 已由 PR #5836 修复,也不追溯否定当时的快照判断。 - SKIP 不被当作 PASS:diff 自有测试 0 SKIP,且容器真实执行,杜绝了"用 SKIP 顶替验证"的造假路径。
- 分支恢复有据可依:worktree 的
pre_start在轮次间把本地分支指针硬重置回origin/main,但 rebase 产物 commit 对象仍在本地对象库(git cat-file -e确认),且 bead 笔记已记录 SHA 为持久状态,因此用git reset --hard AFTER_SHA恢复而非重做,随后对恢复后状态重新跑全部门禁检查。
七、推送策略、合并权限与最终裁定
- 推送目标:
origin(gastownhall/beads)被哨兵配置DISABLED-upstream-is-fetch-only-push-to-fork-and-PR禁止推送;headfork(quad341/beads-sec003-contrib.git)按本 rig 既定先例(be-r3ysh)接受推送。PR 以跨仓库方式对gastownhall/beads:main发起,head 为quad341:deploy/be-krza3-gate。 - 已知基建缺口 be-z3iuv:
attempt_bounded_self_rebase内部的 force-with-lease 推送仍硬编码指向origin(脚本 rebase-resolve-lib.sh 第 489 行),故本轮改用直接手动推送到headfork;rebase 本身干净完成,不受该推送 bug 影响。注意此路径为本机环境信息,仅作为流程记录。 - 合并权限:
gastownhall/beads对本 rig 是仅贡献者仓库,rig 无合并权限;deployer 的职责止于"打开已核验的 PR",门禁结果通过邮件上报 mayor。这是 be-vc1m、be-gd3v、be-79jh、be-39ss、be-pp7e、be-r3ysh 等先例确立的制度。 - 最终裁定:PASS 7/7。分支经本地 ref 重置后恢复并重新核验、对最新 main 的 rebase 复证干净、build/vet/gofmt 独立复跑,已推送 headfork,PR 状态 OPEN/MERGEABLE。deployer 交棒,后续以 PR CI 作为额外的真实环境确认(但不是门禁通过的前提条件)。
八、从本门禁提炼的可复用经验
- 确定性是测试的第一性原理:Go map 迭代顺序、会话 root 推进时机、连接池回收时机,这些隐式非确定性是间歇性 flaky 的温床。
sortedSnapshotQueryNames的教训是:凡是"顺序会影响结果"的测试,必须显式固定顺序并加注释说明原因。 - oracle 的盲区要显式记账:ignored 迁移流的排除谓词让 oracle 失去了对 wisp 列漂移的感知(ga-61ruw 事件),文档没有掩盖这一点,而是记录了代价、实测了 ignored 平面拥有的对象清单,并给出正确的根治方向(补 ignored bundle)。
- 测试要能"精确复现"而非"大概率复现":
TestMigratingOpen_FirstReadSucceeds刻意只发一条查询来复现 be-itm5;connection_pool_test.go用 mock driver 把连接生命周期变成可断言的数值。好的复现测试应该让 bug 每次必现,而不是碰运气。 - 修复的生产代码边界要靠编译约束强制:be-krza3 的 scope 争议最终由"不含 store.go 就无法编译"这一机械事实裁决,比任何评审意见都更有说服力——测试本身就是需求。
九、继续深入仓库
- 门禁全文:release-gates/be-krza3-schema-parity-pool-heal-gate.md
- Parity oracle 实现:internal/storage/dolt/schema_cli_parity_integration_test.go
- 池重建与池参数实现:internal/storage/dolt/store.go(
applyPoolLimits)、internal/storage/dolt/store.go(rebuildPoolAfterMigration) - 连接池单元测试:internal/storage/dolt/connection_pool_test.go
- 迁移后首读复现测试:internal/storage/dolt/post_migration_pool_heal_integration_test.go
- Schema 迁移与 CLI DDL 模板:internal/storage/schema
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考