Beads 存储层 Schema 一致性守卫与连接池迁移修复实战:从 be-krza3 发布门禁看 CLI/Runtime 模式对齐工程
2026/9/13 6:38:47 网站建设 项目流程

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 治理有两个关键约束:

  1. 运行时 schema:由store.initSchema在打开数据库时执行迁移得到,迁移 SQL 由schema.AllMigrationsSQL()统一提供(见 internal/storage/schema)。
  2. 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_migrationslocal_metadatarepo_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 修复方案:静态排除 + 确定性排序

修复包含两处,均为测试代码改动:

  1. 静态排除子句:在 5 个快照查询中加入上述NOT IN/LEFT(...)/NOT (...)过滤,明确表达"oracle 只对比主线迁移流";
  2. 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 }

两个采集器(cliCommittedSchemaSnapshotruntimeCommittedSchemaSnapshot)都改为遍历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.goconnection_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=10MaxIdleConns=5ConnMaxLifetime=1h
TestApplyPoolLimits_OverridesConfig 非零字段覆盖默认值(如 3/2/15min)
TestApplyPoolLimits_ClampsIdleToOpenMaxIdleConns不得超过MaxOpenConns,否则database/sql会静默钳制
TestPool_SequentialQueriesReuseSingleConnection两次顺序查询只 Open 1 次、Close 0 次——池必须复用连接
TestPool_ConcurrentQueriesRespectMaxOpen8 个并发查询、池上限 2 时,底层连接数不超过 2
TestPool_CloseReleasesUnderlyingConnectionsClose()后所有已打开连接全部释放(opens == closes)
TestRebuildPoolAfterMigration_NoopWhenNotMigratedapplied=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预检:是否已合入?NOgh pr list --search无结果;git merge-base --is-ancestor确认目标 commit 不在 main 上,继续评估
1是否存在 Review PASS?PASS审查 bead be-8xjjg,第 2 轮 verdictpass,以 reasonpass关闭
2验收标准是否达成?PASSscope 授权链按文档化路径裁决而非"人说了算";uncovered_criteria: none-count=5复证非确定性已消除
3diff 自有测试是否全过(SKIP=FAIL 规则)?PASS9/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 通道PASSgolangci-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 干净分叉PASSassert_deploy_ancestry_scoperc=0,无.claude/**路径,所有 commit 都引用已接受的 bead id;attempt_bounded_self_rebase0 冲突完成 rebase(BEFORE_SHA=e0c39aa25...→ AFTER_SHA=7d294b4b5...
7单一功能主题PASSbe-0v3l(oracle 修复)+ 被授权的 be-itm5 cherry-pick(编译必需依赖),范围内无杂散 commit

6.1 值得借鉴的三个工程细节

  1. "not caused by this diff" 必须实证而非假设:14 个既有失败通过 base-ref(merge-base6ec78f3a2)对照运行逐一复现,且 4 条归因子句(非 diff 自有、已跟踪、base-ref 已存在、无路径重叠)全部满足——即使后来发现 be-3c78s 已由 PR #5836 修复,也不追溯否定当时的快照判断。
  2. SKIP 不被当作 PASS:diff 自有测试 0 SKIP,且容器真实执行,杜绝了"用 SKIP 顶替验证"的造假路径。
  3. 分支恢复有据可依:worktree 的pre_start在轮次间把本地分支指针硬重置回origin/main,但 rebase 产物 commit 对象仍在本地对象库(git cat-file -e确认),且 bead 笔记已记录 SHA 为持久状态,因此用git reset --hard AFTER_SHA恢复而非重做,随后对恢复后状态重新跑全部门禁检查。

七、推送策略、合并权限与最终裁定

  • 推送目标origingastownhall/beads)被哨兵配置DISABLED-upstream-is-fetch-only-push-to-fork-and-PR禁止推送;headforkquad341/beads-sec003-contrib.git)按本 rig 既定先例(be-r3ysh)接受推送。PR 以跨仓库方式对gastownhall/beads:main发起,head 为quad341:deploy/be-krza3-gate
  • 已知基建缺口 be-z3iuvattempt_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 作为额外的真实环境确认(但不是门禁通过的前提条件)。

八、从本门禁提炼的可复用经验

  1. 确定性是测试的第一性原理:Go map 迭代顺序、会话 root 推进时机、连接池回收时机,这些隐式非确定性是间歇性 flaky 的温床。sortedSnapshotQueryNames的教训是:凡是"顺序会影响结果"的测试,必须显式固定顺序并加注释说明原因。
  2. oracle 的盲区要显式记账:ignored 迁移流的排除谓词让 oracle 失去了对 wisp 列漂移的感知(ga-61ruw 事件),文档没有掩盖这一点,而是记录了代价、实测了 ignored 平面拥有的对象清单,并给出正确的根治方向(补 ignored bundle)。
  3. 测试要能"精确复现"而非"大概率复现"TestMigratingOpen_FirstReadSucceeds刻意只发一条查询来复现 be-itm5;connection_pool_test.go用 mock driver 把连接生命周期变成可断言的数值。好的复现测试应该让 bug 每次必现,而不是碰运气。
  4. 修复的生产代码边界要靠编译约束强制: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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询