captainu-tincan 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)