web-dev-qa-db-ja.com

Redis + ActionController ::ライブスレッドが死んでいない

背景:既存の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メソッドのタイムアウトについて質問します。これもうまくいくと思いますが、どちらのメソッド(上記の接続ポーカーまたは彼のタイムアウトの提案)がより良い解決策になるかはわかりません。理想的には、接続ポーカーソリューションについては、ストリームに書き込まずに、もう一方の端で接続が閉じられているかどうかを判断する方法を見つけたいと思います。現在のところ、ご覧のとおり、クライアント側を実装する必要があります。私の「突っついている」メッセージを個別に処理するためのコード。

34
Paul Richter

私が行ったばかりの解決策(@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
15
James Boutcher

私は現在、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

4
TheNastyOne

@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
2
Leooo

これは、ハートビートを使用しない、潜在的に単純なソリューションです。多くの調査と実験を経て、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接続がコレクション配列に存在しなくなると、自動的にクリアされます。

1
henry74

これは、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

1
Piotr Kędziak

すべてのクライアントにハートビートを送信する代わりに、接続ごとにウォッチドッグを設定する方が簡単な場合があります。 [@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
0
Tilo