ENG-3298: Fix DetachedInstanceError on Comment.delete() replies access#7906
ENG-3298: Fix DetachedInstanceError on Comment.delete() replies access#7906
Conversation
The new `replies` relationship added in #7864 used default lazy loading, which raises DetachedInstanceError when Comment.delete() is called on an instance not bound to a session (e.g. during test fixture teardown in fidesplus). Two fixes: - Re-fetch the comment from the DB at the top of delete() so all subsequent relationship access (replies, references) works regardless of the caller's session state. - Set `lazy="selectin"` on the replies relationship (matching the user relationship) so replies are eagerly loaded and never trigger lazy loading on a detached instance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
There was a problem hiding this comment.
Code Review — PR #7906: Fix DetachedInstanceError on Comment.delete() replies access
Scope: src/fides/api/models/comment.py — two changes: (1) lazy="selectin" added to the replies relationship, (2) delete() re-fetches the comment from DB before relationship access.
Critical
lazy="selectin" on replies is a read-path regression (see inline comment on line 124).
The self-referential selectin fires an additional SELECT for every Comment loaded anywhere in the app, and recursively for all their replies — an unbounded N+1 per nesting level on every list-comments request. The delete() re-fetch added below is already sufficient to fix the DetachedInstanceError without this change. The selectin is both redundant for the fix and harmful to general read performance.
Recommendation: revert lazy="selectin" from the replies relationship. Keep only the re-fetch change in delete().
Suggestions
- Unconditional re-fetch tax (line 175): The re-fetch fires on every
delete()call even whenselfis already session-bound. Consider detecting detachment withsa_inspect(self).detachedand only re-fetching when needed (see inline). Minor but relevant if deletions run in bulk. - Silent swallow on double-delete (line 177): The early
returnwhencomment is Noneis correct, but adding alogger.warningwould help surface stale-reference bugs rather than silently no-oping. - Unchecked test plan item: The PR description has
- [ ] Fidesplus comment tests pass with this fix installedunchecked. Given thatComment.deletehas consumers in the enterprise extension, those tests should pass before merging.
What's good
- The re-fetch pattern is the right approach to solving
DetachedInstanceErrorat the call site rather than requiring callers to manage session state. - The
comment is Noneguard handles the already-deleted edge case cleanly. - Switching all
self.*accesses tocomment.*after the re-fetch is consistent and correct.
🔬 Codegraph: connected (46363 nodes)
💡 Write /code-review in a comment to re-run this review.
Codecov Report❌ Patch coverage is
❌ Your patch status has failed because the patch coverage (62.50%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #7906 +/- ##
==========================================
- Coverage 85.07% 85.06% -0.01%
==========================================
Files 629 629
Lines 40850 40855 +5
Branches 4747 4748 +1
==========================================
+ Hits 34752 34754 +2
- Misses 5027 5029 +2
- Partials 1071 1072 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
- Revert `lazy="selectin"` on replies — self-referential selectin causes unbounded recursive loading on every read path - Add logger.debug on already-deleted early return for diagnostics Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
/code-review |
There was a problem hiding this comment.
Code Review: ENG-3298 — Fix DetachedInstanceError on Comment.delete()
The fix is sound and addresses the root cause cleanly. The re-fetch pattern is a well-understood workaround for detached-instance errors, and the early-return guard correctly handles the already-deleted case. A few observations:
Suggestion
Unnecessary DB round-trip on attached instances (line 175)
The unconditional re-fetch hits the DB on every Comment.delete() call, including the common path where self is already bound to the session. Using sa_inspect(self).detached to gate the re-fetch would avoid the overhead for non-detached callers. See inline comment for a code sketch. Not a blocker, but worth considering if this method is called at volume.
Clarity
Two complementary guards serve different scenarios (lines 176–178 and 239)
The None check after re-fetch guards against already-committed deletions; comment not in db.deleted guards against same-transaction in-flight deletions (e.g., a reply that also has its own CommentReference row and appears in both the BFS and the top-level query). These are not redundant — a note linking the two would help future readers.
Lazy-load strategy on replies is undocumented (line 182)
The default lazy="select" on the replies relationship is a deliberate choice (avoiding the recursive-loading hazard of selectin on self-referential relationships), but it isn't noted in the code. The PR description's bullet "replies relationship now uses lazy=\"selectin\"" does not match the actual code — replies retains the default. A short comment in the relationship definition explaining why selectin was not used would prevent future confusion.
Minor
No test covers the comment is None early-return path added in this PR (a comment deleted before delete() is called). A single two-call test would verify the guard and that the debug log fires.
Overall the change is focused and correct. The inline comments are suggestions/clarifications rather than blockers.
🔬 Codegraph: unavailable
💡 Write /code-review in a comment to re-run this review.
| # Re-fetch from the DB to guarantee a session-bound instance. | ||
| # Callers (e.g. test fixture teardown) may hold a detached reference, | ||
| # which would raise DetachedInstanceError on any lazy relationship access. | ||
| comment = db.query(Comment).filter(Comment.id == self.id).first() |
There was a problem hiding this comment.
src/fides/api/models/comment.py:175
The re-fetch unconditionally issues a SELECT even when self is already bound to db (the common production path). SQLAlchemy's identity map means the returned object will be the same Python instance if it's present, but the DB round-trip still happens unless the session has expire_on_commit=False and the object is unexpired.
A lighter alternative that avoids the extra query for the happy path:
from sqlalchemy import inspect as sa_inspect
if sa_inspect(self).detached:
comment = db.query(Comment).filter(Comment.id == self.id).first()
if comment is None:
logger.debug("Comment {} already deleted, skipping", self.id)
return
else:
comment = selfThis reserves the DB round-trip for the detached case (fixture teardown, etc.) while the normal flow incurs no overhead. Not a blocker if the call volume is low, but worth considering.
| comment = db.query(Comment).filter(Comment.id == self.id).first() | ||
| if comment is None: | ||
| logger.debug("Comment {} already deleted, skipping", self.id) | ||
| return |
There was a problem hiding this comment.
src/fides/api/models/comment.py:178
The None guard here and the comment not in db.deleted guard at line 239 handle two distinct scenarios that are worth documenting together:
- This guard (line 176–178): protects against a comment that was deleted in a prior committed transaction — the re-fetch returns
Nonebecause the row is gone. db.deletedguard (line 239): protects against a comment staged for deletion within the current transaction (e.g. a reply that both has its ownCommentReferencerow and is a descendant of another top-level comment in the samedelete_comments_for_reference_and_typecall).
The two are complementary, not redundant. A brief note linking them would help future readers understand why both checks are needed.
| # Collect all descendants iteratively (breadth-first) | ||
| to_delete = [] | ||
| stack = list(self.replies) | ||
| stack = list(comment.replies) |
There was a problem hiding this comment.
src/fides/api/models/comment.py:182
replies uses default lazy="select", so list(comment.replies) here and node.replies on line 185 each issue a separate SELECT. For the current use case (shallow reply trees) this is fine, but it's worth noting in a comment that lazy="selectin" was intentionally avoided here because self-referential selectin triggers recursive loading on every read path — not just the delete path.
#7906) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#7906) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
DetachedInstanceErrorcaused by the newrepliesrelationship onComment(added in ENG-3298: Add parent_id column to Comment model for threading #7864) using default lazy loadingComment.delete()now re-fetches from the DB to guarantee a session-bound instance before accessing relationshipsrepliesrelationship now useslazy="selectin"(matchinguser) to prevent lazy load on detached instancesRoot cause
The
repliesrelationship used defaultlazy="select". WhenComment.delete()was called on a detached instance (e.g. during fidesplus test fixture teardown), accessingself.replies,self.references, anddb.delete(self)all failed withDetachedInstanceError.Test plan
tests/ctl/models/test_comment.pytests pass (includingtest_delete_parent_comment_deletes_replies,test_delete_parent_deletes_grandchild_replies)🤖 Generated with Claude Code