You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(folders): recursive folder delete leaves every object in bucket storage #177
Deleting a folder recursively removes the database rows and leaves every
underlying object in bucket storage. The code says so. There is no reaper, so
"the user deleted it" and "the bytes are gone" come apart, and the orphaned
objects keep appearing in bucket accounting and in any storage-level audit a
user might run.
Source reproduction
On main @ 7a194f1eba4167d54bd46cf84cdbe86e00532319:
server/internal/folder/folder.go:588-591, the doc comment on Delete: recursive=true: subfolders + files are deleted from the DB. S3 cleanup is TODO — for now we only purge the DB rows; orphan blobs will be reaped by a future garbage-collection pass.
The code matches the comment. The recursive branch's only file statement is the DELETE FROM files … at folder.go:634-641, which does not read storage_key out, and there is no store.Delete anywhere in server/internal/folder/. For contrast, file.Delete
(internal/file/file.go:661-679) does best-effort blob removal for a
single file.
No reaper exists. grep -rni "reap\|garbage\|orphan" --include="*.go" internal/
(non-test) returns only comments and unrelated uses; the only object deletions
in the entire server are file.go:111 (failed upload), file.go:675
(single-file delete) and import.go:613 (import rollback cleanup).
This was named and left open, not overlooked in silence: fix(transfer): expose indeterminate import recovery #41 lists
"garbage collection of genuinely abandoned objects" under "Intentionally out of
scope" — but no follow-up issue was ever created, and this repository's
tracker has no other mention of it.
Expected vs actual
Expected: after mem rm -r (or the Web UI delete) succeeds, the user's objects
are no longer retrievable and no longer counted in their storage, consistent with GOAL.md §7 principle 3 — user visibility and control over stored content — and
with the forget/archive controls that already exist for memories.
Actual: the rows go away, the objects do not. A forgotten file's original bytes
remain in the bucket under a path the user can no longer list.
Scope boundary
The delete itself is simple, and I checked the thing that usually makes object
reclaim hard — it is not a problem here:
Object keys are per-row by construction. storageKey
(internal/file/file.go:683-689) formats users/<user_id>/<file_id>/<basename>, and the import path's importedStorageKey (internal/workspacetransfer/import.go:627-641) formats users/<user_id>/imports/<bundle_id>/<file_id>/<basename>. Both embed the row's
own file id, so two live rows cannot point at one object key today, and an
unconditional DELETE of a row's key cannot destroy another row's bytes.
That is exactly what 0013_file_content_identity.sql:2-5 states when it drops uniq_files_user_sha: "The object-storage key remains per-file so deleting one
entry cannot remove another entry's bytes." Dedup (file.go:464-473 + the Deduped: true early return) collapses same-content uploads to one row, so
it does not reintroduce sharing either.
Consequence: no reference counting is needed, and this issue should not be
built as if it were.
What is actually undecided:
There are only three store.Delete call sites in the whole server
(file.go:111, file.go:675, import.go:613) and storage.Store exposes
just Put/Get/Delete/Bucket — no listing. So a swept pass can only
work from database state, not by enumerating the bucket, which means the
"future garbage-collection pass" the TODO promises needs somewhere to record
keys whose delete was never attempted.
The crash window is the real gap, not the happy path: commitFilePut
(file.go:113-119) documents that a rolled-back commit "may leave an orphan",
and a process killed between a folder transaction committing and its blob
deletes landing leaves the same residue.
So: in scope is a reclaim path for folder deletion plus a decision on where
crash residue is recorded. cleanupUploaded
(import.go:605-617) is the existing precedent for "collect keys, delete,
report". Out of scope is the multi-tenant bucket layout, and out of scope is the
memory forgotten payload erasure, which already behaves differently.
Acceptance
After a recursive folder delete and the reclaim path runs, no object that
was reachable only from that folder remains in the bucket; a test asserts it
against a real object store (compose already ships MinIO), not a stub.
DELETE FROM files … RETURNING storage_key (or equivalent) is the source of
the keys, and the deletes happen after the transaction commits — a failed
blob removal must not roll the user's delete back, matching file.Delete's
existing best-effort shape.
The crash window is decided in writing, not left to the TODO: either
residue is recorded somewhere the server can later sweep (and this issue names
where, given that storage.Store has no listing to sweep against), or the
repo explicitly accepts that killed-mid-delete leaves permanent objects and
says so in the operator docs.
A failed object delete is visible: the operator can see which keys remain,
rather than the current silent _ = derr.
docs/DEPLOYMENT.md or the storage docs state the retention behavior for
deleted objects, so the answer is not folklore.
The two TODO comments are removed or replaced by a link to this issue.
Evidence level
E2 — source-level: the TODO is explicit in the code and the absence of a reaper
is a completed search of server/internal/. No object store was exercised; the
claim that bytes survive a folder delete is read from the code, not observed.
Proposed triage
Applied on filing, per docs/maintainers/triage.md and the precedent in #135: type:bug, area:server, severity:s2-medium, evidence:e2-source, status:needs-triage.
Left to a maintainer: priority, and any change to the set below.
The severity is s2-medium: bounded to recursive folder deletion, no functional
breakage, but it is a user-facing deletion that does not delete, and it is a
storage cost the user cannot see.
Summary
Deleting a folder recursively removes the database rows and leaves every
underlying object in bucket storage. The code says so. There is no reaper, so
"the user deleted it" and "the bytes are gone" come apart, and the orphaned
objects keep appearing in bucket accounting and in any storage-level audit a
user might run.
Source reproduction
On
main @ 7a194f1eba4167d54bd46cf84cdbe86e00532319:server/internal/folder/folder.go:588-591, the doc comment onDelete:recursive=true: subfolders + files are deleted from the DB. S3 cleanup is TODO — for now we only purge the DB rows; orphan blobs will be reaped by a future garbage-collection pass.DELETE FROM files …atfolder.go:634-641, which does not readstorage_keyout, and there is nostore.Deleteanywhere inserver/internal/folder/. For contrast,file.Delete(
internal/file/file.go:661-679) does best-effort blob removal for asingle file.
grep -rni "reap\|garbage\|orphan" --include="*.go" internal/(non-test) returns only comments and unrelated uses; the only object deletions
in the entire server are
file.go:111(failed upload),file.go:675(single-file delete) and
import.go:613(import rollback cleanup)."garbage collection of genuinely abandoned objects" under "Intentionally out of
scope" — but no follow-up issue was ever created, and this repository's
tracker has no other mention of it.
Expected vs actual
Expected: after
mem rm -r(or the Web UI delete) succeeds, the user's objectsare no longer retrievable and no longer counted in their storage, consistent with
GOAL.md§7 principle 3 — user visibility and control over stored content — andwith the forget/archive controls that already exist for memories.
Actual: the rows go away, the objects do not. A forgotten file's original bytes
remain in the bucket under a path the user can no longer list.
Scope boundary
The delete itself is simple, and I checked the thing that usually makes object
reclaim hard — it is not a problem here:
storageKey(
internal/file/file.go:683-689) formatsusers/<user_id>/<file_id>/<basename>, and the import path'simportedStorageKey(internal/workspacetransfer/import.go:627-641) formatsusers/<user_id>/imports/<bundle_id>/<file_id>/<basename>. Both embed the row'sown file id, so two live rows cannot point at one object key today, and an
unconditional
DELETEof a row's key cannot destroy another row's bytes.That is exactly what
0013_file_content_identity.sql:2-5states when it dropsuniq_files_user_sha: "The object-storage key remains per-file so deleting oneentry cannot remove another entry's bytes." Dedup (
file.go:464-473+ theDeduped: trueearly return) collapses same-content uploads to one row, soit does not reintroduce sharing either.
built as if it were.
What is actually undecided:
store.Deletecall sites in the whole server(
file.go:111,file.go:675,import.go:613) andstorage.Storeexposesjust
Put/Get/Delete/Bucket— no listing. So a swept pass can onlywork from database state, not by enumerating the bucket, which means the
"future garbage-collection pass" the TODO promises needs somewhere to record
keys whose delete was never attempted.
commitFilePut(
file.go:113-119) documents that a rolled-back commit "may leave an orphan",and a process killed between a folder transaction committing and its blob
deletes landing leaves the same residue.
So: in scope is a reclaim path for folder deletion plus a decision on where
crash residue is recorded.
cleanupUploaded(
import.go:605-617) is the existing precedent for "collect keys, delete,report". Out of scope is the multi-tenant bucket layout, and out of scope is the
memory
forgottenpayload erasure, which already behaves differently.Acceptance
was reachable only from that folder remains in the bucket; a test asserts it
against a real object store (compose already ships MinIO), not a stub.
DELETE FROM files … RETURNING storage_key(or equivalent) is the source ofthe keys, and the deletes happen after the transaction commits — a failed
blob removal must not roll the user's delete back, matching
file.Delete'sexisting best-effort shape.
residue is recorded somewhere the server can later sweep (and this issue names
where, given that
storage.Storehas no listing to sweep against), or therepo explicitly accepts that killed-mid-delete leaves permanent objects and
says so in the operator docs.
rather than the current silent
_ = derr.docs/DEPLOYMENT.mdor the storage docs state the retention behavior fordeleted objects, so the answer is not folklore.
Evidence level
E2 — source-level: the TODO is explicit in the code and the absence of a reaper
is a completed search of
server/internal/. No object store was exercised; theclaim that bytes survive a folder delete is read from the code, not observed.
Proposed triage
Applied on filing, per
docs/maintainers/triage.mdand the precedent in #135:type:bug,area:server,severity:s2-medium,evidence:e2-source,status:needs-triage.Left to a maintainer: priority, and any change to the set below.
The severity is
s2-medium: bounded to recursive folder deletion, no functionalbreakage, but it is a user-facing deletion that does not delete, and it is a
storage cost the user cannot see.
Raised from the data/index audit on 2026-09-08.