FIX: add mutex around un/mark as solved

FIX: add mutex around un/mark as solved

To prevent any race conditions when two users toggle the state of a reply.

(cf. https://meta.discourse.org/161104)

diff --git a/plugin.rb b/plugin.rb
index a6462c7..72708d7 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -77,133 +77,139 @@ SQL
 
     def self.accept_answer!(post, acting_user, topic: nil)
       topic ||= post.topic
-      accepted_id = topic.custom_fields["accepted_answer_post_id"].to_i
-
-      if accepted_id > 0
-        if p2 = Post.find_by(id: accepted_id)
-          p2.custom_fields["is_accepted_answer"] = nil
-          p2.save!
-
-          if defined?(UserAction::SOLVED)
-            UserAction.where(
-              action_type: UserAction::SOLVED,
-              target_post_id: p2.id
-            ).destroy_all
-          end
-        end
-      end
 
-      post.custom_fields["is_accepted_answer"] = "true"
-      topic.custom_fields["accepted_answer_post_id"] = post.id
+      DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
+        accepted_id = topic.custom_fields["accepted_answer_post_id"].to_i
 
-      if defined?(UserAction::SOLVED)
-        UserAction.log_action!(
-          action_type: UserAction::SOLVED,
-          user_id: post.user_id,
-          acting_user_id: acting_user.id,
-          target_post_id: post.id,
-          target_topic_id: post.topic_id
-        )
-      end
+        if accepted_id > 0
+          if p2 = Post.find_by(id: accepted_id)
+            p2.custom_fields["is_accepted_answer"] = nil
+            p2.save!
 
-      unless acting_user.id == post.user_id
-        Notification.create!(
-          notification_type: Notification.types[:custom],
-          user_id: post.user_id,
-          topic_id: post.topic_id,
-          post_number: post.post_number,
-          data: {
-            message: 'solved.accepted_notification',
-            display_username: acting_user.username,
-            topic_title: topic.title
-          }.to_json
-        )
-      end
+            if defined?(UserAction::SOLVED)
+              UserAction.where(
+                action_type: UserAction::SOLVED,
+                target_post_id: p2.id
+              ).destroy_all
+            end
+          end
+        end
 
-      auto_close_hours = SiteSetting.solved_topics_auto_close_hours
+        post.custom_fields["is_accepted_answer"] = "true"
+        topic.custom_fields["accepted_answer_post_id"] = post.id
 
-      if (auto_close_hours > 0) && !topic.closed
-        begin
-          topic_timer = topic.set_or_create_timer(
-            TopicTimer.types[:close],
-            nil,
-            based_on_last_post: true,
-            duration: auto_close_hours
+        if defined?(UserAction::SOLVED)
+          UserAction.log_action!(
+            action_type: UserAction::SOLVED,
+            user_id: post.user_id,
+            acting_user_id: acting_user.id,
+            target_post_id: post.id,
+            target_topic_id: post.topic_id
           )
-        rescue ArgumentError
-          # https://github.com/discourse/discourse/commit/aad12822b7d7c9c6ecd976e23d3a83626c052dce#diff-4d0afa19fa7752955f36089bca420ab4L1135
-          # this rescue block can be deleted after discourse stable version > 2.4
-          topic_timer = topic.set_or_create_timer(
-            TopicTimer.types[:close],
-            auto_close_hours,
-            based_on_last_post: true
+        end
+
+        unless acting_user.id == post.user_id
+          Notification.create!(
+            notification_type: Notification.types[:custom],
+            user_id: post.user_id,
+            topic_id: post.topic_id,
+            post_number: post.post_number,
+            data: {
+              message: 'solved.accepted_notification',
+              display_username: acting_user.username,
+              topic_title: topic.title
+            }.to_json
           )
         end
 
-        topic.custom_fields[
-          AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD
-        ] = topic_timer.id
+        auto_close_hours = SiteSetting.solved_topics_auto_close_hours
+
+        if (auto_close_hours > 0) && !topic.closed
+          begin
+            topic_timer = topic.set_or_create_timer(
+              TopicTimer.types[:close],
+              nil,
+              based_on_last_post: true,
+              duration: auto_close_hours
+            )
+          rescue ArgumentError
+            # https://github.com/discourse/discourse/commit/aad12822b7d7c9c6ecd976e23d3a83626c052dce#diff-4d0afa19fa7752955f36089bca420ab4L1135
+            # this rescue block can be deleted after discourse stable version > 2.4
+            topic_timer = topic.set_or_create_timer(
+              TopicTimer.types[:close],
+              auto_close_hours,
+              based_on_last_post: true
+            )
+          end
 
-        MessageBus.publish("/topic/#{topic.id}", reload_topic: true)
-      end
+          topic.custom_fields[
+            AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD
+          ] = topic_timer.id
 
-      topic.save!
-      post.save!
+          MessageBus.publish("/topic/#{topic.id}", reload_topic: true)
+        end
 
-      if WebHook.active_web_hooks(:solved).exists?
-        payload = WebHook.generate_payload(:post, post)
-        WebHook.enqueue_solved_hooks(:accepted_solution, post, payload)
-      end
+        topic.save!
+        post.save!
 
-      DiscourseEvent.trigger(:accepted_solution, post)
+        if WebHook.active_web_hooks(:solved).exists?
+          payload = WebHook.generate_payload(:post, post)
+          WebHook.enqueue_solved_hooks(:accepted_solution, post, payload)
+        end
+
+        DiscourseEvent.trigger(:accepted_solution, post)
+      end
     end
 
     def self.unaccept_answer!(post, topic: nil)
       topic ||= post.topic
-      post.custom_fields["is_accepted_answer"] = nil
-      topic.custom_fields["accepted_answer_post_id"] = nil
 
-      if timer_id = topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD]
-        topic_timer = TopicTimer.find_by(id: timer_id)
-        topic_timer.destroy! if topic_timer
-        topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD] = nil
-      end
+      DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
+        post.custom_fields["is_accepted_answer"] = nil
+        topic.custom_fields["accepted_answer_post_id"] = nil
 
-      topic.save!
-      post.save!
+        if timer_id = topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD]
+          topic_timer = TopicTimer.find_by(id: timer_id)
+          topic_timer.destroy! if topic_timer
+          topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD] = nil
+        end
 
