Skip to content

🐛 Fixed 500 error when deleting posts with threaded comments - #29682

Merged
mike182uk merged 2 commits into
mainfrom
mike-onc-1925
Aug 3, 2026
Merged

mike182uk merged 2 commits into
mainfrom
mike-onc-1925

Conversation

@mike182uk

@mike182uk mike182uk commented Jul 30, 2026 •

Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/ONC-1925

The comments.in_reply_to_id foreign key was created with ON DELETE SET NULL, while comments.parent_id uses ON DELETE CASCADE. When a post is deleted, InnoDB cascades into comments row by row and the SET NULL action issues an UPDATE on reply rows whose parent comment is delete-marked in the same cascade. That UPDATE re-validates the row's other foreign keys and fails with ER_NO_REFERENCED_ROW_2 (errno 1452), making it impossible to delete any post that has a reply-to-reply comment

No foreign key delete rule can fix this. Switching in_reply_to_id to CASCADE (this PR's original approach) removes the failing UPDATE, but in_reply_to_id forms a linked list through back-and-forth conversations - each reply referencing the previous one - and InnoDB hard-limits nested cascades to 15 levels, so deleting a post with a 15+ message reply chain fails with errno 3008 instead

The fix is therefore applied in the application instead of the schema: clear in_reply_to_id for the post's comments inside the delete transaction - in Post.destroy (single delete, also used by delete-all-content) and in PostsService#bulkDestroy (bulk delete from the posts list). With the reply chains cleared, the existing comments.post_id ON DELETE CASCADE deletes the comments as designed, regardless of thread shape. The foreign key is left unchanged and no migration is needed

Both admin API delete paths are covered by regression tests using a reply-to-reply thread plus a 20-deep reply chain, verified red/green against MySQL (1452 without the fix; and the chain guards the depth limit if CASCADE is ever reintroduced)

ref https://linear.app/ghost/issue/ONC-1925

The `comments.in_reply_to_id` foreign key was created with `ON DELETE SET
NULL`, while `comments.parent_id` uses `ON DELETE CASCADE`. When a post is
deleted, InnoDB cascades into `comments` row by row and the `SET NULL`
action issues an `UPDATE` on reply rows whose parent comment is
delete-marked in the same cascade. That `UPDATE` re-validates the row's
other foreign keys and fails with `ER_NO_REFERENCED_ROW_2` (`errno 1452`),
making it impossible to delete any post that has a reply-to-reply
comment

Switching the action to `CASCADE` makes the post delete cascade
deletes-only, which cannot fail this way. Nothing depends on the old
`SET NULL` behaviour: it only fires when a comment row is hard-deleted,
and the only hard delete in the product is the post delete cascade
itself, where every comment is removed regardless. Individual comment
deletion is always a soft delete (the `Comment` model overrides `destroy`
to set `status='deleted'`), and the UI / serializers handle removed reply
targets via that status, not via a nulled foreign key
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Post deletion now clears comments.in_reply_to_id before model and bulk deletion operations. New end-to-end tests cover individual and filtered bulk deletion for posts with 20-level threaded comment chains. The tests verify successful deletion and confirm that associated comments are removed.

Suggested reviewers: cmraible, kevinansfield

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the fix for the 500 error when deleting posts with threaded comments.
Description check ✅ Passed The description explains the deletion failure, application-level fix, affected delete paths, and regression tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mike-onc-1925

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the migration [pull request] Includes migration for review label Jul 30, 2026
@github-actions

This comment was marked as outdated.

@nx-cloud

nx-cloud Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 1f9a08d

Command Status Duration Result
nx run ghost:test:ci:integration ✅ Succeeded 2m 31s View ↗
nx run ghost:test:integration ✅ Succeeded 3m 26s View ↗
nx run ghost:test:legacy ✅ Succeeded 3m 14s View ↗
nx run ghost:test:e2e ✅ Succeeded 2m 47s View ↗
nx run-many -t test:unit -p ghost ✅ Succeeded 32s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 22s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded <1s View ↗
nx run-many -t lint -p ghost,ghost-monorepo ✅ Succeeded 21s View ↗
nx run @tryghost/admin:build ✅ Succeeded 3s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-08-03 09:26:46 UTC

@codecov

codecov Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.39%. Comparing base (76b97ad) to head (1f9a08d).
⚠️ Report is 36 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #29682      +/-   ##
==========================================
- Coverage   75.44%   75.39%   -0.06%     
==========================================
  Files        1606     1609       +3     
  Lines      141107   141663     +556     
  Branches    17455    17486      +31     
==========================================
+ Hits       106465   106806     +341     
- Misses      33598    33815     +217     
+ Partials     1044     1042       -2     
Flag Coverage Δ
e2e-tests 77.53% <100.00%> (-0.08%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

member_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'members.id', setNullDelete: true},
parent_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'comments.id', cascadeDelete: true},
in_reply_to_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'comments.id', setNullDelete: true},
in_reply_to_id: {type: 'string', maxlength: 24, nullable: true, unique: false, references: 'comments.id', cascadeDelete: true},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MySQL 8 apparently doesn't support cascading more than 15-levels deep:

Cascading operations may not be nested more than 15 levels deep.
https://dev.mysql.com/doc/mysql-reslimits-excerpt/8.0/en/ansi-diff-foreign-keys.html

That would mean that deletion on a post with a 15-reply chain is going to fail. I've had Claude test that assumption locally and it was able to get a ERROR 3008: Foreign key cascade delete/update exceeds max depth of 15 error on a post with a 15-reply chain

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So given that neither SET NULL or CASCADE are proving sufficient here, I think we might need to manually nullify in_reply_to_id in in the transaction that deletes the post?

Sample code to illustrate:

destroy: function destroy(unfilteredOptions) {
    let options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});

    const destroyPost = async () => {
        await ghostBookshelf.knex('comments')
            .where('post_id', options.id)
            .update('in_reply_to_id', null)
            .transacting(options.transacting);

        return ghostBookshelf.Model.destroy.call(this, options);
    };

    if (!options.transacting) {
        return ghostBookshelf.transaction((transacting) => {
            options.transacting = transacting;
            return destroyPost();
        });
    }

    return destroyPost();
},

(then do the same for the bulk destroy)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one @sagzy

We discussed this on Slack and have gone ahead and updated the implementation using this approach 👍

…tion

ref https://linear.app/ghost/issue/ONC-1925

No foreign key delete rule works for `comments.in_reply_to_id`: `SET NULL`
fails mid-cascade (`errno 1452`) for any post with a reply-to-reply
comment, and `CASCADE` hits MySQL's 15-level cascade depth limit
(`errno 3008`) on long back-and-forth reply chains, which form a linked
list through `in_reply_to_id`. The previous approach of migrating the
foreign key to `CASCADE` is therefore reverted - the fix is instead to
clear `in_reply_to_id` for the post's comments inside the delete
transaction (single and bulk destroy), so the existing `post_id`
cascade can delete the comments regardless of thread shape
@mike182uk mike182uk removed the migration [pull request] Includes migration for review label Aug 3, 2026
await ghostBookshelf.knex('comments')
.where('post_id', options.id)
.update('in_reply_to_id', null)
.transacting(options.transacting);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure that there is always a transaction here?

Else we may want to do sth like

 if (!options.transacting) { 
    // create transaction
    // then call destroyPost()
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destroyPost is only called after options.transacting is set the existing if (!options.transacting) block just below (cut off in the diff view) creates the transaction and assigns it before invoking destroyPost. Callers that pass their own transacting (e.g. deleteAllContent) take the direct path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweet, all good then!

@mike182uk
mike182uk merged commit 0bdcd31 into main Aug 3, 2026
51 checks passed
@mike182uk
mike182uk deleted the mike-onc-1925 branch August 3, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants