Skip to content

fix(folders): clean up blob storage on recursive folder delete (#177) - #210

Closed
xiaocui-big wants to merge 2 commits into
bytefolk:mainfrom
xiaocui-big:fix/folder-delete-blob-cleanup-177
Closed

xiaocui-big wants to merge 2 commits into
bytefolk:mainfrom
xiaocui-big:fix/folder-delete-blob-cleanup-177

Conversation

@xiaocui-big

@xiaocui-big xiaocui-big commented Sep 15, 2026

Copy link
Copy Markdown

Summary

Changes

File Change
server/internal/folder/folder.go Add ObjectStore interface, WithStore/WithLogger options; collect storage keys before delete, clean up blobs after commit
server/cmd/memd/main.go Wire store and logger into folder service
server/internal/folder/delete_integration_test.go Integration tests for blob cleanup and backward compatibility
docs/DEPLOYMENT.md Document object retention behavior and crash window
CHANGELOG.md Add entry under [Unreleased]Fixed

Design decisions

  1. Best-effort cleanup after commit: matches file.Delete's existing shape — a failed blob removal must not roll back the user's folder delete.

  2. No reference counting: object keys are per-row by construction (users/<user_id>/<file_id>/<basename>), so unconditional delete of a row's key cannot destroy another row's bytes (see 0013_file_content_identity.sql:2-5).

  3. Crash window documented, not fixed: if the process is killed between commit and blob delete, the object remains permanently. A reaper would need to record keys whose delete was never attempted, since storage.Store has no listing capability. This is noted in DEPLOYMENT.md as a known gap.

  4. Backward compatible: folder.New(pool) without options still works — DB rows are deleted but blobs are not cleaned up (previous behavior).

Validation Ledger

ID Observable acceptance criterion Command Result
V1 Server build + unit contracts go build ./cmd/memd/... PASS — clean build, no errors
V2 All unit tests pass go test -short ./... PASS — 33 packages ok, 0 failures
V3 Static analysis clean go vet ./... PASS — no issues
V4 Race-free go test -race -short ./... NOT VERIFIED (fork-head PR; CI must run)
V5 Fresh schema + migration + PG semantics go test -run TestRecursiveDelete ./internal/folder with MEM_TEST_DB NOT VERIFIED (requires PostgreSQL + pgvector)
V6 DB concurrency race-free make test-integration-race NOT VERIFIED (requires Docker Compose)

V1–V3 verified locally with Go 1.25.4 (GOPROXY=goproxy.cn). V4–V6 require CI infrastructure not available from this fork-head branch.

Acceptance criteria from #177

  • After recursive folder delete, objects reachable only from that folder are removed from the bucket (test asserts against real object store interface)
  • Storage keys come from DB query before delete; blob deletes happen after transaction commits
  • Crash window is decided in writing: documented in DEPLOYMENT.md as a known gap
  • Failed object delete is visible: logged at WARN level with storage_key
  • DEPLOYMENT.md states retention behavior for deleted objects
  • TODO comments removed (replaced by actual implementation)

Test plan

  • go build ./cmd/memd/... — PASS
  • go test -short ./... — PASS (33 packages)
  • go vet ./... — PASS
  • CI: make test-race (requires branch in upstream repo or fork-head CI support)
  • CI: make test-integration with MEM_TEST_DB pointing to a test database
  • CI: Verify TestRecursiveDeleteCleansUpBlobs passes
  • CI: Verify TestRecursiveDeleteWithoutStore passes (backward compatibility)

Note

This PR is from a fork-head branch. Per repository branch protection, CI cannot run on this PR until it is moved to a branch in the bytefolk/mem repository or the fork-head CI limitation is resolved. The code changes have been verified locally (build, unit tests, vet) but require upstream CI for the full validation ledger.

…olk#177)

Recursive folder delete removed DB rows but left every underlying object
in bucket storage. The code had an explicit TODO acknowledging this.

Changes:
- Add ObjectStore and logger options to folder.Service (WithStore, WithLogger)
- Collect storage keys before deleting files, then delete blobs after
  the transaction commits (best-effort, matching file.Delete's shape)
- Log failed blob deletions at WARN level so operators can see orphans
- Update memd wiring to pass store and logger to folder service
- Add integration tests verifying blob cleanup and backward compatibility
- Document object retention behavior in DEPLOYMENT.md
- Add CHANGELOG entry

The crash window (process killed between commit and blob delete) is
documented as a known gap. A reaper would need to record keys whose
delete was never attempted, since storage.Store has no listing.
@waterbro-8

Copy link
Copy Markdown
Collaborator

这份实现保留下来(#190 已按同一指令关闭)

#177 此前有两条并行实现。#190 已关闭,保留这一份。以下是我实测两份 head 代码后给出的保留理由,以及它合并前还欠什么。

保留理由(三处硬差异)

  1. 构造函数向后兼容New(pool, opts ...Option),不像 fix(folder): compile recursive blob cleanup #190New(pool, store, log) 那样需要同步改所有调用方。
  2. 清理用的 ctxcontext.WithoutCancel(ctx) + 30s 超时。这是决定性的那条 —— fix(folder): compile recursive blob cleanup #190 把请求的 ctx 直接传进 s.store.Delete,请求在提交后取消时清理会静默失效,而失败只写 WARN,现场无痕迹。
  3. 改动面:5 个文件、单个 commit,没有夹带无关改动。fix(folder): compile recursive blob cleanup #190 动了 12 个文件。

#190 里那些设计判断(提交后 best-effort 清理、失败不回滚、崩溃窗口如实记录)这一份都继承了,没有丢。

合并前还需要处理一条:守卫只看了错的那一列

这条不是本 PR 引入的,它来自 main 上既有的代码,所以 #190 也带着它,关闭 #190 并不解决它:

server/internal/folder/folder.gocontainsMemoriesTx 只做 memories.path 的前缀匹配:

AND (m.path = $2 OR left(m.path, length($2) + 1) = $2 || '/')

它从不查 memories.source_file_id,而这一列在 0008_agent_memories.sql:45 明确声明为 uuid REFERENCES files(id) ON DELETE SET NULL。两者不是同一个关系:路径前缀说的是记忆住哪source_file_id 说的是记忆引用了谁

后果是具体的:记忆挂在 /Work/task、引用的是 /Photos/2012 的文件时,DELETE /Work?recursive=true 会把它引用的 blob 物理删除、source_file_id 被 FK 静默置 NULL、记忆行仍是 active、而 source_file_sha256text NOT NULL DEFAULT '',只有格式 CHECK,没有任何回验)继续指着一个已经不存在的对象。本次改动把"删行留 blob"修成了"删行删 blob",在这个路径上却会变成**"删 blob 留记忆"**——比原来更难发现。

建议的处置,二选一,都可落地:

  • 修守卫:把条件扩成同时覆盖引用关系,例如
    OR EXISTS (SELECT 1 FROM memories m JOIN files f ON f.id = m.source_file_id WHERE m.id = m.id AND f.user_id = $1 AND (f.folder_id = $2 OR f.path = $3 OR left(f.path, length($3) + 1) = $3 || '/')),命中时返回 ErrContainsMemories,与本 PR 已有的"先 forget 再删"契约一致;或
  • 明确接受该语义:在 docs/DEPLOYMENT.md 写明"递归删除会连带销毁被其他目录记忆引用的对象",并把 source_file_sha256 与实存对象的失配做成一条可见的运维检查。

另外两条硬约束(与代码质量无关,但决定它能不能合)

#205 落地后我会触发这条 PR 的检查,届时需要真实 CI 结论而不是本地声明。

(以上为评审意见,非批准;本次评论不构成合并投票。)

@waterbro-8 waterbro-8 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review: request changes(只剩一条,很小)

先把话说清楚:这份实现比我昨天关掉的 #190,而且它最关键的那个安全论证我独立验过了,是真的。只剩一条要改。

一、"不需要引用计数"这个前提,是真的

这是整份 PR 的地基——如果不成立,无条件删 key 就是破坏数据。我去 main 上核了:

// server/internal/file/file.go:683-688
func storageKey(userID, fileID uuid.UUID, name string) string {
	clean := gopath.Base(name)
	...
	return fmt.Sprintf("users/%s/%s/%s", userID.String(), fileID.String(), clean)
}

key 内嵌该行自己的 file_id,所以两行不可能共享同一个 key。而 0013_file_content_identity.sql 恰恰是为了允许内容重复才删掉uniq_files_user_sha——在删掉 sha 唯一性的同时把 key 保持为 per-file,正是为了"删一个条目不会带走另一个条目的字节"。docs/DEPLOYMENT.md 新增的那段话与代码一致,不是愿望。

二、做对的地方

  • New(pool, opts ...Option) 向后兼容,不像 #190 那样要同步改所有调用方。
  • context.WithoutCancel(ctx) + 30s 超时——这是关键的一条。请求在提交之后被取消(客户端断开、CLI 被 Ctrl-C)时,#190 直接把请求 ctx 传给 store.Delete,清理会静默失效,而失败只写 WARN,现场不留痕。这里不会。
  • main.go 只加了 3 行,WithStore / WithLogger 注入清晰。
  • 崩溃窗口、无法回收、手工对账方法都写进了 DEPLOYMENT.md,没有把"最终一致"写成"原子"。
  • 顺带一提:file.Deletefile.go:674-678)现在是 _ = derr 直接吞掉错误,你这里改成 WARN 记录,是比现有代码更好的形状。

三、测试在 CI 里真会跑,不是空跑

这点我特意查了:ci.yml:59 设了 MEM_TEST_DB=...mem_testmemory-validation.yml:141 设了 ...mem_integration_test。两个库名都以 _test 结尾,能通过你测试里的保护性断言(t.Fatalf 拒绝非 _test 库)。所以 t.Skip 不会静默吞掉这两个用例。

另外 github.com/PeterGuy326/mem/server/internal/db 这个 import 是对的——main 上的 go.mod 模块路径仍是 github.com/PeterGuy326/mem/server,同包里已有的 workspace_lock_integration_test.go 也是这么写的。我一开始以为组织改名后会是 bytefolk/mem,核过之后确认不是。(顺带:这本身可能是个待办,但不属于本 PR。)

四、要改的那一条 —— 守卫看的是错的那一列

server/internal/folder/folder.go:691containsMemoriesTx 递归分支:

FROM memories AS m
JOIN workspaces AS w ON w.id = m.workspace_id
WHERE w.resource_owner_user_id = $1
  AND m.lifecycle_status IN ('active', 'archived')
  AND (m.path = $2 OR left(m.path, length($2) + 1) = $2 || '/')

它只做 memories.path 的前缀匹配,从不查 memories.source_file_id。而 0008_agent_memories.sql:45 把这一列声明为 uuid REFERENCES files(id) ON DELETE SET NULL。这两者不是同一个关系:path 说的是记忆住在哪source_file_id 说的是记忆引用了谁

所以这种情形守不住:记忆挂在 /Work/task,引用的是 /Photos/2012 的文件。此时 DELETE /Photos?recursive=true

  1. 守卫查不到 —— 记忆的 path 不在 /Photos 下,放行;
  2. 文件行被删,source_file_id 被 FK 静默置 NULL
  3. 本 PR 把 blob 物理删除
  4. 记忆行仍是 active
  5. source_file_sha256text NOT NULL DEFAULT '',只有格式 CHECK,没有任何回验)继续指着一个已经不存在的对象。

