fix(backend): cascade every subcollection when a conversation is deleted#10597
Merged
kodjima33 merged 2 commits intoJul 26, 2026
Merged
Conversation
delete_conversation() purged only the `photos` subcollection before deleting the document. Firestore does not cascade, and a conversation owns more children than photos: the per-provider post-processing transcripts (deepgram_streaming / soniox_streaming / speechmatics_streaming / fal_whisperx, which store verbatim TranscriptSegment text), the Hume emotion predictions, and the analytics_markers marker added by the memory-extraction telemetry. Everything but photos therefore survived a user deleting a conversation — and survived it permanently: the account-deletion wipe walks *existing* documents (`limit().stream()`), and a deleted conversation is not one, so its orphaned children are unreachable by both the delete endpoint and "delete my account". Enumerate the children live (`conversation_ref.collections()`) and purge each one recursively, so any subcollection added later is covered by construction. The recursive purge that account deletion already used moves to database/_client as `delete_collection_recursive(ref, client=...)` and is now shared by both callers instead of being duplicated. ## Verification - backend/tests/unit/test_delete_conversation_cascade.py (new, 3 tests): 2 of 3 fail against the pre-fix delete_conversation (the transcript segments and the analytics marker survive; nested children are left dangling) and all 3 pass on the fix. - Full backend unit suite via `backend/test.sh` (740 files, per-file processes): exit 0. - Adjacent suites re-run individually: test_account_deletion.py (34), test_delete_account_purge_storage.py (5), test_conversation_recordings_purge.py, test_merge_conversations_canonical_delete.py, test_listen_fallback_removal.py, test_dev_api_lock_bypass.py, test_developer_from_segments_idempotency.py, test_conversation_memories_telemetry.py (18) — all pass. Failure-Class: none
Six unit modules replace database._client with a hand-built stub that enumerates exactly what production imports from it. Moving the recursive subcollection purge into that module added a name none of them carried, so importing database.users raised ImportError at collection time.
kodjima33
force-pushed
the
watchdog/delete-conversation-subcollection-cascade
branch
from
July 26, 2026 12:18
0c52aee to
451e832
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug
Deleting a conversation left most of its child data in Firestore permanently.
database.conversations.delete_conversation()purged only thephotossubcollection and then deleted the document. Firestore does not cascade, and a conversation owns more children than photos:deepgram_streaming/soniox_streaming/speechmatics_streaming/fal_whisperx— the per-provider post-processing transcripts written bystore_model_segments_result, i.e. verbatimTranscriptSegmenttextstore_model_emotion_predictions_resultanalytics_markers/conversation_memories_extracted— the marker added five hours ago by feat(desktop): instrument macOS memory activation and extraction outcomes #10543 (try_claim_conversation_memory_analytics), so every conversation with extracted memories now grows oneEverything but photos survived
DELETE /v1/conversations/{id}— and survived it permanently.users.delete_user_data()walks each collection withlimit().stream(), which returns only existing documents; a deleted conversation is not one, so the wipe never descends into the orphans it left behind. Neither the delete endpoint nor "delete my account" can reach that data afterwards.Fix
delete_conversation()now enumerates the conversation's children live (conversation_ref.collections()) and purges each recursively before deleting the document, so a subcollection added later is covered by construction rather than by remembering to add another special case.The recursive purge account deletion already used moves to
database/_client.pyasdelete_collection_recursive(ref, *, client)and is shared by both callers instead of being duplicated.delete_conversation_photos()is unchanged — its other callers (lifecycle.py,merge_conversations.py) still use it.What I tested
backend/tests/unit/test_delete_conversation_cascade.py(3 tests) — drives productiondelete_conversationagainst an in-memory Firestore fake. 2 of 3 fail on the pre-fix code (the transcript segments and the analytics marker survive; a nested child is left dangling) and all 3 pass on the fix.backend/test.sh(740 files, one process per file): exit 0.test_account_deletion.py(34),test_delete_account_purge_storage.py(5),test_conversation_recordings_purge.py,test_merge_conversations_canonical_delete.py,test_listen_fallback_removal.py,test_dev_api_lock_bypass.py,test_developer_from_segments_idempotency.py,test_conversation_memories_telemetry.py(18) — all pass.make preflightgreen.Not exercised against live Firestore (no prod write from a watchdog run); the behaviour is proven through the production code path with the storage layer faked.
Known follow-up (not in this PR)
Conversations deleted before this lands still have orphaned children, and account deletion cannot see them because its walk is query-based. Reaching those needs
list_documents()(which does return references to non-existent parents) in the account-deletion walk — a change to the irreversible wipe path that deserves its own review.Failure-Class: none
🤖 automated by the hourly watchdog — tested and merged