背景:既存のRailsアプリケーションの1つにチャット機能を組み込みました。使用しているのは新しいActionController::Live
モジュールとPumaの実行(Nginxが本番環境で)、Redisを介したメッセージのサブスクライブ。EventSource
クライアント側を使用して非同期で接続を確立しています。
問題の概要:接続が終了したときにスレッドが停止することはありません。
たとえば、ユーザーが移動したり、ブラウザーを閉じたり、アプリケーション内の別のページに移動したりすると、(予想どおりに)新しいスレッドが生成されますが、古いスレッドは引き続き使用されます。
私が現在見ている問題は、これらの状況のいずれかが発生した場合、何かがこの壊れたストリームに書き込もうとするまで、サーバーはブラウザ側の接続が終了したかどうかを知る方法がないということです。元のページから移動しました。
この問題は文書化されているようです github上 そして同様の質問がStackOverflowで尋ねられます ここ(かなり正確に同じ質問) と ここ(アクティブな数の取得に関して)スレッド) 。
これらの投稿に基づいて私が思いついた唯一の解決策は、ある種のスレッド/接続ポーカーを実装することです。壊れた接続に書き込もうとすると、IOError
が生成され、接続をキャッチして適切に閉じることができるため、スレッドが停止します。これは、そのソリューションのコントローラーコードです。
def events
response.headers["Content-Type"] = "text/event-stream"
stream_error = false; # used by flusher thread to determine when to stop
redis = Redis.new
# Subscribe to our events
redis.subscribe("message.create", "message.user_list_update") do |on|
on.message do |event, data| # when message is received, write to stream
response.stream.write("messageType: '#{event}', data: #{data}\n\n")
end
# This is the monitor / connection poker thread
# Periodically poke the connection by attempting to write to the stream
flusher_thread = Thread.new do
while !stream_error
$redis.publish "message.create", "flusher_test"
sleep 2.seconds
end
end
end
rescue IOError
logger.info "Stream closed"
stream_error = true;
ensure
logger.info "Events action is quitting redis and closing stream!"
redis.quit
response.stream.close
end
(注:events
メソッドはsubscribe
メソッドの呼び出しでブロックされているようです。他のすべて(ストリーミング)は正しく機能するため、これは正常であると思います。)
(その他の注意:フラッシャースレッドの概念は、ガベージスレッドコレクターのような、単一の長時間実行バックグラウンドプロセスとしてより理にかなっています。上記の実装の問題は、接続ごとに新しいスレッドが生成されることです。これは無意味です。誰でもこの概念を実装しようとすると、私が概説したほどではなく、単一のプロセスのように実行する必要があります。これを単一のバックグラウンドプロセスとして正常に再実装したら、この投稿を更新します。)
このソリューションの欠点は、問題を完全に解決したわけではなく、遅延または軽減しただけであるということです。スケーリングの観点からはひどいように思われるajaxなどの他のリクエストに加えて、ユーザーごとに2つのスレッドがまだあります。多数の同時接続が可能な大規模なシステムでは、完全に達成不可能で実用的ではないようです。
重要な何かが欠けているような気がします。 Railsには、私が行ったようなカスタム接続チェッカーを実装しないと明らかに壊れている機能があるとは信じがたいです。
質問:「接続ポーカー」やガベージスレッドコレクターなどの何かを実装せずに、接続/スレッドを停止させるにはどうすればよいですか?
いつものように、私が何かを忘れたかどうか私に知らせてください。
Updateちょっとした情報を追加するだけです:Huetschがgithubに投稿されました このコメント それを指摘SSEはTCPに基づいており、通常、接続が閉じられるとFINパケットを送信し、相手側(この場合はサーバー)に接続を安全に閉じることができることを知らせます。Huetschは、どちらのブラウザーもそうではないことを指摘しています。そのパケットを送信する(おそらくEventSource
ライブラリのバグ?)、またはRailsがそれをキャッチしていない、またはそれを使って何もしていない(その場合は間違いなくRailsのバグ) )検索は続行されます...
別の更新Wiresharkを使用すると、実際にFINパケットが送信されているのを確認できます。確かに、私はプロトコルレベルのことについてあまり知識がなく、経験もありませんが、私が知る限り、イベントソースを使用してSSE接続を確立すると、ブラウザから送信されるFINパケットを確実に検出します。ブラウザ、およびその接続を削除してもパケットは送信されません(SSEがないことを意味します)。TCPの知識はそれほどありませんが、これは接続が実際に行われていることを示しているようです。クライアントによって適切に終了されています。おそらくこれは、PumaまたはRailsのバグを示しています。
さらに別の更新@ JamesBoutcher/boutcheratwest(github)が私に redis Webサイトでの議論 この問題、特に.(p)subscribe
メソッドがシャットダウンしないという事実に関して。そのサイトの投稿者は、ここで発見したのと同じことを指摘しました。Rails環境は、クライアント側の接続が閉じられたときに通知されないため、.(p)unsubscribe
を実行できません。彼は.(p)subscribe
メソッドのタイムアウトについて質問します。これもうまくいくと思いますが、どちらのメソッド(上記の接続ポーカーまたは彼のタイムアウトの提案)がより良い解決策になるかはわかりません。理想的には、接続ポーカーソリューションについては、ストリームに書き込まずに、もう一方の端で接続が閉じられているかどうかを判断する方法を見つけたいと思います。現在のところ、ご覧のとおり、クライアント側を実装する必要があります。私の「突っついている」メッセージを個別に処理するためのコード。
私が行ったばかりの解決策(@teegから多くを借りた)は問題なく機能しているようです(失敗テストはしていません)
config/initializers/redis.rb
$redis = Redis.new(:Host => "xxxx.com", :port => 6379)
heartbeat_thread = Thread.new do
while true
$redis.publish("heartbeat","thump")
sleep 30.seconds
end
end
at_exit do
# not sure this is needed, but just in case
heartbeat_thread.kill
$redis.quit
end
そして私のコントローラーで:
def events
response.headers["Content-Type"] = "text/event-stream"
redis = Redis.new(:Host => "xxxxxxx.com", :port => 6379)
logger.info "New stream starting, connecting to redis"
redis.subscribe(['parse.new','heartbeat']) do |on|
on.message do |event, data|
if event == 'parse.new'
response.stream.write("event: parse\ndata: #{data}\n\n")
elsif event == 'heartbeat'
response.stream.write("event: heartbeat\ndata: heartbeat\n\n")
end
end
end
rescue IOError
logger.info "Stream closed"
ensure
logger.info "Stopping stream thread"
redis.quit
response.stream.close
end
私は現在、ActionController:Live、EventSource、Pumaを中心に展開するアプリを作成しています。ストリームを閉じるなどの問題が発生している場合は、IOError
をレスキューする代わりに、Rails 4.2レスキューClientDisconnected
。例:
def stream
#Begin is not required
Twitter_client = Twitter::Streaming::Client.new(config_params) do |obj|
# Do something
end
rescue ClientDisconnected
# Do something when disconnected
ensure
# Do something else to ensure the stream is closed
end
私はこのフォーラムの投稿からこの便利なヒントを見つけました(一番下にあります): http://railscasts.com/episodes/401-actioncontroller-live?view=comments
@James Boutcherに基づいて、2つのワーカーを持つクラスター化されたPumaで以下を使用したため、config/initializers /redis.rbでハートビート用に作成されたスレッドは1つだけです。
config/puma.rb
on_worker_boot do |index|
puts "worker nb #{index.to_s} booting"
create_heartbeat if index.to_i==0
end
def create_heartbeat
puts "creating heartbeat"
$redis||=Redis.new
heartbeat = Thread.new do
ActiveRecord::Base.connection_pool.release_connection
begin
while true
hash={event: "heartbeat",data: "heartbeat"}
$redis.publish("heartbeat",hash.to_json)
sleep 20.seconds
end
ensure
#no db connection anyway
end
end
end
これは、ハートビートを使用しない、潜在的に単純なソリューションです。多くの調査と実験を経て、sinatra + sinatra sse gemで使用しているコードを次に示します(Rails 4)に簡単に適合させる必要があります:
class EventServer < Sinatra::Base
include Sinatra::SSE
set :connections, []
.
.
.
get '/channel/:channel' do
.
.
.
sse_stream do |out|
settings.connections << out
out.callback {
puts 'Client disconnected from sse';
settings.connections.delete(out);
}
redis.subscribe(channel) do |on|
on.subscribe do |channel, subscriptions|
puts "Subscribed to redis ##{channel}\n"
end
on.message do |channel, message|
puts "Message from redis ##{channel}: #{message}\n"
message = JSON.parse(message)
.
.
.
if settings.connections.include?(out)
out.Push(message)
else
puts 'closing orphaned redis connection'
redis.unsubscribe
end
end
end
end
end
Redis接続はon.messageをブロックし、(p)subscribe /(p)unsubscribeコマンドのみを受け入れます。サブスクライブを解除すると、redis接続はブロックされなくなり、最初のsseリクエストによってインスタンス化されたWebサーバーオブジェクトによって解放されます。 redisでメッセージを受信し、ブラウザへのsse接続がコレクション配列に存在しなくなると、自動的にクリアされます。
これは、Redis。(p)subscribe呼び出しのブロックを終了し、未使用の接続トレッドを強制終了するタイムアウト付きのソリューションです。
class Stream::FixedController < StreamController
def events
# Rails reserve a db connection from connection pool for
# each request, lets put it back into connection pool.
ActiveRecord::Base.clear_active_connections!
# Last time of any (except heartbeat) activity on stream
# it mean last time of any message was send from server to client
# or time of setting new connection
@last_active = Time.zone.now
# Redis (p)subscribe is blocking request so we need do some trick
# to prevent it freeze request forever.
redis.psubscribe("messages:*", 'heartbeat') do |on|
on.pmessage do |pattern, event, data|
# capture heartbeat from Redis pub/sub
if event == 'heartbeat'
# calculate idle time (in secounds) for this stream connection
idle_time = (Time.zone.now - @last_active).to_i
# Now we need to relase connection with Redis.(p)subscribe
# chanel to allow go of any Exception (like connection closed)
if idle_time > 4.minutes
# unsubscribe from Redis because of idle time was to long
# that's all - fix in (almost)one line :)
redis.punsubscribe
end
else
# save time of this (last) activity
@last_active = Time.zone.now
end
# write to stream - even heartbeat - it's sometimes chance to
# capture dissconection error before idle_time
response.stream.write("event: #{event}\ndata: #{data}\n\n")
end
end
# blicking end (no chance to get below this line without unsubscribe)
rescue IOError
Logs::Stream.info "Stream closed"
rescue ClientDisconnected
Logs::Stream.info "ClientDisconnected"
rescue ActionController::Live::ClientDisconnected
Logs::Stream.info "Live::ClientDisconnected"
ensure
Logs::Stream.info "Stream ensure close"
redis.quit
response.stream.close
end
end
このブロッキング呼び出しを終了するには、reds。(p)unsubscribeを使用する必要があります。例外はこれを破ることはできません。
この修正に関する情報を含む私のシンプルなアプリ: https://github.com/piotr-kedziak/redis-subscribe-stream-puma-fix
すべてのクライアントにハートビートを送信する代わりに、接続ごとにウォッチドッグを設定する方が簡単な場合があります。 [@NeilJewersに感謝]
class Stream::FixedController < StreamController
def events
# Rails reserve a db connection from connection pool for
# each request, lets put it back into connection pool.
ActiveRecord::Base.clear_active_connections!
redis = Redis.new
watchdog = Doberman::WatchDog.new(:timeout => 20.seconds)
watchdog.start
# Redis (p)subscribe is blocking request so we need do some trick
# to prevent it freeze request forever.
redis.psubscribe("messages:*") do |on|
on.pmessage do |pattern, event, data|
begin
# write to stream - even heartbeat - it's sometimes chance to
response.stream.write("event: #{event}\ndata: #{data}\n\n")
watchdog.ping
rescue Doberman::WatchDog::Timeout => e
raise ClientDisconnected if response.stream.closed?
watchdog.ping
end
end
end
rescue IOError
rescue ClientDisconnected
ensure
response.stream.close
redis.quit
watchdog.stop
end
end