├── .gitignore ├── .gitmodules ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── README.md ├── faye-redis.gemspec ├── lib └── faye │ ├── redis.rb │ └── redis_factory.rb └── spec ├── faye_redis_spec.rb ├── redis.conf └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | *.rdb 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/faye"] 2 | path = vendor/faye 3 | url = git://github.com/faye/faye.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | 4 | rvm: 5 | - 1.9.3 6 | - 2.0.0 7 | - 2.1.10 8 | - 2.2.6 9 | - 2.3.3 10 | - 2.4.0 11 | - jruby-19mode 12 | - jruby-9 13 | 14 | services: 15 | - redis-server 16 | 17 | before_script: 18 | - git submodule update --init --recursive 19 | 20 | script: bundle exec rspec -c spec/ 21 | 22 | env: TRAVIS=1 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.2.0 / 2013-10-01 2 | 3 | * Migrate from Yajl to MultiJson to support JRuby 4 | * Trigger `close` event as required by Faye 1.0 5 | 6 | 7 | ### 0.1.1 / 2013-04-28 8 | 9 | * Improve garbage collection to avoid leaking Redis memory 10 | 11 | 12 | ### 0.1.0 / 2012-02-26 13 | 14 | * Initial release: Redis backend for Faye 0.8 15 | 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | All projects under the [Faye](https://github.com/faye) umbrella are covered by 4 | the [Code of Conduct](https://github.com/faye/code-of-conduct). 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org/' 2 | gemspec 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Faye::Redis [![Build Status](https://travis-ci.org/faye/faye-redis-ruby.svg)](https://travis-ci.org/faye/faye-redis-ruby) 2 | 3 | This plugin provides a Redis-based backend for the 4 | [Faye](http://faye.jcoglan.com) messaging server. It allows a single Faye 5 | service to be distributed across many front-end web servers by storing state and 6 | routing messages through a [Redis](http://redis.io) database server. 7 | 8 | 9 | ## Usage 10 | 11 | Pass in the engine and any settings you need when setting up your Faye server. 12 | 13 | ```rb 14 | require 'faye' 15 | require 'faye/redis' 16 | 17 | bayeux = Faye::RackAdapter.new( 18 | :mount => '/', 19 | :timeout => 25, 20 | :engine => { 21 | :type => Faye::Redis, 22 | :host => 'redis.example.com', 23 | # more options 24 | } 25 | ) 26 | ``` 27 | 28 | The full list of settings is as follows. 29 | 30 | * `:uri` - redis URL (example: `redis://:secretpassword@example.com:9000/4`) 31 | * `:host` - hostname of your Redis instance 32 | * `:port` - port number, default is `6379` 33 | * `:password` - password, if `requirepass` is set 34 | * `:database` - number of database to use, default is `0` 35 | * `:namespace` - prefix applied to all keys, default is `''` 36 | * `:socket` - path to Unix socket if `unixsocket` is set 37 | 38 | 39 | ## License 40 | 41 | (The MIT License) 42 | 43 | Copyright (c) 2011-2013 James Coglan 44 | 45 | Permission is hereby granted, free of charge, to any person obtaining a copy of 46 | this software and associated documentation files (the 'Software'), to deal in 47 | the Software without restriction, including without limitation the rights to 48 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 49 | the Software, and to permit persons to whom the Software is furnished to do so, 50 | subject to the following conditions: 51 | 52 | The above copyright notice and this permission notice shall be included in all 53 | copies or substantial portions of the Software. 54 | 55 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 56 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 57 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 58 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 59 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 60 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 61 | -------------------------------------------------------------------------------- /faye-redis.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'faye-redis' 3 | s.version = '0.2.0' 4 | s.summary = 'Redis backend engine for Faye' 5 | s.author = 'James Coglan' 6 | s.email = 'jcoglan@gmail.com' 7 | s.homepage = 'http://github.com/faye/faye-redis-ruby' 8 | 9 | s.extra_rdoc_files = %w[README.md] 10 | s.rdoc_options = %w[--main README.md --markup markdown] 11 | s.require_paths = %w[lib] 12 | 13 | s.files = %w[CHANGELOG.md README.md] + 14 | Dir.glob('lib/**/*.rb') 15 | 16 | s.add_dependency 'eventmachine', '>= 0.12.0' 17 | s.add_dependency 'em-hiredis', '>= 0.2.0' 18 | s.add_dependency 'multi_json', '>= 1.0.0' 19 | 20 | s.add_development_dependency 'rspec' 21 | s.add_development_dependency 'rspec-eventmachine' 22 | s.add_development_dependency 'websocket-driver' 23 | end 24 | -------------------------------------------------------------------------------- /lib/faye/redis.rb: -------------------------------------------------------------------------------- 1 | require 'em-hiredis' 2 | require 'multi_json' 3 | 4 | require File.expand_path('../redis_factory', __FILE__) 5 | 6 | module Faye 7 | class Redis 8 | 9 | DEFAULT_GC = 60 10 | LOCK_TIMEOUT = 120 11 | 12 | def self.create(server, options) 13 | new(server, options) 14 | end 15 | 16 | def initialize(server, options) 17 | @server = server 18 | @options = options 19 | @factory = options[:factory] || RedisFactory.new(options) 20 | 21 | init 22 | end 23 | 24 | def init 25 | return if @redis or !EventMachine.reactor_running? 26 | 27 | gc = @options[:gc] || DEFAULT_GC 28 | @ns = @options[:namespace] || '' 29 | @redis = @factory.call 30 | 31 | @subscriber = @redis.pubsub 32 | 33 | @message_channel = @ns + '/notifications/messages' 34 | @close_channel = @ns + '/notifications/close' 35 | 36 | @subscriber.subscribe(@message_channel) 37 | @subscriber.subscribe(@close_channel) 38 | @subscriber.on(:message) do |topic, message| 39 | empty_queue(message) if topic == @message_channel 40 | @server.trigger(:close, message) if topic == @close_channel 41 | end 42 | 43 | @gc = EventMachine.add_periodic_timer(gc, &method(:gc)) 44 | end 45 | 46 | def disconnect 47 | return unless @redis 48 | @subscriber.unsubscribe(@message_channel) 49 | @subscriber.unsubscribe(@close_channel) 50 | EventMachine.cancel_timer(@gc) 51 | end 52 | 53 | def create_client(&callback) 54 | init 55 | client_id = @server.generate_id 56 | @redis.zadd(@ns + '/clients', 0, client_id) do |added| 57 | next create_client(&callback) if added == 0 58 | @server.debug 'Created new client ?', client_id 59 | ping(client_id) 60 | @server.trigger(:handshake, client_id) 61 | callback.call(client_id) 62 | end 63 | end 64 | 65 | def client_exists(client_id, &callback) 66 | init 67 | cutoff = get_current_time - (1000 * 1.6 * @server.timeout) 68 | 69 | @redis.zscore(@ns + '/clients', client_id) do |score| 70 | callback.call(score.to_i > cutoff) 71 | end 72 | end 73 | 74 | def destroy_client(client_id, &callback) 75 | init 76 | @redis.zadd(@ns + '/clients', 0, client_id) do 77 | @redis.smembers(@ns + "/clients/#{client_id}/channels") do |channels| 78 | i, n = 0, channels.size 79 | next after_subscriptions_removed(client_id, &callback) if i == n 80 | 81 | channels.each do |channel| 82 | unsubscribe(client_id, channel) do 83 | i += 1 84 | after_subscriptions_removed(client_id, &callback) if i == n 85 | end 86 | end 87 | end 88 | end 89 | end 90 | 91 | def after_subscriptions_removed(client_id, &callback) 92 | @redis.del(@ns + "/clients/#{client_id}/messages") do 93 | @redis.zrem(@ns + '/clients', client_id) do 94 | @server.debug 'Destroyed client ?', client_id 95 | @server.trigger(:disconnect, client_id) 96 | @redis.publish(@close_channel, client_id) 97 | callback.call if callback 98 | end 99 | end 100 | end 101 | 102 | def ping(client_id) 103 | init 104 | timeout = @server.timeout 105 | return unless Numeric === timeout 106 | 107 | time = get_current_time 108 | @server.debug 'Ping ?, ?', client_id, time 109 | @redis.zadd(@ns + '/clients', time, client_id) 110 | end 111 | 112 | def subscribe(client_id, channel, &callback) 113 | init 114 | @redis.sadd(@ns + "/clients/#{client_id}/channels", channel) do |added| 115 | @server.trigger(:subscribe, client_id, channel) if added == 1 116 | end 117 | @redis.sadd(@ns + "/channels#{channel}", client_id) do 118 | @server.debug 'Subscribed client ? to channel ?', client_id, channel 119 | callback.call if callback 120 | end 121 | end 122 | 123 | def unsubscribe(client_id, channel, &callback) 124 | init 125 | @redis.srem(@ns + "/clients/#{client_id}/channels", channel) do |removed| 126 | @server.trigger(:unsubscribe, client_id, channel) if removed == 1 127 | end 128 | @redis.srem(@ns + "/channels#{channel}", client_id) do 129 | @server.debug 'Unsubscribed client ? from channel ?', client_id, channel 130 | callback.call if callback 131 | end 132 | end 133 | 134 | def publish(message, channels) 135 | init 136 | @server.debug 'Publishing message ?', message 137 | 138 | json_message = MultiJson.dump(message) 139 | channels = Channel.expand(message['channel']) 140 | keys = channels.map { |c| @ns + "/channels#{c}" } 141 | 142 | @redis.sunion(*keys) do |clients| 143 | clients.each do |client_id| 144 | queue = @ns + "/clients/#{client_id}/messages" 145 | 146 | @server.debug 'Queueing for client ?: ?', client_id, message 147 | @redis.rpush(queue, json_message) 148 | @redis.publish(@message_channel, client_id) 149 | 150 | client_exists(client_id) do |exists| 151 | @redis.del(queue) unless exists 152 | end 153 | end 154 | end 155 | 156 | @server.trigger(:publish, message['clientId'], message['channel'], message['data']) 157 | end 158 | 159 | def empty_queue(client_id) 160 | return unless @server.has_connection?(client_id) 161 | init 162 | 163 | key = @ns + "/clients/#{client_id}/messages" 164 | 165 | @redis.multi 166 | @redis.lrange(key, 0, -1) 167 | @redis.del(key) 168 | @redis.exec.callback do |json_messages, deleted| 169 | next unless json_messages 170 | messages = json_messages.map { |json| MultiJson.load(json) } 171 | @server.deliver(client_id, messages) 172 | end 173 | end 174 | 175 | private 176 | 177 | def get_current_time 178 | (Time.now.to_f * 1000).to_i 179 | end 180 | 181 | def gc 182 | timeout = @server.timeout 183 | return unless Numeric === timeout 184 | 185 | with_lock 'gc' do |release_lock| 186 | cutoff = get_current_time - 1000 * 2 * timeout 187 | @redis.zrangebyscore(@ns + '/clients', 0, cutoff) do |clients| 188 | i, n = 0, clients.size 189 | next release_lock.call if i == n 190 | 191 | clients.each do |client_id| 192 | destroy_client(client_id) do 193 | i += 1 194 | release_lock.call if i == n 195 | end 196 | end 197 | end 198 | end 199 | end 200 | 201 | def with_lock(lock_name, &block) 202 | lock_key = @ns + '/locks/' + lock_name 203 | current_time = get_current_time 204 | expiry = current_time + LOCK_TIMEOUT * 1000 + 1 205 | 206 | release_lock = lambda do 207 | @redis.del(lock_key) if get_current_time < expiry 208 | end 209 | 210 | @redis.setnx(lock_key, expiry) do |set| 211 | next block.call(release_lock) if set == 1 212 | 213 | @redis.get(lock_key) do |timeout| 214 | next unless timeout 215 | 216 | lock_timeout = timeout.to_i(10) 217 | next if current_time < lock_timeout 218 | 219 | @redis.getset(lock_key, expiry) do |old_value| 220 | block.call(release_lock) if old_value == timeout 221 | end 222 | end 223 | end 224 | end 225 | 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /lib/faye/redis_factory.rb: -------------------------------------------------------------------------------- 1 | module Faye 2 | class RedisFactory 3 | 4 | DEFAULT_HOST = '0.0.0.0' 5 | DEFAULT_PORT = 6379 6 | DEFAULT_DATABASE = 0 7 | 8 | def initialize(options) 9 | @options = options 10 | end 11 | 12 | def call 13 | uri = @options[:uri] || nil 14 | socket = @options[:socket] || nil 15 | host = @options[:host] || DEFAULT_HOST 16 | port = @options[:port] || DEFAULT_PORT 17 | auth = @options[:password] || nil 18 | db = @options[:database] || DEFAULT_DATABASE 19 | 20 | if uri 21 | EventMachine::Hiredis.connect(uri) 22 | elsif socket 23 | EventMachine::Hiredis::Client.new(socket, nil, auth, db).connect 24 | else 25 | EventMachine::Hiredis::Client.new(host, port, auth, db).connect 26 | end 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/faye_redis_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Faye::Redis do 4 | let(:engine_opts) do 5 | pw = ENV["TRAVIS"] ? nil : "foobared" 6 | {:type => Faye::Redis, :password => pw, :namespace => Time.now.to_i.to_s} 7 | end 8 | 9 | after do 10 | disconnect_engine 11 | redis = EM::Hiredis::Client.connect('0.0.0.0', 6379) 12 | redis.auth(engine_opts[:password]) 13 | redis.flushall 14 | end 15 | 16 | it_should_behave_like "faye engine" 17 | it_should_behave_like "distributed engine" 18 | 19 | next if ENV["TRAVIS"] 20 | 21 | describe "using a Unix socket" do 22 | before { engine_opts[:socket] = "/tmp/redis.sock" } 23 | it_should_behave_like "faye engine" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/redis.conf: -------------------------------------------------------------------------------- 1 | daemonize no 2 | pidfile /tmp/redis.pid 3 | port 6379 4 | unixsocket /tmp/redis.sock 5 | timeout 300 6 | loglevel verbose 7 | logfile stdout 8 | databases 16 9 | 10 | save 900 1 11 | save 300 10 12 | save 60 10000 13 | 14 | rdbcompression yes 15 | dbfilename dump.rdb 16 | dir ./ 17 | 18 | slave-serve-stale-data yes 19 | 20 | requirepass foobared 21 | 22 | appendonly no 23 | appendfsync everysec 24 | no-appendfsync-on-rewrite no 25 | 26 | vm-enabled no 27 | vm-swap-file /tmp/redis.swap 28 | vm-max-memory 0 29 | vm-page-size 32 30 | vm-pages 134217728 31 | vm-max-threads 4 32 | 33 | hash-max-zipmap-entries 512 34 | hash-max-zipmap-value 64 35 | 36 | list-max-ziplist-entries 512 37 | list-max-ziplist-value 64 38 | 39 | set-max-intset-entries 512 40 | 41 | activerehashing yes 42 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../lib/faye/redis', __FILE__) 2 | require 'websocket/driver' 3 | require File.expand_path('../../vendor/faye/spec/ruby/engine_examples', __FILE__) 4 | 5 | class << Faye 6 | attr_accessor :logger 7 | end 8 | --------------------------------------------------------------------------------