mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-08-03 14:03:02 +08:00
### What this PR does
Before this PR:
- Deleting a topic/message/painting FK-cascades its `*_file_ref` rows,
but the `file_entry` row and the internal blob survive forever: the DB
sweep only reports zero-ref entries and the FS sweep only unlinks files
with no DB row. Every chat attachment, AI-generated image, and painting
file leaked permanently.
- Entries that never acquire a ref (crashed sends, abandoned transient
inputs) leaked the same way, papered over by an ad-hoc `permanentDelete`
in `imageGenerationJobHandler`.
After this PR:
- `file_entry` gains a `cleanup_policy` intent column (`'manual'`
default | `'delete_when_unreferenced'`), **required at every TS creation
surface** (DB default `'manual'` is the safe backstop: a forgotten
assignment leaks recoverably instead of deleting unrecoverably).
- Business creation paths are classified: chat attachments, AI-generated
images, painting inputs/outputs, and transient image-job inputs are
`delete_when_unreferenced`; Files-page uploads (add-to-library) and the
user avatar are `manual`; provider / mini-app logos are
`delete_when_unreferenced` (ref-backed single-file slots). `copy`
inherits the source policy; `ensureExternalEntry` reuse may upgrade
auto→manual but never downgrades. AiService is infrastructure — it
receives the policy from the calling business feature (required field on
the image request / wire payload / job payload) rather than hardcoding
it.
- A scan-based cleanup pass (`FileManager.runEntryCleanup`,
`internal/entryCleanup.ts`) reclaims auto-policy entries with zero
persistent refs, past a 1h grace window: registry-driven anti-join
discovery (`persistentRefAbsenceConditions` generated from
`persistentFileRefTablesBySourceType`, with a coverage test),
per-candidate synchronous `withWriteTx` re-verification, and post-commit
reuse of `permanentDelete`'s cleanup (external entries are DB-row-only —
the user's file is never touched).
- `job_file_ref` registers each async image-generation job's input
images / edit mask as a persistent ref holder, so a non-terminal job's
inputs are not reclaimed mid-flight (their ids live only in `job.input`
JSON, invisible to the anti-join); the ref cascades away with the job
row.
- **The mechanism is fully silent — no user surface.** Triggers are an
ungated init pass and a 30-min interval idle-gated via
`PowerService.getSystemIdleTime()` (≥60s idle, 2h reliability floor).
There is **no per-delete nudge and no DB trigger** (spec §5.5: refs die
by FK cascade, so a nudge has no chokepoint), **no volume-based safety
abort** (spec §5.3), and **no policy-flip endpoint / pin-unpin UI /
manual "clean up now" action** — `cleanup_policy` is set at creation
(and by the migrators) and only ever observed, never user-edited.
- v1 migrators classify by reference state: files referenced by migrated
chats/paintings → `delete_when_unreferenced`; zero-ref survivors stay
`manual` (first-scan mass deletion of v1 libraries is impossible).
- Binding contract docs updated: `architecture.md` (§6 lifecycle +
startup timeline) and `file-manager-architecture.md` (§7) now
distinguish the automatic entry-cleanup reaper from the on-demand orphan
`runSweep`; new spec `docs/references/file/file-entry-cleanup.md` (incl.
rejected outbox-queue / trigger designs with rationale, and the
delayed-materialization rule that removes the draft window for painting
inputs entirely); plus a breaking-changes entry.
Fixes #
### Why we need it and why it was done in this way
File storage grew unboundedly for every user; the file module cannot
infer retention intent from ref count alone (a zero-ref library file and
a zero-ref orphaned attachment look identical), so intent is stored as
data (`cleanup_policy`).
The following tradeoffs were made:
- Scan-based derived-state discovery instead of an event log: idempotent
and crash-safe by construction, covers the never-referenced leak class,
and — since SQLite triggers cannot wake JS — has identical reclamation
latency to any queue-based design at half the code.
- Grace window (1h on `created_at`) protects the
entry-created-before-refs-written send window without per-event
bookkeeping.
- No volume-based safety abort: an earlier revision guarded against a
classification bug mass-converting `manual` files into candidates, but
it false-positived on the *primary legitimate use case* (a user deleting
many chats/paintings, whose attachments then genuinely should be
reclaimed). Since there is **no runtime `manual →
delete_when_unreferenced` path at all** (§4.2 — a file becomes auto only
by being created auto or by the migrators' one-time flip), the nightmare
it guarded is structurally impossible, so the abort was net-negative and
removed (§5.3).
- The mechanism is deliberately silent: a file quietly disappearing
after its owning chat/painting is deleted is the intended lifecycle (the
user's original is untouched — chat attachments are copies). No
pin/unpin, no cleanup badge, no manual trigger.
The following alternatives were rejected (full rationale in spec §10 /
§11):
- Outbox queue + `AFTER DELETE` triggers on ref tables, and a
trigger-as-dirty-flag variant.
- Per-business `onSourceDeleted` hooks and global `ref_count = 0 ⇒
delete` (ownership inversion / cannot express retention intent).
- A per-delete-flow `scheduleCleanup()` nudge (shipped in an earlier
revision of this branch) — removed; refs die by FK cascade so it had no
chokepoint (spec §5.5).
### Breaking changes
Deleting a chat/topic/painting now reclaims its exclusively-owned files
(row + internal blob) once they have no other references and pass the
grace window; the Files page no longer accumulates every historical
upload forever. The mechanism is **silent and automatic — there is no
user-facing control** (no pin/unpin, no "clean up now"). Files created
outside chat/painting flows (Files-page uploads, avatar) are kept;
nothing irreplaceable is lost — chat attachments are Cherry's own
copies, the user's original file is never touched. v1-migrated files
keep today's behavior unless they were referenced by a migrated
chat/painting. Entry:
`v2-refactor-temp/docs/breaking-changes/2026-07-04-automatic-file-cleanup-on-deletion.md`.
### Special notes for your reviewer
- **Core commits (base→head):** `feat(data)` `cleanup_policy` column +
`job_file_ref` schema → `feat(file)` scan-based cleanup pass →
`feat(ai,migration)` async image-job input-ref registration +
reference-state `cleanup_policy` back-fill → `feat(renderer)`
chat/painting attachments created auto-reclaimable → `docs(file)` design
spec + contract-doc updates. Review rounds added fix/refactor commits on
top; the two worth calling out by name are below.
- **Adjacent fix, not required by GC — `imageGenerationJobHandler`
`recovery: 'retry'` → `'abandon'`** (commit `fix(image-generation-job):
abandon instead of resuming jobs no one can consume`), which also drops
the persisted-`taskId` resume branch and its `ctx.patchMetadata({ taskId
})`. GC correctness does **not** depend on this: even under `'retry'`
the inputs stay pinned by `job_file_ref` and the output being
zero-referenced and reclaimed is the intended design. The job was built
restart-durable but its **delivery** never was —
`ImageGenerationJobOutput` has exactly one read point in the repo
(`AiService.ts`, immediately after `await handle.finished`), so a
resumed run re-paid the vendor and delivered into a dead in-memory
promise while the user still watched a spinner that would never resolve.
`'abandon'` makes that honest. It surfaced *because* GC made the wasted
output visible, so it is fixed here rather than left as a known-dead
path; no breaking-changes entry, since no user-observable outcome
changes (only a redundant charge disappears).
- **Painting draft inputs materialize at generate time, not during the
draft** (`refactor(paintings)` + `fix(paintings)` commits). An earlier
revision of this branch held them with a `temp_session` ref for the
draft window; that whole subsystem had zero production callers and was
removed instead. The composer's attachments become `FileEntry`s inside
the submit path, after the precondition guard passes — so a rejected
request creates nothing to reclaim.
- **Migration `0026_*.sql`** is a drizzle table-recreate whose emitted
SQL needed two hand-corrections (drizzle-kit codegen bugs: new column in
the INSERT/SELECT list; functional-index quoting). `pnpm
db:migrations:generate` is a no-op afterwards, so CI's generate-and-diff
drift check passes. It has been regenerated (never renumbered) on each
upstream sync that claimed the index first. Dev migrations remain
throwaway per repo policy.
- **Upgrade compatibility (updated — v2 now has shipped betas,
`v2.0.0-beta.1` / `.2`):** the schema change itself is safe for existing
v2 installs — the new column's DB default is `'manual'`, so every
pre-existing row is pinned and nothing a beta tester already has can be
reclaimed. What that also means is the honest limitation: a tester who
ran the v1→v2 migration **before** this PR keeps all their historical
entries at `manual`, so GC never reclaims those files. Neither
gap-filler was added: no legacy-payload normalization for in-flight
image jobs, and no one-shot reference-state back-fill for
already-migrated DBs. Both are deliberate — a back-fill would have to
re-derive reference state over an arbitrary existing DB, which is
exactly the mass-reclassification the design forbids at runtime (§4.2).
Fresh v1→v2 migrations run `markEntriesAutoCleanup` and classify
correctly. If maintainers want existing beta DBs to benefit, that is a
follow-up migration to design deliberately, not something to smuggle in
here.
- Silent design: no pin/drain UI, no policy-flip endpoint (spec §4.2
records that there is no runtime `manual → delete_when_unreferenced`
transition, which is what makes the removed safety abort unnecessary).
### Checklist
- [x] Branch: This PR targets the correct branch — `main` for active
development, `v1` for v1 maintenance fixes
- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [x] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others
### Release note
```release-note
Files exclusively owned by deleted chats, topics, or paintings are now automatically cleaned up after a grace period, instead of accumulating forever. Files you upload from the Files page are kept permanently; v1-migrated files are preserved unless they belonged to migrated chats or paintings. action required: none.
```
---------
Signed-off-by: eurfelux <eurfelux@gmail.com>
Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: SuYao <sy20010504@gmail.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>