Skip to content

perf(storage): replace clear-time VACUUM with batched purge and incremental reclaim - #494

Merged
qxcnm merged 4 commits into
qxcnm:mainfrom
xcosmosbox:perf/requestlog-batched-purge
Oct 8, 2026
Merged

qxcnm merged 4 commits into
qxcnm:mainfrom
xcosmosbox:perf/requestlog-batched-purge

Conversation

@xcosmosbox

@xcosmosbox xcosmosbox commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

合并顺序

这是一组 4 个 PR,请按以下顺序合并:

  1. perf(rusqlite): read last_insert_rowid lazily and pin each connection to one SQLite handle #493 perf(rusqlite) SQLite 垫片:按需读取 rowid、固定物理连接
  2. perf(storage): replace clear-time VACUUM with batched purge and incremental reclaim #494(本 PR) perf(storage) 清空日志去掉 VACUUM,改为分批删除与增量回收
  3. feat(core): add request payload group commit and spill segment primitives #495 feat(core) 请求内容组提交与磁盘溢出的 core 基础设施
  4. feat(requestlog): move payload capture onto a bounded background write queue #496 feat(requestlog) 请求内容捕获切换到有界后台写队列

本 PR 依赖前序 PR #493,请在 #493 合并后再合并。

本分支基于 #493 的分支。#493 合并前,Files changed 会同时显示前序 PR 的提交;只看本 PR 的改动请看提交 871255cc、309c99c7,或增量对比。#493 合并后我会把本分支 rebase 到最新 main。

后续 PR:#495 依赖本 PR。

概述

现在清空请求日志时,会在一个事务里删除全部请求内容,然后执行 PRAGMA wal_checkpoint(TRUNCATE); VACUUM;。开启完整请求内容存储后数据库可能有数 GB:VACUUM 要重写整个文件、需要同等大小的空闲磁盘,持有写锁的时间足以让网关写日志等满 3 秒 busy timeout 而报错;在 temp_store=MEMORY 下临时副本还可能放在内存里。

本 PR 去掉清空时的 VACUUM:清空只在短事务里让旧数据不可见,物理删除改为限时分批进行,空闲页由后台小步归还给系统。

改动

  • 清空与保留期裁剪只做标记:短事务内递增清空代次(或抬高保留期水位),并把四张请求内容表当前的最大 rowid 记进新表 request_log_payload_purges(迁移 142)。之后按 rowid 从小到大分批删除:每批 50–4000 行自适应,目标 60 ms,批间停 15 ms;删除期间临时开启 secure_delete,结束后恢复。blob 在每个删除事务内检查引用后回收。
  • 待删数据立即不可见:预览、完整内容、上游尝试的读取以及父请求选择都排除待删范围内的行;新请求不会以待删行为父节点,保留期裁剪仍先重基存活的子请求。
  • 清空接口限时:清空 RPC 内同步删除最多 10 秒,剩余部分(以及重启前未完成的部分)由后台续删。首个事务提交后出现的错误只记日志,不再让清空返回失败。同一个数据库同一时间只有一个分批删除在执行。
  • 后台维护线程 storage-maintenance:每 30 秒一轮,负责保留期裁剪(不再在网关写日志的线程上同步执行)、续删(每轮最多 5 秒)、wal_checkpoint 遇到 busy 时重试,以及在可回收空间不少于 64 MiB 且占 10% 以上时用 incremental_vacuum 小步归还空间(每步 16–8192 页,目标 50 ms,步间停 200 ms,每轮最多 5 秒)。
  • 自动回收模式:新库默认 auto_vacuum=INCREMENTAL,老库不自动转换。每个连接设置 journal_size_limit=64 MiB,限制 checkpoint 后留下的 WAL 文件大小。空间统计按 page_count - freelist_count 计算,不看文件大小。
  • 管理入口:新增仅管理员可调用的 storage/spaceUsage、storage/reclaim RPC(Web 鉴权为 password 模式时同样拒绝成员),以及对应的 Tauri 命令和 Web 映射。设置 → 网关新增「数据库空间」卡片:显示已用 / 可回收空间、数据库与 WAL 文件大小、自动回收模式和后台删除状态,可「立即回收」;老库提供一次性「整理数据库」(启用增量回收并 VACUUM),执行前检查数据目录和临时目录的空闲空间,分批删除未完成时拒绝执行。远程数据库模式下显示不支持。
  • 日志页清空成功后提示磁盘空间不会立即变小,并给出回收入口。en/ko/ru 文案。
  • CI 新增 Test SQLite storage maintenance 步骤:core 全量测试和 service 维护模块单测。