为什么这条在本 PR 里才变得要紧:没有本 PR 时,第 3 步是"留下孤儿 blob"——也就是 #177 要修的现象,运维还能靠 source_file_sha256 去桶里捞回来。加了本 PR 之后,同样一次操作会变成不可恢复的销毁。也就是说本 PR 把一个"占空间"的问题,换成了这个路径上的"丢数据"问题。这不是你引入的缺陷(main 上本来就有),但本 PR 是让它第一次产生实质后果的那一步。

改法(推荐):给 containsMemoriesTx 加一个 folderID 参数,把引用关系一起纳入:

OR EXISTS (
  SELECT 1
    FROM memories AS m2
    JOIN workspaces AS w2 ON w2.id = m2.workspace_id
    JOIN files AS f2 ON f2.id = m2.source_file_id
   WHERE w2.resource_owner_user_id = $1
     AND m2.lifecycle_status IN ('active', 'archived')
     AND f2.user_id = $1
     AND (f2.folder_id = $3
          OR f2.path = $2
          OR left(f2.path, length($2) + 1) = $2 || '/')
)

命中时照旧返回 ErrContainsMemories,与已有的"先 forget 再删"契约一致。调用点在 Delete 的递归分支里,src.ID 现成可用。

改法(可接受):如果你希望先把泄漏修掉、守卫单独开一条,那就请在 docs/DEPLOYMENT.md 里把这条语义明确写出来——"递归删除会连带销毁被其他目录记忆引用的对象,且不可恢复",并把 source_file_sha256 与桶内实存对象的失配做成一条可见的运维检查。只加个 TODO 不够,因为本 PR 同时删掉了可恢复性。

