Skip to content

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

Description

@waterbro-8

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 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:

  1. 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.
  2. 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.

Raised from the data/index audit on 2026-09-08.

No activity

Activity on this issue will appear here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:serverGo API, CLI, MCP, storage, or server runtimeevidence:e2-sourceSource or log evidence identifies the likely causeseverity:s2-mediumMedium impact with a practical workaroundstatus:needs-triageAwaiting maintainer classificationtype:bugSomething is broken or behaves incorrectly

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions