Conversation
A member of an aggregate which is a collection holds its foreign key at the child, so "on delete cascade" removes it together with its owner. A member which is referenced by the owner (one-to-one) holds the foreign key at the owner instead, and a cascade only ever runs from the referenced row to the referencing one - so deleting the owner leaves the referenced row behind, unreachable. The generated cascade of these 28 relationships therefore says "deleting the item storage deletes the character", which is the opposite of what's meant. The most visible case is an item storage: every deleted character left its inventory behind, and with it every item lying in it. That happens in the normal game path (DeleteCharacterAction), not just in the bot purge, which worked around it by deleting the storages itself. The source generator now emits the delete triggers for these members along with the cascades it already generates, so the invariant is maintained by the same annotation: mark a member of an aggregate and its delete behaviour is correct, whichever side holds the foreign key. Members which reference a shared type are skipped - deleting them would take data away from their other owners. - EfCoreModelGenerator generates AggregateDeleteTriggers.CreateScript, a trigger function plus one trigger per member (26 of 28; Buff. MagicEffectDefinition and Skill.MasterDefinition reference a shared type and need an annotation review first). - A migration applies the script and removes the item storages which were orphaned before the triggers existed. - A test fails when a model change makes the generated script differ from what the migrations apply, so a new member can't be forgotten. - BotGenerator no longer deletes the storages itself: the trigger has already removed the rows when EF saves, which would fail the save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN
#927 moved the deletion of a single bot account into TryDeleteBotAccountAsync. Kept that structure and removed the manual deletion of the item storages there instead: the delete triggers remove them now, and EF's own delete of the storage would find the row already gone and fail the save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN
Deploying openmudocs with
|
| Latest commit: |
80ba05c
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f8737952.openmudocs.pages.dev |
| Branch Preview URL: | https://claude-pr927-cascade-delete.openmudocs.pages.dev |
sven-n
left a comment
There was a problem hiding this comment.
Thanks for tackling the orphaned item storages. I think the triggers conflict with the existing aggregate-delete mechanism, though, and would break ordinary deletes. Details are in the inline comments; here is the root cause.
EntityFrameworkContextBase.DeleteAsync → ForEachAggregate already calls Context.Remove on single-object [MemberOfAggregate] members (Inventory, Vault, MerchantStore, …). With the new AFTER DELETE triggers, the member row is gone by the time EF issues its own DELETE for it. That DELETE hits 0 rows and throws DbUpdateConcurrencyException. Affected paths: deleting a character in-game, the bot purge, and admin-panel deletes of config objects with one-to-one members.
The actual leak was narrower: ForEachAggregate isn't recursive, so deleting an account removes its characters but not the characters' inventories. Making ForEachAggregate recurse into the members it removes would fix that in one place, without triggers. If the triggers stay, ForEachAggregate has to stop removing non-collection members, or every DeleteAsync caller breaks.
This analysis comes from reading the code and EF's delete ordering. I haven't run it against Postgres. An integration test that deletes a character and an account with a vault would confirm it either way.
Generated by Claude Code
Replaces the delete triggers of the previous commits: they conflicted with EntityFrameworkContextBase.DeleteAsync, which already removes single-object [MemberOfAggregate] members through ForEachAggregate. The member row was gone by the time EF issued its own DELETE for it, so the DELETE hit 0 rows and threw a DbUpdateConcurrencyException - breaking the in-game character deletion, the bot purge and the admin-panel deletes of config objects. The triggers were also denied on the data schema when they ran from the config context, which only has SELECT there. The actual leak was narrower: ForEachAggregate was not recursive, so deleting an account removed its characters but not their inventories - and an inventory is referenced BY its character, so no delete cascade of the database reaches it. ForEachAggregate now walks the whole aggregate. Members are tracked by reference while it does, because the entities compare equal by their id and a graph which references itself would otherwise recurse forever. DetachInternal no longer recurses itself, so the graph is walked once. The migration is reduced to what a cascade or a traversal can never do: it removes the rows which are already orphaned. Item storages (inventories and vaults) and the sender appearances of deleted letters are covered; config objects orphaned through a two-level path (e.g. a map's battle zone rectangles) are not - see the pull request description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN
…e-delete-review-dk6qyh
The recursion walks one level deeper than before, and [MemberOfAggregate] is not a clean "owned exclusively" list: Buff.MagicEffectDefinition marks a shared row. The effect definition lives in GameConfiguration.MagicEffects and is referenced by Skill.MagicEffectDef and ItemDefinition.ConsumeEffect as well, so deleting a monster definition would have taken it away from a skill and an item. The objects a collection of the GameConfiguration holds are roots of their own. When deleting, the traversal now stops when it reaches one of them through the aggregate of a member. The direct members are untouched by this - only the recursion is new, so nothing which was deleted before is kept now. Detaching keeps walking everything, because a stale configuration has to be detached whole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN
|
Closed the two points I'd left hanging on a question rather than waiting for an answer. The shared configuration objects are now guarded ( The "through a member" part matters: direct members are treated exactly as before, so nothing that was deleted before this PR is kept now. Deleting a The config-side orphan cleanup stays out, deliberately rather than pending a decision. The migration sweeps CI was green on the previous head; this push re-runs it. Generated by Claude Code |
Fixes #933.
EntityFrameworkContextBase.DeleteAsyncremoves the members of an aggregate throughForEachAggregate, which was not recursive. So deleting an account removed its characters, but not the characters' inventories — and an inventory is referenced BY its character, so its foreign key sits at the character and no delete cascade of the database ever reaches it. The storage row, and every item lying in it, stayed behind as unreachable rows.BotGenerator.DeleteAllBotsAsyncworked around exactly this by deleting the storages itself.ForEachAggregatenow walks the whole aggregate instead of one level.What's in here
EntityFrameworkContextBase.ForEachAggregaterecurses into the members it hands to the action. Members are tracked by reference while it walks: the entities compare equal by their id, so aHashSet<object>on the default comparer would confuse two members sharing the default id, and a graph which references itself would recurse forever.DetachInternalno longer recurses itself — it passed an action which called back intoForEachAggregate— so the graph is walked once.[MemberOfAggregate]is not a clean "owned exclusively" list:Buff.MagicEffectDefinitionmarks a row which lives inGameConfiguration.MagicEffectsand is referenced bySkill.MagicEffectDefandItemDefinition.ConsumeEffecttoo. Walking one level deeper would have taken it away from a skill and an item when a monster definition is deleted. So when deleting, the traversal stops when it reaches an object which a collection of theGameConfigurationholds — those are roots of their own — through the aggregate of a member. Direct members are untouched by this rule, so nothing which was deleted before this PR is kept now: only the new recursion is constrained. Detaching still walks everything, because a stale configuration has to be detached whole.BotGeneratorno longer deletes the storages by hand; the recursion covers them. It keeps the full-graph reload, sinceForEachAggregatecan only walk what is loaded.What changed since the first version
The first version added
AFTER DELETEtriggers. The review on this PR showed that they conflict withForEachAggregate, which already removes single-object[MemberOfAggregate]members: the trigger removed the row first, then EF's ownDELETEhit 0 rows and threw aDbUpdateConcurrencyException— breaking the in-game character deletion, the bot purge and admin-panel deletes of config objects. The cross-schema triggers would also have been denied, since the config role holds onlyGRANT SELECTondata."ItemStorage"anddata."Item". All of that is reverted; the source generator is untouched again.That review also corrected the premise I opened this with:
DeleteCharacterActiondid not leak the inventory, becauseForEachAggregateremoved it. Only the account → characters → inventory path leaked. #933 is updated accordingly.Limitations
LetterBodyis the only table referencing it). Config-side depth-2 orphans are left — a map'sBattleZone→Ground/LeftGoal/RightGoalrectangles, a monster'sItemCraftings→SimpleCraftingSettings, and so on. Enumerating the referencing columns for those by hand and getting one wrong would delete live configuration, which is worse than a few unreachable rows. Easy to add as a follow-up with a second pair of eyes on the list.Buff.MagicEffectDefinitionandSkill.MasterDefinitionmark shared rows. The guard above keeps the recursion away from them, but dropping the annotations is the real fix — it changes the model (the generated cascade goes away andJsonQueryBuilderwould emit a$refinstead of inlining the object), so it wants its own migration. Noted in Deleting an account orphans the item storages of its characters #933.Testing
Not run here: this environment has no .NET SDK and no PostgreSQL, so nothing was built or executed locally, and there is no new automated test — an integration test for this needs a database, so it could only be added in the
[Ignore]d style ofBackupServiceEfCoreTestsand would give CI no signal. CI is green on the branch (Azure, GitHub Actions and Codacy), which covers compilation and the existing suite.What would actually settle it, against a real database: delete a character, and an account with a vault and characters, and confirm
data."ItemStorage"is empty afterwards and noDbUpdateConcurrencyExceptionis thrown. Then delete aMonsterDefinitionwhich has buffs and confirm itsMagicEffectDefinitionrows survive. Happy to write that up as an[Ignore]d test if you want it in the repo.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q9QAbEmp9rhnSWqZvkVaeN