Beads 存储层扩展契约:深入解析IssueFilter.Lite轻量 SELECT 与部分水合机制
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
本文围绕 Beads 仓库内部存储扩展文档 engdocs/EXTENDING.md 展开,系统讲解嵌入或直连 bd 存储层的调用方必须遵守的IssueFilter.Lite契约:哪些重 TEXT 列会被省略、返回的*types.Issue上哪些字段可信、如何用IsLitePartial感知水合深度,以及这一机制在issueops存储栈中的强制点与后端覆盖边界。读完本文,你将能安全地在自己的调用方代码中启用 lite 扫描,并理解其与完整水合路径、模式 B(id 收缩)与 counts 大查询之间的协作关系。
这份文档的定位:存储 API 调用方契约
engdocs/EXTENDING.md开篇即明确自己的读者群体:它不是面向终端用户的文档,而是面向嵌入 bd 或直接对话存储层的代码。凡是调用store.SearchIssues(ctx, query, filter)的模块,都受这份契约约束。这意味着文中描述的IssueFilter.Lite是一个内部扩展点——它不会改变bdCLI 的默认行为,而是为那些希望跳过大型正文列、只做路由/列表类读取的调用方提供一条显式可选的快路径。
文档中出现的工程标识(如 be-uwvs.2)说明这是有计划的演进:lite 契约本身已经落地在 issueops 栈,而把filter.Lite贯通到 domain/db 代理服务器栈被明确推迟到后续的 CLI 接线工作中。
为什么需要 Lite:heavy TEXT 列的成本
bd 的issues表把一张 issue 的所有内容都放在同一行里,其中包含六个体量可观、以自由文本为主的 TEXT 列:
description(描述)design(设计)acceptance_criteria(验收标准)notes(备注)waiters(等待者列表)payload(事件载荷)
这六列就是源码中 internal/storage/issueops/scan.go 定义的HeavyDropList。对一个以“列出所有 issue 供路由决策”为目的的查询来说,这六列中的绝大多数内容根本不会被读取:路由只需要身份、状态、优先级、时间戳、标签、依赖这些“小而常读”的列。把多 KB 的正文列随每一行一起物化,是对 IO 与内存的浪费。
IssueFilter.Lite正是为此设计:当filter.Lite == true时,存储层发出一个更窄的 SELECT,从投影列中剔除上述六个重列,让列表路径只搬运真正需要的数据。
调用方契约:三条 MUST/MAY 规则
engdocs/EXTENDING.md以非常明确的措辞规定了 lite 结果的语义,任何调用方都必须遵守:
MUST NOT:禁止读取被省略的六个字段
当以IssueFilter.Lite == true调用store.SearchIssues时,调用方绝对不得从返回的*types.Issue上读取Description、Design、AcceptanceCriteria、Notes、Payload或Waiters。lite 扫描之后这些字段全部是零值——它们根本没有从行中取回,读取它们不产生任何信号。把“零值”误当成“内容为空”是这类 API 最常见的错误用法。
MAY:可以读取其余所有字段
除上述六个字段外,其余字段在 lite 扫描中全部保留:身份标识(ID、标题、内容哈希)、状态、优先级、类型、时间戳(创建/更新/开始/关闭/截止/推迟)、标签、依赖、元数据(metadata)、租约覆盖列(lease_expires_at、heartbeat_at、granted_node)、行版本令牌(row_lock)与存储类(storage_class)。源码中 internal/storage/issueops/scan.go 的 lite 目标列表证实了这一点——它只跳过了HeavyDropList中的六列。
值得注意的是:metadata被刻意保留。scan.go 的注释明确说明它“体积小且路由会读取”;row_lock与租约三列也保留,因为它们是乐观并发令牌、活跃租约状态与授权副本标识,属于路由/认领代码的必读项,而非本次拆分要跳过的多 KB 正文。
MUST:用IsLitePartial检测部分水合
如果调用方需要针对水合深度做分支处理,必须通过issue.IsLitePartial来检测 lite 抓取的记录。该字段在 internal/types/types.go 中定义,是仅内部可见的标志(json:"-"),永远不会随序列化跨过线路(wire)——也就是说,任何外部消费者都无法通过 JSON 观察到它,这使它成为区分“真·无文本 issue”和“未水合的文本”的唯一内部手段。
恢复完整正文的唯一途径:GetIssue
lite 列表之后,如果针对某个特定 issue 需要完整正文,唯一正确的恢复方式是调用store.GetIssue(ctx, id)——它始终返回完整行。internal/storage/issueops/get_issue.go 的实现印证了这一点:它固定使用完整的IssueSelectColumns投影,配合LeaseJoin与单行扫描。
默认行为:零成本迁移
IssueFilter.Lite默认值为false(见 internal/types/types.go 的字段注释)。因此,所有未显式选择加入的现有调用点保持今天的行为:六个重列完整水合,Issue.IsLitePartial为false。这是一个向后完全兼容的开关——新增该字段不会让任何存量代码悄悄改变语义,只有显式设置Lite: true的调用方才会进入窄投影路径。
这一设计在 internal/storage/issueops/search.go 中体现得极为直白:
func SearchIssuesInTx(ctx context.Context, tx DBTX, query string, filter types.IssueFilter) ([]*types.Issue, error) { proj := issueProjection if filter.Lite { proj = issueLiteProjection } return searchInTx(ctx, tx, query, filter, proj) }filter.Lite只在两个投影字面量之间做一次选择,其余全部共享。
契约在源码中的强制位置
engdocs/EXTENDING.md精确列出了四处强制点,逐一对应到源码:
列清单:scan.go的三个常量
internal/storage/issueops/scan.go 定义:
IssueSelectColumns:完整水合的规范列清单,直接复用sqlbuild.IssueSelectColumns;IssueSelectColumnsLite:lite 列清单,与完整清单保持列顺序一致,仅剔除HeavyDropList;HeavyDropList:被省略的六个重列,注释明确要求其满足集合恒等式cols(IssueSelectColumnsLite) ∪ HeavyDropList == cols(IssueSelectColumns)。
扫描助手:ScanIssueFrom与ScanIssueLiteFrom
同一文件中的两个扫描函数按位置(positionally)绑定扫描目标,二者都必须与各自的列清单逐列对应:
ScanIssueFrom(scan.go)水合全部字段,IsLitePartial保持false;ScanIssueLiteFrom(scan.go)不读取六个重字段,并在返回前设置issue.IsLitePartial = true(第 414 行)。
注意 scan.go 第 68-69 行的警告:调用方必须保证查询精确选择了IssueSelectColumns(或 Lite 版)且顺序一致,位置扫描对列顺序极度敏感——这正是下面 schema-parity 守卫要锁死的东西。
SELECT 分发:search.go的投影选择
internal/storage/issueops/search.go 定义了两个searchProjection[*types.Issue]字面量:
issueProjection:完整列 +ScanIssueFrom;issueLiteProjection:lite 列 +ScanIssueLiteFrom,注释明确指向 engdocs/EXTENDING.md 作为调用方契约。
两者共享同一套 wisp-merge 与水合机制(searchTableInTxT):标签/依赖水合(hydrateIssueLabelsAndDeps)、issues+wisps 双平面合并、去重(GH#3567)、LeaseJoin租约连接、Pattern B id 收缩,全部由searchProjection[T]泛型抽象承载,lite 投影不另起炉灶。
Schema 一致性守卫:scan_test.go的两个测试
这是把契约“焊死”在 CI 上的关键:
- TestIssueSelectColumns_LitePlusHeavyEqualsFull:集合守卫。未来任何列被加入
IssueSelectColumns而未归类到IssueSelectColumnsLite或HeavyDropList二者之一,测试即失败,并给出可操作的错误信息; - TestIssueSelectColumnsLite_IsFullMinusHeavyInOrder:顺序守卫。集合比较无法发现“两个同类型列被对调”的问题——因为扫描是位置绑定,列对调后每行的值会静默错位而没有任何成员关系变化。该测试以“从完整清单原位删除重列必须精确复现 lite 清单”为 oracle,把 lite 清单变成完整清单的派生结果而非第二份手工维护的副本,防止“新列加到完整清单中间却追加到 lite 清单末尾”的漂移。
配套行为测试同样完备:TestScanIssueLiteFrom_LeavesHeavyFieldsBlank 验证六字段零值、身份字段仍水合、IsLitePartial=true;TestScanIssueFrom_PopulatesHeavyFields 验证其反面;search_lite_merge_test.go 则从端到端确认Lite: true确实触发了 lite 扫描路径。
源码级原理:投影抽象、租约覆盖与共享的列构建器
searchProjection[T]:一次抽象,三种投影
internal/storage/issueops/search.go 的searchProjection[T]结构体把“投影列 → 扫描函数 → ID 提取 → 后扫描水合 → Go 侧排序 → 租约连接”全部参数化。三种投影实例各司其职:
| 投影 | 列 | 扫描 | 用途 |
|---|---|---|---|
issueProjection | IssueSelectColumns | ScanIssueFrom | 完整水合 |
issueLiteProjection | IssueSelectColumnsLite | ScanIssueLiteFrom | lite 窄投影 |
idProjection | 仅id | 裸 ID 扫描 | 模式 B 收缩 / 部分 ID 解析 |
模式 B(idShrink)值得一提:对带Limit的宽投影查询,先跑廉价的SELECT id扫描,再对幸存行批量抓取并水合,避免为被 LIMIT 丢弃的行流式搬运整个投影。lite 投影与完整投影一样启用idShrink,说明 lite 与 Pattern B 是正交的两层优化:前者削减每行的宽度,后者削减行数。
列清单的真正宿主:sqlbuild纯 SQL 构建器
列清单的实际定义不在 issueops,而在 internal/storage/sqlbuild/sqlbuild.go:
IssueBaseColumns(第 46-55 行)与IssueBaseColumnsLite(第 64-73 行):行本身的列,不含租约覆盖;LeaseSelectColumns(第 79 行)与LeaseJoin(第 99-101 行):租约覆盖列与LEFT JOIN leases ON leases.issue_id = <table>.id片段;IssueSelectColumns/IssueSelectColumnsLite(第 86-93 行):基础列 + 租约覆盖列的组合。
sqlbuild包被经典 issueops 栈(生产,*sql.Tx)与 domain/db 仓库栈(代理服务器)共享,其设计目标是保证两个实现针对相同过滤器产生相同的行集合(由 Seam A 奇偶校验套件固定)。任何选用IssueSelectColumnsLite的查询都必须同时在 FROM 子句中包含LeaseJoin(table)——漏掉连接会在leases.*引用上响亮失败,而不会静默出错。
counts 大查询中的 Lite 变体
lite 契约不止作用于无计数搜索。按 internal/types/types.go 的注释,filter.Lite在两个栈上都被计入返回IssueWithCounts的计数页读取(bd list --json的两条路由与GET /v0/beads/issues),它作为sqlbuild.CountsHydration.Lite搭乘 counts 大查询,而 internal/storage/sqlbuild/counts.go 中渲染的就是基础列的带限定符变体(如ReadyWorkIssueColumns)。
后端覆盖与已知边界
engdocs/EXTENDING.md的最后一部分交代了诚实的能力边界,这对调用方至关重要:
- 已支持:
filter.Lite目前仅由 issueops 支撑的存储后端(Dolt、嵌入式 Dolt)通过上述分发机制执行; - 尚未支持:代理服务器路径 internal/storage/domain/db(
issueSQLRepositoryImpl.searchTable/fetchIssuesByIDs)尚不检查filter.Lite,总是发出完整的issueSelectColumns查询并返回完全水合的 issue(IsLitePartial == false); - 定性:文档将其明确评价为“正确但未优化”(correct-but-unoptimized)——由于目前尚不存在 lite 调用方,这一差异在今天就不可见;把
filter.Lite贯通 domain/db 栈的工作被推迟到 CLI 接线后续(be-uwvs.2+),不属于本次基础工作的一部分。
这意味着:如果你的调用方运行在 proxied-server/domain/db 路径上,设置Lite: true目前不会报错,也不会加速——你得到的是行为正确但完全水合的结果。这是可观测、可预期的降级,而不是契约违反。这一事实边界也提醒嵌入式集成者:判断某个查询是否真正走了 lite 路径,唯一可靠的方法是检查返回行的IsLitePartial是否为true,而不是假设设置即生效。
实践要点速查
为嵌入式调用方(库用户、扩展、自定义路由)总结安全用法:
- 列表/路由类读取:设置
filter.Lite = true,只消费身份、状态、优先级、时间戳、标签、依赖与元数据字段; - 绝不读取
Description/Design/AcceptanceCriteria/Notes/Payload/Waiters——它们是零值,不构成“内容为空”的证据; - 需要分支:用
issue.IsLitePartial判断水合深度(该标志不会出现在任何序列化输出上); - 需要正文:对单个 issue 调用
store.GetIssue(ctx, id)恢复完整行; - 验证生效:检查
IsLitePartial == true,若在 domain/db 代理路径上观察到false,属于文档明确记载的“正确但未优化”状态; - 做后端选型:确认目标存储是 issueops 支撑的 Dolt/嵌入式 Dolt 后端,否则 lite 暂不生效。
延伸阅读
- 契约正文:engdocs/EXTENDING.md
- 列清单与扫描实现:internal/storage/issueops/scan.go
- 投影分发与共享机制:internal/storage/issueops/search.go
- 纯 SQL 列构建器:internal/storage/sqlbuild/sqlbuild.go
IsLitePartial与IssueFilter.Lite定义:internal/types/types.go- Schema 一致性守卫:internal/storage/issueops/scan_test.go
- 读取器契约(含 lite 断言):backend/conformance/reader_contract.go
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考