改动范围

  • Frontend
  • Desktop / Tauri
  • Service
  • Gateway / Protocol Adapter
  • Docs / Governance
  • Workflow / Release

主要文件

  • crates/core/src/storage/request_log_payload_purge.rs(新)、storage_space.rs(新)、request_logs.rs、request_log_payload_store.rs
  • crates/core/migrations/142_request_log_payload_purges.sql
  • crates/service/src/storage/maintenance.rs(新)、rpc_dispatch/storage_space.rs(新)、rpc_dispatch/mod.rs、lifecycle/startup.rs、requestlog/seaorm.rs
  • apps/src/app/settings/components/storage-space-card.tsx(新)、apps/src/lib/api/storage-space.ts(新)、apps/src-tauri/src/commands/storage_space.rs(新)

验证

本机 2 vCPU / 3.6 GB RAM,构建与测试以单核、低优先级、串行方式运行,均在叠加了 #493 的本分支上执行。

  • cargo fmt --all -- --check,WebSocket 依赖 pin 检查
  • cargo test -p rusqlite:15/15 通过
  • cargo test -p codexmanager-core:lib 484、集成测试 48,全部通过。新增回归覆盖:清空不执行 VACUUM 并恢复 secure_delete;未脱敏内容清空后不残留在数据库和 WAL 文件字节中;分批删除不误删清空后写入的数据,rowid 复用也不误删;时间预算用尽后续删;保留期裁剪重基存活子请求、不复用过期父请求;跨越保留期的预览在删除前即不可见;同一文件的第二个删除立即返回;分批删除未完成时拒绝整理;新库为 INCREMENTAL、老库不自动转换、增量回收与显式转换、每个连接限制 WAL 大小
  • cargo check --workspace --all-targets(包含 service 维护模块单测的类型检查),改动文件无新增 warning
  • node --test --test-concurrency=1:前端 runtime 测试 250/250 通过
  • pnpm -C apps run build:desktop
  • service 维护模块新增的 4 个单测:由 CI 执行并通过(本机链接 service 测试二进制会耗尽内存),见本次 CI
  • cargo test --workspace:未执行,原因同上

风险与影响面

  • 清空后数据库文件不会立即变小:释放的页先被新数据复用,可回收空间较多时由后台逐步归还;老库需要在设置里手动「整理数据库」一次才会启用增量回收。
  • 大库的分批删除可能持续几分钟。这期间被清空的内容已不可读,但删除完成前仍物理存在于数据库文件中(删除时开启 secure_delete 覆盖)。
  • 「整理数据库」会重写整个文件,期间网关写日志可能短暂等待,界面提示在低峰时执行。
  • 远程 SeaORM 存储:清空与裁剪逻辑不变,空间卡片显示不支持。

备注

  • 不包含敏感 token、cookie、API key

Transaction::execute no longer issues an extra SELECT last_insert_rowid()
after every statement. Connection and Transaction now query the rowid on
demand from the exact physical handle that ran the INSERT (the held
transaction handle, or the single pooled handle), falling back to the last
rowid observed from statement results if the lookup fails.

Add shim tests for rowid semantics and run them in CI.
SQLx pools default to a 30 min max lifetime and 10 min idle timeout, which
silently replaced the single pooled handle. That lost connection-scoped
state (last_insert_rowid, busy_timeout, temp_store, journal_size_limit)
and wiped in-memory databases. Disable both so a Connection keeps one
handle for its whole life, like a real sqlite3 handle.
…mental reclaim

Clearing request logs ran `wal_checkpoint(TRUNCATE); VACUUM` after deleting
every payload row inside one transaction. With full payload storage that
rewrote a multi-GB file, needed as much free disk, held the write lock long
enough for gateway writes to hit the 3 s busy timeout, and with
temp_store=MEMORY could hold the transient copy in RAM.

