Skip to content

feat: 保留期回收——按已投递位点丢弃整份数据文件 - #296

Merged
NeverENG merged 1 commit into
mainfrom
feat/retention
Aug 13, 2026
Merged

feat: 保留期回收——按已投递位点丢弃整份数据文件#296
NeverENG merged 1 commit into
mainfrom
feat/retention

Conversation

@NeverENG

@NeverENG NeverENG commented Aug 13, 2026

Copy link
Copy Markdown
Owner

作为「写入前置缓冲」,此前已投递的数据永不回收,本地只增不减,长跑必然涨满磁盘。

为什么按整文件丢弃,而不是逐 key 写墓碑

墓碑会让写入量翻倍,而且自身还要再经一轮 compaction 才消失。文件级丢弃是 O(1)、无写放大——这正是日志结构系统回收空间的方式。

判据:该 SSTable 的 MaxKey 严格小于已提交的投递游标。投递按 key 升序推进,故游标之前的数据已全部被读过。

保守之处(宁可少回收,不可误删)

MaxKey 不可信则跳过 没有可读 footer 的文件(老格式或尾部残缺)无法判断是否已整体投递
严格小于 恰好含游标的文件保留——游标是「下一批起点」,本身尚未被消费
游标为空不回收 尚无提交时不动任何文件
与 compaction 共用 fileMu 二者都删文件。若交错,compaction 正在读的源文件可能被删——POSIX 下已打开的 fd 仍可读,那批已回收的数据会被写进合并输出,即「已回收的数据复活」
游标先压到 offset 保留前缀之下 游标自身以 __offset__/<sink> 存在同一 KV 空间。若某文件同时含业务数据与游标、而游标又大于它,整份文件会连游标一起删掉,投递将从头重投全部数据

挂载点:offset 提交成功之后(装饰 OffsetStore,投递主体不改)。游标落地才代表这批不会再被重投,此时回收才不会删掉仍需重投的数据。

默认关闭RetentionEnabled):开启即意味着已投递的数据不再能从本地读回,这是「缓冲」而非「存储」的语义,必须由使用方明示。

顺带修一处扫描复杂度问题

KVServer.Scan 此前总把从游标到键空间末尾的条目(上限 1 万)全部物化,而调用方只取前几百条 —— 每批代价 O(剩余数据量),总代价随数据量平方增长。现在 limit 一路传到扫描里,并按文件的 [MinKey,MaxKey] 先排除与区间无交集的 SSTable。

实测取 201 条的耗时随游标推进:13ms → 3.5ms → 0.9ms

测试

4 个用例,含回收与 compaction 并发-race)与 MaxKey 不可信时跳过。核心用例断言:整份已投递的文件被删、跨越游标的文件保留、保留文件里的数据仍可读、已回收的数据确实读不到。

过程中发现的一条既有限制(已写入代码注释)

E2E 验证时投递只走了约 310/2000,追下去不是回收的问题,也不是我引入的

KVSourcekey 升序推进游标,IdempotentFileSink 按「已投递的最大 key」过滤 —— 两者都假设投递顺序与 key 顺序一致。而压测用 20 个并发 writer 乱序写入,投递一启动就沿 key 顺序冲到很后面,此后才落地的较小 key 永远排在游标之前,再也不会被投递

对照实测:2000 条若在投递启动前写完,10 轮取满 2000/2000;与并发 writer 同时进行则只投出约 310 条。

要覆盖乱序到达,游标需改为按「写入序」而非「key 序」推进(为每条写入分配单调序号并按其建索引),属独立设计。当前实现适用于时间序 key(如 imu:dev0:<ts>)这类天然单调的摄入场景 —— 这一边界已写进 source.go 的包内注释。

验证

go build ./...(含 -tags pprof)、go vetgofmtgo test -race ./... 连跑 2 次全绿。

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added optional data retention cleanup. When enabled, fully delivered data files are automatically removed; disabled by default.
    • Added bounded scanning to limit the number of returned results.
  • Bug Fixes
    • Improved scan efficiency by skipping storage files that cannot contain matching data.
    • Coordinated cleanup with compaction to preserve data consistency and avoid unsafe concurrent deletion.
  • Documentation
    • Documented the new retention setting and its default behavior.

作为「写入前置缓冲」,此前已投递的数据永不回收,本地只增不减,长跑必然涨满磁盘。

回收按整个 SSTable 文件丢弃,而非逐 key 写墓碑:墓碑会让写入量翻倍,且自身还要再经一轮
compaction 才消失;文件级丢弃是 O(1),无写放大。判据是「该文件 MaxKey 严格小于已提交的
投递游标」——投递按 key 升序推进,故游标之前的数据已全部被读过。

保守之处(宁可少回收,不可误删):
- 仅在 MaxKey 可信时回收。没有可读 footer 的文件 MaxKey 未知,一律跳过。
- 用严格小于:恰好含游标的文件保留,因为游标本身尚未被消费。
- 游标为空(尚无提交)时不回收。
- 回收与 compaction 共用 fileMu 串行。二者都删文件,若交错,compaction 正在读的源文件
  可能被删——POSIX 下已打开的 fd 仍可读,那批已回收的数据会被写进合并输出,即「已回收的
  数据复活」。
- 游标先被压到 offset 保留前缀之下:游标自身以 `__offset__/<sink>` 存在同一 KV 空间,
  若某文件同时含业务数据与游标而游标又大于它,整份文件会连游标一起删掉,投递将从头重投。

回收挂在 offset 提交成功之后(装饰 OffsetStore,投递主体不改):游标落地才代表这批不会
再被重投,此时回收才不会删掉仍需重投的数据。默认关闭——开启即改变读语义,必须由使用方明示。

顺带修一处扫描的复杂度问题:KVServer.Scan 此前总把从游标到键空间末尾的条目(上限 1 万)
全部物化,而调用方只取前几百条,每批代价 O(剩余数据量)。limit 现在一路传到扫描里,并按
文件的 [MinKey,MaxKey] 先排除与区间无交集的 SSTable。实测取 201 条的耗时随游标推进从
13ms 降到 0.9ms。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NeverENG
NeverENG merged commit 048ee8e into main Aug 13, 2026
3 checks passed
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a35027fb-ed65-4463-8dca-7e3ebda6f0fd

📥 Commits

Reviewing files that changed from the base of the PR and between c414cb8 and aeff002.

📒 Files selected for processing (11)
  • README.md
  • config/global.go
  • service/delivery/source.go
  • service/delivery/source_test.go
  • service/delivery_bootstrap.go
  • service/fsm.go
  • service/router.go
  • service/scan_integration_test.go
  • service/shard_routing_integration_test.go
  • storage/engine.go
  • storage/retention_test.go

📝 Walkthrough

Walkthrough

Changes

Retention-aware delivery

Layer / File(s) Summary
Bounded scan contracts
service/delivery/source.go, service/delivery/source_test.go, service/fsm.go, service/router.go, service/*integration_test.go
Scan APIs accept a limit. Delivery scans use limit+1 to handle reserved keys. Local scans retain unlimited behavior.
SSTable reclamation and synchronization
storage/engine.go, storage/retention_test.go
ReclaimUpTo removes SSTables with trusted maximum keys below the bound. Compaction and deletion share a mutex. Tests cover boundaries, unknown keys, empty bounds, readability, and concurrency.
Retention-aware delivery commits
config/global.go, service/delivery_bootstrap.go, service/fsm.go, README.md
RetentionEnabled defaults to false. When enabled, successful offset commits reclaim delivered SSTables. Failed commits do not reclaim files.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • NeverENG/BanFlux#272: Introduced the storage.Engine implementation extended here with SSTable reclamation and compaction synchronization.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/retention

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

🐯 BanGD 数据库内核评审

整体风险:🟡 中

变更总结:本 PR 为「写入前置缓冲」新增保留期回收能力:按已投递的 offset 游标,将 MaxKey 严格小于游标的整份 SSTable 文件从磁盘删除,使本地缓冲不再只增不减。核心架构:在 offset 提交成功之后、以装饰器(reclaimingOffsetStore)挂在投递路径上调用 KVServer.ReclaimDelivered → Engine.ReclaimUpTo,按文件级 O(1) 丢弃(而非墓碑+compaction),并用与 compaction 共用的 fileMu 串行化所有删文件动作,防止「已回收数据复活」。同时把 KVServer.Scan 的 limit 一路传到扫描内部,并按文件的 [MinKey,MaxKey] 先排除区间无关的 SSTable,修复了按游标分批取数时的 O(剩余数据量) 每批代价、总体 O(n²) 的扫描复杂度问题。此外文档化了一个既有的投递语义边界:KVSource 按 key 升序推进游标,无法覆盖乱序到达的写入。

本评审不阻塞合入;架构级建议以 Issue 形式跟踪,普通问题在下方内联列出。

架构问题(共 4 项)

普通问题(共 1 项)

💡 [建议 · 边界条件] service/delivery/source.go:67 Fetch 对跳过的保留 key 计入游标推进但可能造成批空转

  • 当 limit+1 条扫描结果里 limit 条都是保留 key(如大量 offset 文件时),本批业务 batch 为空(0 条),但 lastScanned 已被推到最后一个保留 key,游标前进。若 offset 相关 key 数量多于 limit,会出现多轮空转直到越过所有保留 key——虽然批空时 Deliverer 直接 return nil(游标不动,见 deliverOnce 的 if len(batch)==0 return nil),但若 batch 为空而 next 已前进,Deliverer 不会 Commit,游标不会前进——这不构成空转(因为 batch 为空时游标不 Commit)。然而若 batch 恰有 1 条业务 key 且其余 limit 条全是保留 key,本批投 1 条但游标一次性越过大量保留 key,下一批从更远处扫——这是期望行为(保留 key 被跳过),不算 bug。核心检查:limit+1 的余量保证即使恰有 1 条保留 key 占位也能凑够 limit 条业务 key。若保留 key 恰好占满 limit+1,本批业务为 0,游标在 batch 非空时才 Commit——batch 为空则游标不动、下轮重扫同样在保留 key 处卡住——这是死循环风险:若保留 key 数量 >= limit+1,Fetch 每轮都返回空 batch + 前进的 next,但 Deliverer 因 batch 空不 Commit,游标永停在保留 key 之前的起始点,每轮重扫同样超量的保留 key,空转。
  • 建议:在 Fetch 中,若 batch 为空但 lastScanned 已前进(即本批全是保留 key 被跳过),应直接返回 next = lastScanned+0x00 而不是返回原 cursor,使调用方(Deliverer)能推进游标越过这一大片保留 key。当前实现返回 next(已前进)而非 cursor,但 Deliverer 在 batch 空时不 Commit,游标仍停住——需要对 Deliverer 明确『batch 空但 next > cursor 时也 Commit』,或让 Fetch 在 batch 为空时仍返回可推进的游标且调用方据此提交。当前 Retention 场景 offset key 只有一个文件,不足以触发,但偏移文件数增长时(多 sink)会成死循环隐患。

本次评审消耗 token:共 295004 tokens(输入 234695,输出 11413,缓存命中 48896,缓存写入 0)|维度 [concurrency, memory, lock, storage, performance]|补充阅读周边文件 [service/delivery/offset/store.go, storage/sstable.go, service/delivery/deliverer.go, storage/options.go, service/offset_committer.go]|对抗式复核 3 票/条,过滤疑似误报 3 条

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant