ReactionLikeSynchronizer reprocesses already-trashed PostAction likes every hour

AI-generated summary

James_Wong reports a bug on a self-hosted Discourse site (v2026.10.0) where the ReactionLikeSynchronizer job causes 500/504 errors and blocks PostgreSQL requests by repeatedly processing the same 26,766 PostAction likes every hour. Although these likes are already marked as deleted, the synchronizer trashes them again, leading to timeouts and user-facing errors such as failed reactions and topics remaining unread. The issue is specific to negative or excluded reaction types (e.g., ‘angry’, ‘bomb’, ‘no_entry’).

The root cause appears to be in the ReactionLikeSynchronizer#trash_excluded_post_actions method, which updates deleted_at without checking if the action is already deleted. This results in the same rows being reprocessed hourly. As a workaround, the user disabled discourse_reactions_like_sync_enabled to stop the errors. James_Wong later edited the post to correct typos and the SQL query in the notes.

We’ve encountered an issue on a self-hosted Discourse site in which the ReactionLikeSynchronizer seems to be triggering 500/504 errors for user actions.

The job appears to be repeatedly trashing the same 26,766 PostAction likes every hour, even though all of them have been marked as deleted. During these longer sync runs, normal user requests seem to block in PostgreSQL and hit Pitchfork’s 30-second timeout.

Environment

  • Self-hosted Discourse on version v2026.10.0-latest-277-ge1f6bd561
  • PostgreSQL 18.4
  • discourse_reactions_like_sync_enabled = true
  • This is an older site, so there is a lot of historical reaction data

User Reports

  • Reactions sometimes fail and produce a “Something went wrong” message.
  • Topics a user visited sometimes remain marked unread
  • Reports are clustered around once per hour at the top of the hour.

Logs and Data

13:58:58 ReactionLikeSynchronizer starts
13:59:50 Pitchfork worker timeout / 500
14:00:02 Pitchfork worker timeout / 500
14:00:18 Pitchfork worker timeout / 500
14:00:48 ReactionLikeSynchronizer completes

15:00:09 sync starts
15:01:16 request timeout / 500
15:01:40 request timeout / 500
15:01:45 sync completes

Synchronizer Output

Inserted 0 post action likes, recovered 0 trashed post action likes. (0 total)
Trashed 26766 post action likes.

Notably, the same 26766 count shows up on every run I checked.

Database Contents

SELECT
  COUNT(*) AS total,
  COUNT(*) FILTER (WHERE pa.deleted_at IS NULL) AS currently_active,
  COUNT(*) FILTER (WHERE pa.deleted_at IS NOT NULL) AS already_trashed
FROM post_actions pa
JOIN discourse_reactions_reaction_users ru
  ON ru.user_id = pa.user_id
 AND ru.post_id = pa.post_id
JOIN discourse_reactions_reactions r
  ON r.id = ru.reaction_id
WHERE pa.post_action_type_id = 2
  AND r.reaction_value IN (
    '-1','poop','face_with_symbols_on_mouth','enraged_face',
    'face_vomiting','nauseated_face','angry','kitchen_knife',
    'water_pistol','bomb','clown_face','unamused_face','roll_eyes',
    'confused','smirking_face','disappointed_face','pensive_face',
    'lying_face','weary_face','tired_face','confounded_face',
    'persevering_face','smiling_face_with_horns','angry_face_with_horns',
    'cigarette','person_gesturing_no','prohibited','no_entry',
    'dagger','cross_mark'
  );
 total | currently_active | already_trashed

-------+------------------+-----------------

 26766 |                0 |           26766

Notes

For what it’s worth, it looks like ReactionLikeSynchronizer#trash_excluded_post_actions matches likes that have been excluded even if they have already been deleted. The query looks like this:

UPDATE post_actions
SET deleted_at = NOW()
FROM discourse_reactions_reaction_users ru
INNER JOIN discourse_reactions_reactions
  ON discourse_reactions_reactions.id = ru.reaction_id
WHERE post_actions.user_id = ru.user_id
  AND post_actions.post_id = ru.post_id
  AND post_actions.post_action_type_id = :like
  AND discourse_reactions_reactions.reaction_value IN (:excluded_from_like)
RETURNING post_actions.id

Notably, this doesn’t exclude deleted actions via something like AND post_actions.deleted_at IS NULL.

This appears to result in the same set of rows being reprocessed every hour. I can add a stack trace, if necessary.

As a workaround, I’ve disabled discourse_reactions_like_sync_enabled and am monitoring whether the errors stop occurring every hour.

Edited to fix typos and correct the query in the “Notes” section.