ReactionLikeSynchronizer reprocesses already-trashed PostAction likes every hour

AI-generated summary

James_Wong reported a bug on self-hosted Discourse where the ReactionLikeSynchronizer job causes 500/504 errors by repeatedly trashing the same 26,766 already-deleted PostAction likes every hour. This process blocks PostgreSQL requests, leading to Pitchfork timeouts and user-facing issues like failed reactions and unread topic states. The root cause is identified in the trash_excluded_post_actions method, which lacks a filter for deleted_at IS NULL, causing it to reprocess rows that are already marked as deleted.

As a temporary workaround, James_Wong disabled discourse_reactions_like_sync_enabled. In response, zogstrip acknowledged the report and shared two pull requests (#44647 and #44648) that rework the synchronization logic to use batching. This change ensures the process runs slightly longer but prevents blocking major tables.

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.

thanks for the report @James_Wong :+1: i’ve reworked the way likes & reactions are counted and synchronized using batches so that it takes a little bit longer in practice but never blocks any major tables

:abacus: FIX: Recalculate like counters with one shared rule by ZogStriP · Pull Request #44647 · discourse/discourse · GitHub
:repeat_button: FIX: Stop the reactions like sync from re-trashing likes every hour by ZogStriP · Pull Request #44648 · discourse/discourse · GitHub

2 Likes