-      # TODO remove_action! does not allow for this type of interface
-      if defined? UserAction::SOLVED
-        UserAction.where(
-          action_type: UserAction::SOLVED,
-          target_post_id: post.id
-        ).destroy_all
-      end
+        topic.save!
+        post.save!
+
+        # TODO remove_action! does not allow for this type of interface
+        if defined? UserAction::SOLVED
+          UserAction.where(
+            action_type: UserAction::SOLVED,
+            target_post_id: post.id
+          ).destroy_all
+        end
+
+        # yank notification
+        notification = Notification.find_by(
+          notification_type: Notification.types[:custom],
+          user_id: post.user_id,
+          topic_id: post.topic_id,
+          post_number: post.post_number
+        )
 
-      # yank notification
-      notification = Notification.find_by(
-        notification_type: Notification.types[:custom],
-        user_id: post.user_id,
-        topic_id: post.topic_id,
-        post_number: post.post_number
-      )
+        notification.destroy! if notification
 
-      notification.destroy! if notification
+        if WebHook.active_web_hooks(:solved).exists?
+          payload = WebHook.generate_payload(:post, post)
+          WebHook.enqueue_solved_hooks(:unaccepted_solution, post, payload)
+        end
 
-      if WebHook.active_web_hooks(:solved).exists?

[... diff too long, it was truncated ...]

GitHub sha: 9ecac7a3

Proper URL is https://meta.discourse.org/t/161104