- clear/prune now only make old data unreachable in a short transaction
  (generation bump or retention watermark plus per-table rowid bounds in the
  new request_log_payload_purges table, migration 142) and delete payload
  rows in adaptive batches with secure_delete enabled; blobs are swept with a
  reference check inside each delete transaction
- rows waiting for the purge are invisible to every reader and are never
  chosen as parents of new manifests; work left over after the 10 s budget
  (or after a restart) is continued by a background maintenance thread, and
  errors after the first commit no longer fail the clear
- the maintenance thread also runs retention pruning off the request path,
  retries busy WAL checkpoints and returns free pages with small
  incremental_vacuum steps; new databases use auto_vacuum=INCREMENTAL
- every connection sets journal_size_limit; space accounting uses live pages
  (page_count - freelist_count), never the file size
- admin-only storage/spaceUsage and storage/reclaim RPCs and a settings card
  show used/reclaimable space and offer an explicit, disk-checked one-time
  rebuild for databases created by older versions
When a path did not exist, normalized_components canonicalized its parent
and dropped the last component, so a missing mount point such as
/definitely-not-a-mount compared equal to / and could win the
longest-prefix match. Resolve the deepest existing ancestor instead and
re-append the components below it.
@qxcnm
qxcnm merged commit 86aa010 into qxcnm:main Oct 8, 2026
3 checks passed
@qxcnm

qxcnm commented Oct 8, 2026

Copy link
Copy Markdown
Owner

补充一项合并后的数据安全跟进:复核 #494 的批量清理边界时发现,清理标记使用秒级 now_ts(),删除条件为 rowid <= max_rowid AND created_at <= cleared_at。如果清理提交后旧的最高 rowid 被删除并在同一秒复用,新写入行可能同时满足两个条件,随后被后台 purge 删除。现有 reused_rowid_after_clear_is_not_purged 只用 cleared_at + 5,没有覆盖同秒情况。

建议尽快补一个 created_at == cleared_at 的 rowid 复用回归,并改用清理代次/不可复用的单调标识,或使用能严格区分清理前后的时间/事务边界;修复前不要把该清理条件扩展到更多异步删除路径。

@xcosmosbox

Copy link
Copy Markdown
Contributor Author

补充一项合并后的数据安全跟进:复核 #494 的批量清理边界时发现,清理标记使用秒级 now_ts(),删除条件为 rowid <= max_rowid AND created_at <= cleared_at。如果清理提交后旧的最高 rowid 被删除并在同一秒复用,新写入行可能同时满足两个条件,随后被后台 purge 删除。现有 reused_rowid_after_clear_is_not_purged 只用 cleared_at + 5,没有覆盖同秒情况。

建议尽快补一个 created_at == cleared_at 的 rowid 复用回归,并改用清理代次/不可复用的单调标识,或使用能严格区分清理前后的时间/事务边界;修复前不要把该清理条件扩展到更多异步删除路径。

👌

@xcosmosbox

Copy link
Copy Markdown
Contributor Author

@qxcnm 谢谢指出,同一秒内复用 rowid 被误删的问题确实存在,修复单独提在了 #498:

  1. 分批清理不再用秒级时间区分清空前后,改用清理代次。迁移 143 给四张待清理表和清理标记表各加了一个 generation 列,每次写入在同一条语句里记下当前代次,清空时在标记里记下提升后的代次,标记只删代次更低的行(以及迁移前写入的行),清空之后写入的行不管 rowid 是否复用、是否在同一秒都不会被删。删除和读取可见性用的是同一个条件。
  2. 升级前留下、还没删完的旧标记没有代次,仍按原来的时间条件判断,但只作用于迁移前写入的行。
  3. 补了 created_at == cleared_at 的 rowid 复用回归,四张表都覆盖;把条件临时改回旧的时间条件时这个测试会失败。另外补了旧标记、迁移重跑两个回归。
  4. 没有把这个清理条件扩展到别的异步删除路径。

#498 基于当前 main,CI 已全部通过,和 #495 两种顺序合并都没有冲突。麻烦您方便时审阅。

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.

2 participants