Skip to content

[Bug]: doc gc deletes AFFiNE's own internal docs (db$…$docProperties/folders, userdata$…$settings) — they are never workspace pages #15526

Description

@MdeRhoter

Disclosure: this bug was found and this report was written by Claude Code (Anthropic's CLI coding agent), while diagnosing a real production failure on my self-hosted instance. I am submitting it after reviewing it. I have deliberately left the "not generated by AI" checkbox unchecked rather than tick it inaccurately. Every claim below is backed by rows from my own database and by the 0.27.4 source; please judge it on that evidence.

What happened?

The doc gc / document-cleanup feature deletes AFFiNE's own internal workspace docs, because they are never listed as pages in the workspace root doc and are therefore treated as orphans.

On 2026-08-24 at 04:00:00–04:00:02 UTC, the first-ever document_cleanup_execute sweep on my instance deleted 19 docs across all 5 workspaces. 17 of the 19 were internal docs:

  • db$<ws>$docProperties
  • db$<ws>$folders
  • db$<ws>$explorerIcon
  • db$<ws>$docCustomPropertyInfo
  • userdata$<user>$<ws>$settings
  • userdata$<user>$<ws>$favorite

(The other 2 were genuinely orphaned page docs — the feature's legitimate target.)

User-visible consequences:

  1. Sidebar folder trees were lost in 4 workspaces (db$<ws>$folders deleted).
  2. Journal template setting was lost (userdata$…$settings holds templateDoc.value.journalTemplateId).
  3. Doc properties were lost in 4 workspaces.
  4. snapshot_histories had 0 rows for the deleted docs, so there is no version history to restore from.

Expected: internal/reserved docs (db$…, userdata$…) should never be cleanup candidates. They are storage AFFiNE itself creates and depends on, and by design they never appear in the root doc's page list.

Root cause (0.27.4 source)

In packages/backend/native/src/runtime/storage_runtime/document_cleanup.rs:

load_stored_doc_activity() scans every stored guid, sparing only the workspace root:

SELECT guid AS doc_id, updated_at FROM snapshots WHERE workspace_id = $1
UNION ALL SELECT guid, created_at FROM updates WHERE workspace_id = $1
UNION ALL SELECT guid, timestamp  FROM snapshot_histories WHERE workspace_id = $1
WHERE doc_id <> $1

Liveness is then decided purely by page membership in the root doc:

fn root_contains(root: CurrentDoc, doc_id: &str) -> RuntimeResult<bool> {
  let projection = affine_doc_loader::project_workspace_root(root.blob, true)...;
  Ok(projection.doc_ids.iter().any(|id| id == doc_id))
}

reconcile_workspace() marks everything stored-but-not-a-live-page, and execute_document_cleanup_candidates deletes it after gracePeriodDays (default 30).

Internal docs are never in projection.doc_ids, so they are marked on every nightly run. Grepping that file for userdata, db$, prefix, starts_with, is_internal, exclude, reserved returns no matches — there is no allowlist or prefix filter at all.

Why it fired when it did

document_cleanup (mark_only) began running on my instance 2026-07-25 — consistent with #15282 (feat(server): impl doc gc, merged 2026-07-19). 07-25 + 30 days grace = 2026-08-24, the date of the first execute sweep. So instances that adopted doc gc around then are reaching the deletion boundary now.

It recurs

missing_since only resets when the doc sees activity, and internal docs are re-marked nightly. So any internal doc that goes 30 days without a write is deleted again. After I recreated a folder, its clock simply restarted.

The client does not heal it

After db$<ws>$folders was deleted, my browser tab was still connected, so it pushed only incremental ops against a parent that no longer existed server-side. Decoding the resulting snapshot shows the folder's name as a live but orphaned item with an unresolvable parent:

4522778557396753:0-6  parent=iKaPhGpcOnekUzNW4gyfo  ...  $$DELETED=true
2985492552941628:0    parent=-  del=false  ["Network Redesign_"]   <- orphan, never materialises

A page reload did not re-create the missing parent node. Net effect: the folder still renders from browser-local state, but the server copy is permanently unusable — so this is silent, and users will only notice on a new browser or device.

Suggested fix

Exclude reserved doc-id prefixes (db$, userdata$) from the candidate scan in load_stored_doc_activity() / reconcile_workspace(), or make the live set include workspace-scoped internal docs rather than pages only.

Not fixed on canary

I diffed document_cleanup.rs between v0.27.4 and canary (2026-08-25): the changes are refactoring (function signatures, merge_current_doc arity, doc_blob_ref_projections added to the delete list). The same grep for internal-doc filtering still returns nothing, so v0.27.5-beta.0 does not address this.

No way to disable or tune it

In packages/backend/server/src/core/storage/blob-job.ts, gracePeriodDays = 30 is a hardcoded handler default and the cron enqueues with an empty payload:

@Cron(CronExpression.EVERY_DAY_AT_4AM)
 queue.add('backendRuntime.executeDocumentCleanupCandidates', {})

async executeDocumentCleanupCandidates({ workspaceId, gracePeriodDays = 30, limit = 100 })

There is no config binding, and nothing in app_configs for it — so self-hosters cannot opt out while waiting for a fix.

Environment

  • Distribution version: Linux (self-hosted, Docker, ghcr.io/toeverything/affine:0.27.4)
  • Self-hosting: Yes
  • Self-hosting version: 0.27.4
  • Database: external PostgreSQL

Relevant log output

storage_reconciliation_runs — the first document_cleanup_execute rows ever recorded on this instance:

started_at                 kind                      workspace_id  doc_id
2026-08-24T04:00:02.514Z   document_cleanup_execute   fe07bcd8…     userdata$ae5e65c2…$fe07bcd8…$settings
2026-08-24T04:00:02.475Z   document_cleanup_execute   fe07bcd8…     db$fe07bcd8…$folders
2026-08-24T04:00:02.348Z   document_cleanup_execute   2cf4d0c0…     userdata$ae5e65c2…$2cf4d0c0…$favorite
2026-08-24T04:00:02.268Z   document_cleanup_execute   2cf4d0c0…     db$2cf4d0c0…$folders
2026-08-24T04:00:02.169Z   document_cleanup_execute   2cf4d0c0…     db$2cf4d0c0…$explorerIcon
2026-08-24T04:00:02.082Z   document_cleanup_execute   2cf4d0c0…     db$2cf4d0c0…$docProperties
2026-08-24T04:00:01.715Z   document_cleanup_execute   edecd35c…     userdata$ae5e65c2…$edecd35c…$settings
2026-08-24T04:00:01.391Z   document_cleanup_execute   edecd35c…     db$edecd35c…$docProperties
2026-08-24T04:00:00.702Z   document_cleanup_execute   6bc3499a…     db$6bc3499a…$docCustomPropertyInfo

Row counts for the deleted settings doc — note snapshot_histories: 0, i.e. unrecoverable:

{"snapshots":1,"doc_grants":1,"updates":0,"snapshot_histories":0,"workspace_pages":0,
 "doc_access_policies":0,"doc_blob_refs":0,"comments":0,"replies":0}

Aggregate confirming this was the first execute sweep, after a month of mark-only runs:

kind                      mode        n     first                     last
document_cleanup          mark_only   155   2026-07-25T02:00:00.247Z  2026-08-24T02:00:06.499Z
document_cleanup_execute  execute      19   2026-08-24T04:00:00.201Z  2026-08-24T04:00:02.514Z

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

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions