├── .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 [](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 |
--------------------------------------------------------------------------------