改完叫我,我立刻复评放行。

五、非阻塞的两点

  • cleanupBlobs 的 30 秒是整批共享的预算。一个几千文件的目录会中途超时,之后每个 key 都记一条 WARN,日志会被刷屏而分不清是"真的删失败"还是"预算耗尽"。建议按批次续期,或者至少在超时那条 WARN 里说明是预算用尽。
  • collectStorageKeysTxpath 前缀和 folder_id 收 key。如果某行的 pathfolder_id 不同步(移动/重命名后的残留),会少收——方向是安全的(留孤儿,不会误删),和 DELETE FROM files 用的是同一套谓词,所以两者一致。只是记一笔。

(本次为独立评审:我不是本 PR 作者,也不是其 head 的推送者。以上不构成合并投票之外的任何承诺。)

sun-970 pushed a commit to sun-970/mem that referenced this pull request Sep 17, 2026
bytefolk#210)

containsMemoriesTx only checked memories.path prefix, never
memories.source_file_id. Since 0008 declares source_file_id ON DELETE SET
NULL, deleting /Photos would physically destroy blobs referenced by active
memories living at /Work — trading orphan files for irreversible data loss.

Add a source_file_id IN (SELECT id FROM files ...) subquery to the
recursive branch so any active/archived memory pointing at a file inside
the deleted folder tree blocks deletion with ErrContainsMemories.
@waterbro-8

Copy link
Copy Markdown
Collaborator

Follow-up for the remaining review item (source_file_id citation guard) is on organization branch PR #214 rather than this fork head.

#214 includes your blob-cleanup commits plus the recursive-delete block when an out-of-tree memory cites a file in the folder. Prefer that PR for re-review. This thread can stay as the original candidate.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Closing as superseded by #214.

This was the better of the two #177 implementations (kept over #190). The remaining review request still stands: recursive delete must not physically destroy an object still cited by an active/archived memory whose path is outside the folder (memories.source_file_id … ON DELETE SET NULL).

#214 keeps this blob-cleanup shape and adds that citation guard, plus the timeout-budget WARN clarification. Please follow #214 for the rest of #177.

Thank you — the ObjectStore wiring, post-commit cleanup, and per-row key safety argument from this PR are what #214 is carrying forward.

@waterbro-8 waterbro-8 closed this Sep 17, 2026
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.

fix(folders): recursive folder delete leaves every object in bucket storage

2 participants