Rails & postgresql, notify/listen whenever a new record is created on an admin dashboard without race condition

耗尽温柔 提交于 2020-05-16 22:43:12

问题


I have an admin dashboard where I want an alert to be fired whenever a user is created (on a separate page). The code below works, however, there's a race condition. If 2 users are created very close together, it will only fire once.

class User < ApplicationRecord
  after_commit :notify_creation, on: :create

  def notify_creation
    ActiveRecord::Base.connection_pool.with_connection do |connection|
      self.class.execute_query(connection, ["NOTIFY user_created, '?'", id])
    end
  end

  def self.listen_to_creation
    ActiveRecord::Base.connection_pool.with_connection do |connection|
      begin
        execute_query(connection, ["LISTEN user_created"])
        connection.raw_connection.wait_for_notify do |event, pid, id|
          yield id
        end
      ensure
        execute_query(connection, ["UNLISTEN user_created"])
      end
    end
  end

  def self.clean_sql(query)
    sanitize_sql(query)
  end

  private

  def self.execute_query(connection, query)
    sql = self.clean_sql(query)
    connection.execute(sql)
  end
end

class AdminsController < ApplicationController
  include ActionController::Live

  def update
    response.headers['Content-Type'] = 'text/event-stream'
    sse = SSE.new(response.stream, event: 'notice')
    begin
      User.listen_to_creation do |user_id|
        sse.write({user_id: user_id})
      end
    rescue ClientDisconnected
    ensure
      sse.close
    end
  end
end

This is my first time doing this, so I followed this tutorial, which like most tutorials are focused on updates to a single record, rather than listening to an entire table for new creation.


回答1:


This is happening because you send only one update at once and then the request ends. If you make a request at the AdminsController#update. You have one subscriber waiting for your notification. Look at this block

begin
  execute_query(connection, ["LISTEN user_created"])
  connection.raw_connection.wait_for_notify do |event, pid, id|
    yield id
  end
ensure
  execute_query(connection, ["UNLISTEN user_created"])
end

As soon as you get one notification, the block yields and then you close the channel. So if you are relying on frontend to make one more connection attempt once it gets the result, if a record gets created before you start listening to the channel again in the new connection, you won't get a notification as there was no listener attached to Postgres at that time.

This is sort of a common issue in any realtime notification system. You would ideally want a pipe to frontend(Websocket, SSE or even LongPolling) which is always open. If you get a new item you send it to the frontend using that pipe and you should ideally keep that pipe open as in case of Websockets and SSE. Right now you are kind of treating your SSE connection as a long poll.

So your code should look something like

# Snippet 2
  def self.listen_to_creation
    ActiveRecord::Base.connection_pool.with_connection do |connection|
      begin
        execute_query(connection, ["LISTEN user_created"])
        loop do 
          connection.raw_connection.wait_for_notify do |event, pid, id|
            yield id
          end
        end
      ensure
        execute_query(connection, ["UNLISTEN user_created"])
      end
    end
  end

But this will run into a problem where it will keep thread alive forever even if the connection is closed until some data comes to thread and it is encounters an error while writing back then. You can choose to either run it a fixed number of times with short lived notify intervals or you can add sort of a hearbeat to it. There are two simple ways of accomplishing a hearbeat. I will add them as quick hack codes.

# Snippet 3
def self.listen_to_creation(heartbeat_interval = 10)
    ActiveRecord::Base.connection_pool.with_connection do |connection|
      begin
        execute_query(connection, ["LISTEN user_created"])
        last_hearbeat = Time.now
        loop do 
          connection.raw_connection.wait_for_notify(heartbeat_interval) do |event, pid, id|
            yield({id: id})
          end
          if Time.now - last_heartbeat >= heartbeat_interval
            yield({heartbeat: true})
            last_heartbeat = Time.now
          end
        end
      ensure
        execute_query(connection, ["UNLISTEN user_created"])
      end
    end
  end

In the above example you will at least be sending something in the pipe every heartbeat_interval seconds. So, if the pipe closes it should error out and close the pipe thus freeing up the thread.

This approach kind of adds controller related logic to model and if you want to hold postgres notify without a time interval, the other thing that you can do to do a heartbeat is, just launch a thread in the controller itself. Launch a thread in the controller method that sleeps for heartbeat_interval and writes sse.write({heartbeat: true}) after waking up. You can leave the model code the same as Snippet 2 in that case.

Also, I added the other things to watch with SSEs with Puma & Rails in an answer to your other question:



来源:https://stackoverflow.com/questions/60897923/rails-postgresql-notify-listen-whenever-a-new-record-is-created-on-an-admin-d

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!