问题
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