captainu-tincan 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,67 @@
1
+ require 'date'
2
+ require 'json'
3
+ require 'tincan/message'
4
+
5
+ module Tincan
6
+ # Encapsulates a failed attempt at a message attempted from a Redis queue.
7
+ class Failure
8
+ attr_accessor :failed_at, :attempt_count, :message_id, :queue_name
9
+
10
+ # Creates a new instance of a notification with an object (usually an
11
+ # ActiveModel instance).
12
+ # @param [Integer] message_id The identifier for the message to retry.
13
+ # @param [String] queue_name The name of the queue in which this failure
14
+ # originally occurred.
15
+ # @return [Tincan::Message] An instance of this class.
16
+ def initialize(message_id = nil, queue_name = nil)
17
+ self.message_id = message_id
18
+ self.attempt_count = 0
19
+ self.failed_at = DateTime.now
20
+ self.queue_name = queue_name
21
+ end
22
+
23
+ # Gives a date and time when this object is allowed to be attempted again,
24
+ # derived from when it last failed, plus the number of attempts, in
25
+ # seconds.
26
+ # @return [DateTime] The date/time when this is allowed to be retried.
27
+ def attempt_after
28
+ failed_at + Rational((attempt_count * 10), 86400)
29
+ end
30
+
31
+ # Deserializes an object from a JSON string.
32
+ # @param [String] json A JSON string to be decoded.
33
+ # @return [Tincan::Failure] A deserialized failure.
34
+ def self.from_json(json)
35
+ from_hash(JSON.parse(json))
36
+ end
37
+
38
+ # Assigns keys and values to this object based on an already-deserialized
39
+ # JSON object.
40
+ # @param [Hash] hash A hash of properties and their values.
41
+ # @return [Tincan::Failure] A failure.
42
+ def self.from_hash(hash)
43
+ instance = new(hash['message_id'], hash['queue_name'])
44
+ instance.attempt_count = hash['attempt_count'].to_i
45
+ instance
46
+ end
47
+
48
+ # Generates a version of this failure as a JSON string.
49
+ # @return [String] A JSON-compliant marshalling of this instance's data.
50
+ def to_json(options = {})
51
+ Hash[%i(failed_at attempt_count message_id queue_name).map do |name|
52
+ [name, send(name)]
53
+ end].to_json(options)
54
+ end
55
+
56
+ # Object overrides
57
+
58
+ # Overrides equality operator to determine if all ivars are equal
59
+ def ==(other)
60
+ false unless other
61
+ checks = %i(failed_at attempt_count message_id queue_name).map do |p|
62
+ other.send(p) == send(p)
63
+ end
64
+ checks.all?
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,79 @@
1
+ require 'date'
2
+ require 'json'
3
+ require 'active_support/inflector'
4
+
5
+ module Tincan
6
+ # Encapsulates a message published to (and received from) a Redis "tincan"
7
+ # queue.
8
+ class Message
9
+ attr_accessor :object_name, :change_type, :object_data, :published_at
10
+
11
+ # Creates a new instance of a notification with an object (usually an
12
+ # ActiveModel instance).
13
+ # @param [Block] Takes an optional block to configure this instance.
14
+ # @return [Tincan::Message] An instance of this class.
15
+ def initialize
16
+ yield self if block_given?
17
+ self.published_at ||= DateTime.now
18
+ end
19
+
20
+ # Deserializes an object from a JSON string.
21
+ # @param [String] json A JSON string to be decoded.
22
+ # @return [Tincan::Message] A deserialized notification.
23
+ def self.from_json(json)
24
+ from_hash(JSON.parse(json))
25
+ end
26
+
27
+ # Assigns keys and values to this object based on an already-deserialized
28
+ # JSON object.
29
+ # @param [Hash] hash A hash of properties and their values.
30
+ # @return [Tincan::Message] A message.
31
+ def self.from_hash(hash)
32
+ instance = new do |i|
33
+ hash.each do |key, val|
34
+ val = DateTime.iso8601(val) if key == 'published_at'
35
+ i.send("#{key}=".to_sym, val)
36
+ end
37
+ end
38
+ instance
39
+ end
40
+
41
+ # Checks for proper change type and sets it if it's valid.
42
+ # @param [Symbol] value :create, :modify, or :delete.
43
+ def change_type=(value)
44
+ if %i(create modify delete).include?(value.to_sym)
45
+ @change_type = value.to_sym
46
+ else
47
+ fail ArgumentError, ':change_type must be :create, :modify or :delete'
48
+ end
49
+ end
50
+
51
+ # Generates a version of this notification as a JSON string.
52
+ # @return [String] A JSON-compliant marshalling of this instance's data.
53
+ def to_json(options = {})
54
+ # Note: at some point I may want to override how this is done. I think
55
+ # Rabl could definitely be of some use here.
56
+ Hash[%i(object_name change_type object_data published_at).map do |name|
57
+ [name, send(name)]
58
+ end].to_json(options)
59
+ end
60
+
61
+ # Object overrides
62
+
63
+ # Overrides equality operator to determine if all ivars are equal
64
+ def ==(other)
65
+ false unless other
66
+ checks = %i(object_name change_type object_data published_at).map do |p|
67
+ other.send(p) == send(p)
68
+ end
69
+ checks.all?
70
+ end
71
+
72
+ def to_s
73
+ vars = instance_variables.map do |v|
74
+ "#{v}=#{instance_variable_get(v).inspect}"
75
+ end.join(', ')
76
+ "<#{self.class}: #{vars}>"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,167 @@
1
+ require 'tincan/failure'
2
+ require 'tincan/message'
3
+ require 'redis'
4
+
5
+ module Tincan
6
+ # An object whose purpose is to listen to a variety of Redis queues and fire
7
+ # off notifications when triggered.
8
+ class Receiver
9
+ attr_reader :config
10
+ attr_accessor :client_name, :listen_to, :redis_host, :redis_port,
11
+ :namespace, :on_exception, :logger
12
+
13
+ # Lifecycle methods
14
+
15
+ # Creates and return a listener object, ready to listen. You can pass in
16
+ # either a hash or a block; the block takes priority.
17
+ # @param [Hash] options A list of keys/values to assign to this instance.
18
+ # @return [Tincan::Receiver] Self.
19
+ def initialize(options = {})
20
+ if block_given?
21
+ yield(self)
22
+ else
23
+ @config = options
24
+ ivars = %i(client_name listen_to redis_host redis_port namespace
25
+ on_exception logger)
26
+ ivars.each { |n| send("#{n}=".to_sym, @config[n]) }
27
+ end
28
+ self.redis_port ||= 6379
29
+ end
30
+
31
+ # Related objects
32
+
33
+ # The instance of a Redis communicator that can subscribe messages.
34
+ # @return [Redis] The Redis client used by this object.
35
+ def redis_client
36
+ @redis_client ||= ::Redis.new(host: redis_host, port: redis_port)
37
+ end
38
+
39
+ # Transactional (submission) methods
40
+
41
+ # Registers this receiver against a Redis set based on the object name.
42
+ # Looks like "namespace:object_name:receivers".
43
+ # @return [Tincan::Receiver] Self.
44
+ def register
45
+ listen_to.keys.each do |object_name|
46
+ receiver_list_key = key_for_elements(object_name, 'receivers')
47
+ logger.info "Registered against Tincan set #{receiver_list_key}"
48
+ redis_client.sadd(receiver_list_key, client_name)
49
+ end
50
+ self
51
+ end
52
+
53
+ # Handles putting a message identifier into a failed "retries" list.
54
+ # @param [Integer] message_id The identifier of the failed message.
55
+ # @param [String] original_list The name of the originating list.
56
+ # @return [Integer] The number of failed entries in the same list.
57
+ def store_failed_message(message_id, original_list)
58
+ logger.warn "Storing failure #{message_id} for list #{original_list}"
59
+ failure = Failure.new(message_id, original_list)
60
+ store_failure(failure)
61
+ end
62
+
63
+ # Handles putting a message identifier into a failed "retries" list.
64
+ # @param [Tincan::Failure] failure The failure to store.
65
+ # @return [Integer] The number of failed entries in the same list.
66
+ def store_failure(failure)
67
+ error_list = failure.queue_name.gsub('messages', 'failures')
68
+ redis_client.rpush(error_list, failure.to_json)
69
+ end
70
+
71
+ # Message handling methods
72
+
73
+ # Iterates through stored lambdas for a given object, and passes the
74
+ # message to all of them.
75
+ # @param [String] object_name The object name gleamed from the list key.
76
+ # @param [Tincan::Message] message The Message generated from the JSON
77
+ # hash retrieved from Redis.
78
+ def handle_message_for_object(object_name, message)
79
+ logger.debug "Encountered #{object_name} message: #{message.object_data}"
80
+ listen_to[object_name.to_sym].each do |stored_lambda|
81
+ stored_lambda.call(message)
82
+ end
83
+ end
84
+
85
+ # Loop methods
86
+
87
+ # Registers and subscribes. That is all.
88
+ def listen
89
+ register
90
+ subscribe
91
+ end
92
+
93
+ # Formatting and helper methods
94
+
95
+ # Asks the instance of Redis for the proper JSON data for a message, and
96
+ # then turns that into a Tincan::Message.
97
+ # @param [Integer] message_id The numerical ID of the message to retrieve.
98
+ # @param [String] object_name The object name of the message to retrieve;
99
+ # this helps the receiver determine which list it was
100
+ # posted to.
101
+ # @return [Tincan::Message] An initialized Message, or nil if not found.
102
+ def message_for_id(message_id, object_name)
103
+ key = key_for_elements(object_name, 'messages', message_id)
104
+ json = redis_client.get(key)
105
+ return nil unless json
106
+ Message.from_json(json)
107
+ end
108
+
109
+ # A flattened list of message list keys, in the format of
110
+ # "namespace:object_name:client:messages".
111
+ # @return [Array] An array of object_names formatted with client name, like
112
+ # "namespace:object_name:client:messages".
113
+ def message_list_keys
114
+ @message_list_keys ||= listen_to.keys.map do |object_name|
115
+ %w(messages failures).map do |type|
116
+ key_for_elements(object_name, client_name, type)
117
+ end
118
+ end.flatten
119
+ end
120
+
121
+ private
122
+
123
+ # Loops on a blocking pop call to a series of message lists on Redis and
124
+ # yields a given block when triggered. Handles turning JSON back into
125
+ # notification messages. Uses `BLPOP` as a blocking pop, indefinitely,
126
+ # until we get a message.
127
+ def subscribe
128
+ logger.info 'Awaiting new messages from Tincan.'
129
+ loop do
130
+ begin
131
+ message_list, content = redis_client.blpop(message_list_keys)
132
+ object_name = message_list.split(':')[1]
133
+ message_type = message_list.split(':').last
134
+
135
+ if message_type == 'messages'
136
+ message = message_for_id(content, object_name)
137
+ elsif message_type == 'failures'
138
+ failure = Failure.from_json(content)
139
+ if failure.attempt_after > DateTime.now
140
+ store_failure(failure)
141
+ next
142
+ end
143
+ message = message_for_id(failure.message_id, object_name)
144
+ end
145
+
146
+ handle_message_for_object(object_name, message) if message
147
+ rescue Interrupt
148
+ logger.warn 'Encountered interrupt.'
149
+ raise
150
+ rescue Exception => e
151
+ logger.warn "Encountered exception #{e}."
152
+ on_exception.call(e, {}) if on_exception
153
+ next unless content
154
+ failure ||= Failure.new(content, message_list)
155
+ failure.attempt_count += 1
156
+ store_failure(failure)
157
+ end
158
+ end
159
+ end
160
+
161
+ # Joins a variadic set of elements along with a namespace with colons.
162
+ # @return [String] A joined string to be used as a Redis key.
163
+ def key_for_elements(*elements)
164
+ ([namespace] + elements).join(':')
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,101 @@
1
+ require 'tincan/message'
2
+ require 'redis'
3
+
4
+ module Tincan
5
+ # An object whose purpose is to send messages to a given series of Redis
6
+ # message queues for those receiving them.
7
+ class Sender
8
+ attr_reader :config
9
+ attr_accessor :redis_host, :redis_port, :namespace
10
+
11
+ # Lifecycle methods
12
+
13
+ # Creates and return a sender object, ready to send. You can pass in
14
+ # either a hash or a block; the block takes priority.
15
+ # @param [Hash] options A list of keys/values to assign to this instance.
16
+ # @return [Tincan::Receiver] Self.
17
+ def initialize(options = {})
18
+ if block_given?
19
+ yield(self)
20
+ else
21
+ @config = options
22
+ ivars = %i(redis_host redis_port namespace)
23
+ ivars.each { |n| send("#{n}=".to_sym, @config[n]) }
24
+ end
25
+ self.redis_port ||= 6379
26
+ end
27
+
28
+ # Related objects
29
+
30
+ # The instance of a Redis communicator that can publish messages.
31
+ # @return [Redis] The Redis client used by this object.
32
+ def redis_client
33
+ @redis_client ||= ::Redis.new(host: redis_host, port: redis_port)
34
+ end
35
+
36
+ # Transactional (lookup) methods
37
+
38
+ # Asks Redis for the set of all active receivers and generates string
39
+ # keys for all of them. Formatted like "namespace:object:client:messages".
40
+ # @return [Array] An array of keys identifying all receiver pointer lists.
41
+ def keys_for_receivers(object_name)
42
+ receiver_list_key = key_for_elements(object_name, 'receivers')
43
+ receivers = redis_client.smembers(receiver_list_key)
44
+ receivers.map do |receiver|
45
+ key_for_elements(object_name, receiver, 'messages')
46
+ end
47
+ end
48
+
49
+ # Tells Redis to delete any pending messages for registered receivers, by
50
+ # essentially deleting the message key.
51
+ # @return [Boolean] True, just because.
52
+ def flush_all_queues_for_object(object_name)
53
+ keys_for_receivers(object_name).each do |key|
54
+ redis_client.del(key)
55
+ end
56
+ true
57
+ end
58
+
59
+ # Communication methods
60
+
61
+ # Bundles up an object in a message object and publishes it to the Redis
62
+ # host.
63
+ # @param [Tincan::Message] message The message to publish.
64
+ # @param [Symbol] change_type :create, :modify, or :delete.
65
+ # @return [Boolean] true if the operation was a success.
66
+ def publish(message)
67
+ identifier = identifier_for_message(message)
68
+ redis_client.set(primary_key_for_message(message), message.to_json)
69
+ keys_for_receivers(message.object_name.downcase).each do |key|
70
+ redis_client.rpush(key, identifier)
71
+ end
72
+ true
73
+ end
74
+
75
+ # Formatting and helper methods
76
+
77
+ # Generates an identifier to be used for a message. It's unique!
78
+ # @param [Tincan::Message] message The message for which to generate a
79
+ # unique identifier.
80
+ # @return [Integer] A unique identifier number for the message.
81
+ def identifier_for_message(message)
82
+ message.published_at.to_time.to_i
83
+ end
84
+
85
+ # Generates a key to be used as the primary destination key in Redis.
86
+ # @param [Tincan::Message] message The message to use in key generation.
87
+ # @return [String] A properly-formatted key to be used with Redis.
88
+ def primary_key_for_message(message)
89
+ identifier = identifier_for_message(message)
90
+ key_for_elements(message.object_name.downcase, 'messages', identifier)
91
+ end
92
+
93
+ private
94
+
95
+ # Joins a variadic set of elements along with a namespace with colons.
96
+ # @return [String] A joined string to be used as a Redis key.
97
+ def key_for_elements(*elements)
98
+ ([namespace] + elements).join(':')
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,3 @@
1
+ module Tincan
2
+ VERSION = '0.7.0'
3
+ end
data/license.markdown ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 CaptainU, LLC.
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/readme.markdown ADDED
@@ -0,0 +1,120 @@
1
+ # Tincan
2
+
3
+ [![Build Status](https://travis-ci.org/captainu/tincan.svg?branch=master)](https://travis-ci.org/captainu/tincan) [![Code Climate](https://codeclimate.com/github/captainu/tincan.png)](https://codeclimate.com/github/captainu/tincan) [![Code Climate](https://codeclimate.com/github/captainu/tincan/coverage.png)](https://codeclimate.com/github/captainu/tincan)
4
+
5
+ Provides an easy way to register senders and receivers on a reliable Redis message queue, to be used in lieu of Redis's own pub/sub commands (which are connection-reliant). This uses Redis's lists and sets using a defined and namespaced series of keys that allows for a *sender* to publish structured notification messages to a Redis server, which then get referenced in multiple receiver-specific lists, all of which are being watched by a client running a blocking pop (Redis's `BLPOP` command). These clients, known as *receivers*, handle the messages and route them to any number of custom-defined Ruby lambdas.
6
+
7
+ This is a Ruby implementation of [David Marquis's outstanding post](http://davidmarquis.wordpress.com/2013/01/03/reliable-delivery-message-queues-with-redis/) about reliable delivery message queues with Redis. It borrows heavily from the amazing [Sidekiq](https://github.com/mperham/sidekiq)'s design regarding its command-line and deployment interface.
8
+
9
+ See below for some usage examples (more coming soon).
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ``` ruby
16
+ gem 'captainu-tincan', github: 'captainu/tincan', require: 'tincan'
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ``` bash
22
+ $ bundle install
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Sender
28
+
29
+ ``` ruby
30
+ sender = Tincan::Sender.new do |config|
31
+ config.redis_host = 'localhost'
32
+ config.namespace = 'tincan' # Or whatever you'd like
33
+ end
34
+
35
+ # some_object here is something that responds to #to_json
36
+ sender.publish(some_object, :create)
37
+ ```
38
+
39
+ In order to reset a list of messages for all listening receivers, initiate a sender (like demonstrated above), and perform the following (where `some_object` is the singlar, underscored-and-downcased version of the name of the Class for which to nuke messages).
40
+
41
+ ``` ruby
42
+ sender.flush_all_queues_for_object('some_object')
43
+ ```
44
+
45
+ ### Receiver
46
+
47
+ ``` ruby
48
+ receiver = Tincan::Receiver.new do |config|
49
+ config.redis_host = 'localhost'
50
+ config.client_name = 'my-receiver'
51
+ config.namespace = 'tincan' # Same as above.
52
+ config.logger = ::Logger.new(STDOUT)
53
+ config.listen_to = {
54
+ college: [
55
+ ->(data) { SomeThing.handle_data(data) },
56
+ ->(data) { SomeOtherThing.handle_same_data(data) }
57
+ ],
58
+ college_team: [
59
+ -> (data) { AnotherThing.handle_this_data(data) }
60
+ ]
61
+ }
62
+ config.on_exception = lambda do |ex, context|
63
+ Airbrake.notify_or_ignore(ex, parameters: context)
64
+ end
65
+ end
66
+
67
+ # This call blocks and loops
68
+ receiver.listen
69
+ ```
70
+
71
+ #### Rails integration for Receiver
72
+
73
+ Drop a `tincan.yml` file in your Rails project at `config/tincan.yml`, and make it look like this!
74
+
75
+ ``` yaml
76
+ defaults: &defaults
77
+ redis_host: localhost
78
+ listen_to:
79
+ college:
80
+ - SomeModel.update_from_tincan
81
+ college_team:
82
+ - SomeOtherModel.update_from_tincan
83
+
84
+ development:
85
+ <<: *defaults
86
+ namespace: tincan-development
87
+ client_name: your_app-dev
88
+
89
+ test:
90
+ <<: *defaults
91
+ namespace: tincan-test
92
+ client_name: your_app-test
93
+
94
+ production:
95
+ <<: *defaults
96
+ namespace: tincan
97
+ client_name: your_app
98
+ ```
99
+
100
+ Then, a command-line `tincan` worker is just a few keystrokes away.
101
+
102
+ ``` bash
103
+ $ bundle exec tincan
104
+ ```
105
+
106
+ You can even daemonize it with `-d`. The command-line library is largely modeled after [Sidekiq](https://github.com/mperham/sidekiq), and is currently in dire need of some tests. Use at your own risk.
107
+
108
+ For integration with Capistrano, see [capistrano-tincan](https://github.com/captainu/capistrano-tincan).
109
+
110
+ ## Contributing
111
+
112
+ 1. [Fork it](https://github.com/captainu/tincan/fork)!
113
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
114
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
115
+ 4. Push to the branch (`git push origin my-new-feature`)
116
+ 5. Create a new pull request
117
+
118
+ ## Contributors
119
+
120
+ - [Ben Kreeger](https://github.com/kreeger)