tom_queue 0.0.1.dev

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.
data/lib/tom_queue.rb ADDED
@@ -0,0 +1,56 @@
1
+ # This is a bit of a library-in-a-library, for the time being
2
+ #
3
+ # This module manages the interaction with AMQP, handling publishing of
4
+ # work messages, scheduling of work off the AMQP queue, etc.
5
+ #
6
+ ##
7
+ #
8
+ # You probably want to start with TomQueue::QueueManager
9
+ #
10
+ module TomQueue
11
+ require 'tom_queue/logging_helper'
12
+
13
+ require 'tom_queue/queue_manager'
14
+ require 'tom_queue/work'
15
+
16
+ require 'tom_queue/deferred_work_set'
17
+ require 'tom_queue/deferred_work_manager'
18
+
19
+ require 'tom_queue/external_consumer'
20
+
21
+ require 'tom_queue/sorted_array'
22
+
23
+ # Public: Sets the bunny instance to use for new QueueManager objects
24
+ def bunny=(new_bunny)
25
+ @@bunny = new_bunny
26
+ end
27
+ # Public: Returns the current bunny instance
28
+ #
29
+ # Returns whatever was passed to TomQueue.bunny =
30
+ def bunny
31
+ defined?(@@bunny) && @@bunny
32
+ end
33
+ module_function :bunny, :bunny=
34
+
35
+ def default_prefix=(new_default_prefix)
36
+ @@default_prefix = new_default_prefix
37
+ end
38
+ def default_prefix
39
+ defined?(@@default_prefix) && @@default_prefix
40
+ end
41
+ module_function :default_prefix=, :default_prefix
42
+
43
+
44
+ # Public: Set an object to receive notifications if an internal exception
45
+ # is caught and handled.
46
+ #
47
+ # IT should be an object that responds to #notify(exception) and should be
48
+ # thread safe as reported exceptions will be from background threads crashing.
49
+ #
50
+ class << self
51
+ attr_accessor :exception_reporter
52
+ attr_accessor :logger
53
+ end
54
+
55
+
56
+ end
@@ -0,0 +1,233 @@
1
+ require 'tom_queue/work'
2
+ module TomQueue
3
+
4
+ # Internal: This is an internal class that oversees the delay of "deferred"
5
+ # work, that is, work with a future :run_at value.
6
+ #
7
+ # There is a singleton object for each prefix value (really, there should only be a single
8
+ # prefix used. The queue manager ensures this thread is running whenever #pop is called.
9
+ # Work is also pushed to this maanger by the QueueManager when it needs to be deferred.
10
+ #
11
+ # Internally, this class opens a separate AMQP channel (without a prefetch limit) and
12
+ # leaves all the deferred messages in an un-acked state. An internal timer is maintained
13
+ # to delay until the next message is ready to be sent, at which point the message is
14
+ # dispatched back to the QueueManager, and at some point will be processed by a worker.
15
+ #
16
+ # If the host process of this manager dies for some reason, the broker will re-queue the
17
+ # un-acked messages onto the deferred queue, to be re-popped by another worker in the pool.
18
+ #
19
+ class DeferredWorkManager
20
+
21
+ include LoggingHelper
22
+
23
+ # Public: Scoped singleton accessor
24
+ #
25
+ # This returns the shared work manager instance, creating it if necessary.
26
+ #
27
+ # Returns a DeferredWorkManager instance
28
+ @@singletons_mutex = Mutex.new
29
+ @@singletons = {}
30
+ def self.instance(prefix = nil)
31
+ prefix ||= TomQueue.default_prefix
32
+ prefix || raise(ArgumentError, 'prefix is required')
33
+
34
+ @@singletons_mutex.synchronize { @@singletons[prefix] ||= self.new(prefix) }
35
+ end
36
+
37
+
38
+ # Public: Return a hash of all prefixed singletons, keyed on their prefix
39
+ #
40
+ # This method really is just a convenience method for testing.
41
+ #
42
+ # NOTE: The returned hash is both a dupe and frozen, so should be safe to
43
+ # iterate and mutate instances.
44
+ #
45
+ # Returns: { "prefix" => DeferredWorkManager(prefix), ... }
46
+ def self.instances
47
+ @@singletons.dup.freeze
48
+ end
49
+
50
+ # Public: Shutdown all managers and wipe the singleton objects.
51
+ # This method is really just a hook for testing convenience.
52
+ #
53
+ # Returns nil
54
+ def self.reset!
55
+ @@singletons_mutex.synchronize do
56
+ @@singletons.each_pair do |k,v|
57
+ v.ensure_stopped
58
+ end
59
+ @@singletons = {}
60
+ end
61
+ nil
62
+ end
63
+
64
+ # Public: Return the AMQP prefix used by this manager
65
+ #
66
+ # Returns string
67
+ attr_reader :prefix
68
+
69
+ # Internal: Creates the singleton instance, please use the singleton accessor!
70
+ #
71
+ # prefix - the AMQP prefix for this instance
72
+ #
73
+ def initialize(prefix)
74
+ @prefix = prefix
75
+ @thread = nil
76
+ end
77
+
78
+ # Public: Handle a deferred message
79
+ #
80
+ # work - (String) the work payload
81
+ # opts - (Hash) the options of the message. See QueueManager#publish, but must include:
82
+ # :run_at = (Time) when the work should be run
83
+ #
84
+ def handle_deferred(work, opts)
85
+ run_at = opts[:run_at]
86
+ raise ArgumentError, 'work must be a string' unless work.is_a?(String)
87
+ raise ArgumentError, ':run_at must be specified' if run_at.nil?
88
+ raise ArgumentError, ':run_at must be a Time object if specified' unless run_at.is_a?(Time)
89
+
90
+ debug "[handle_deferred] Pushing work onto deferred exchange: '#{@prefix}.work.deferred'"
91
+ # Push this work on to the deferred exchange
92
+ channel = TomQueue.bunny.create_channel
93
+ exchange, _ = setup_amqp(channel)
94
+
95
+ exchange.publish(work, {
96
+ :mandatory => true,
97
+ :headers => opts.merge(:run_at => run_at.to_f)
98
+
99
+ })
100
+ channel.close
101
+ end
102
+
103
+ # Public: Return the Thread associated with this manager
104
+ #
105
+ # Returns Ruby Thread object, or nil if it's not running
106
+ attr_reader :thread
107
+
108
+ # Public: Ensure the thread is running, starting if necessary
109
+ #
110
+ def ensure_running
111
+ @thread_shutdown = false
112
+ @thread = nil unless @thread && @thread.alive?
113
+ @thread ||= begin
114
+ debug "[ensure_running] Starting thread"
115
+ Thread.new(&method(:thread_main))
116
+ end
117
+ end
118
+
119
+ # Public: Ensure the thread shuts-down and stops. Blocks until
120
+ # the thread has actually shut down
121
+ #
122
+ def ensure_stopped
123
+ if @thread
124
+ @thread_shutdown = true
125
+ @deferred_set && @deferred_set.interrupt
126
+ @thread.join
127
+ end
128
+ end
129
+
130
+ # Internal: Creates the bound exchange and queue for deferred work on the provided channel
131
+ #
132
+ # channel - a Bunny channel to use to create the queue / exchange binding. If you intend
133
+ # to use the returned channel / exchange objects, they will be accessed via
134
+ # this channel.
135
+ #
136
+ # Returns [ <exchange object>, <queue object> ]
137
+ def setup_amqp(channel)
138
+ exchange = channel.fanout("#{prefix}.work.deferred",
139
+ :durable => true,
140
+ :auto_delete => false)
141
+
142
+ queue = channel.queue("#{prefix}.work.deferred",
143
+ :durable => true,
144
+ :auto_delete => false).bind(exchange.name)
145
+
146
+ [exchange, queue]
147
+ end
148
+
149
+ ##### Thread Internals #####
150
+ #
151
+ # Cross this barrier with care :)
152
+ #
153
+
154
+ # Internal: This is called on a bunny internal work thread when
155
+ # a new message arrives on the deferred work queue.
156
+ #
157
+ # A given message will be delivered only to one deferred manager
158
+ # so we simply schedule the message in our DeferredWorkSet (which
159
+ # helpfully deals with all the cross-thread locking, etc.)
160
+ #
161
+ # response - the AMQP response object from Bunny
162
+ # headers - (Hash) a hash of headers associated with the message
163
+ # payload - (String) the message payload
164
+ #
165
+ #
166
+ def thread_consumer_callback(response, headers, payload)
167
+ run_at = Time.at(headers[:headers]['run_at'])
168
+
169
+ # schedule it in the work set
170
+ @deferred_set.schedule(run_at, [response, headers, payload])
171
+ rescue Exception => e
172
+ r = TomQueue.exception_reporter
173
+ r && r.notify(e)
174
+
175
+ ### Avoid tight spinning workers by not re-queueing redlivered messages more than once!
176
+ response.channel.reject(response.delivery_tag, !response.redelivered?)
177
+ end
178
+
179
+ # Internal: The main loop of the thread
180
+ #
181
+ def thread_main
182
+ debug "[thread_main] Deferred thread starting up"
183
+
184
+ # Make sure we're low priority!
185
+ Thread.current.priority = -10
186
+
187
+ # Create a dedicated channel, and ensure it's prefetch
188
+ # means we'll empty the queue
189
+ @channel = TomQueue.bunny.create_channel
190
+ @channel.prefetch(0)
191
+
192
+ # Create an exchange and queue
193
+ _, queue = setup_amqp(@channel)
194
+
195
+ @deferred_set = DeferredWorkSet.new
196
+
197
+ @out_manager = QueueManager.new(prefix)
198
+
199
+ # This block will get called-back for new messages
200
+ consumer = queue.subscribe(:ack => true, &method(:thread_consumer_callback))
201
+
202
+ # This is the core event loop - we block on the deferred set to return messages
203
+ # (which have been scheduled by the AMQP consumer). If a message is returned
204
+ # then we re-publish the messages to our internal QueueManager and ack the deferred
205
+ # message
206
+ until @thread_shutdown
207
+
208
+ # This will block until work is ready to be returned, interrupt
209
+ # or the 10-second timeout value.
210
+ response, headers, payload = @deferred_set.pop(2)
211
+
212
+ if response
213
+ headers[:headers].delete('run_at')
214
+ @out_manager.publish(payload, headers[:headers])
215
+ @channel.ack(response.delivery_tag)
216
+ end
217
+ end
218
+
219
+ consumer.cancel
220
+
221
+ rescue
222
+ reporter = TomQueue.exception_reporter
223
+ reporter && reporter.notify($!)
224
+
225
+ ensure
226
+ @channel && @channel.close
227
+ @deferred_set = nil
228
+ @thread = nil
229
+ end
230
+
231
+ end
232
+
233
+ end
@@ -0,0 +1,165 @@
1
+ module TomQueue
2
+
3
+ # Internal: This class wraps the pool of work items that are waiting for their run_at
4
+ # time to be reached.
5
+ #
6
+ # It also incorporates the logic and coordination required to stall a thread until the
7
+ # work is ready to run.
8
+ #
9
+ class DeferredWorkSet
10
+
11
+ # Internal: A wrapper object to store the run at and the opaque work object inside the @work array.
12
+ class Element < Struct.new(:run_at, :work)
13
+ include Comparable
14
+
15
+ # Internal: An integer version of run_at, less precise, but faster to compare.
16
+ attr_reader :fast_run_at
17
+
18
+ # Internal: Creates an element. This is called by DeferredWorkSet as work is scheduled so
19
+ # shouldn't be done directly.
20
+ #
21
+ # run_at - (Time) the time when this job should be run
22
+ # work - (Object) a payload associated with this work. Just a plain ruby
23
+ # object that it is up to the caller to interpret
24
+ #
25
+ def initialize(run_at, work)
26
+ super(run_at, work)
27
+ @fast_run_at = (run_at.to_f * 1000).to_i
28
+ end
29
+
30
+ # Internal: Comparison function, referencing the scheduled run-time of the element
31
+ #
32
+ # NOTE: We don't compare the Time objects directly as this is /dog/ slow, as is comparing
33
+ # float objects, and this function will be called a /lot/ - so we compare reasonably
34
+ # accurate integer values created in the initializer.
35
+ #
36
+ def <=> (other)
37
+ fast_run_at <=> other.fast_run_at
38
+ end
39
+
40
+ # Internal: We need to override this in order for elements with the same run_at not to be deleted
41
+ # too soon.
42
+ #
43
+ # When #<=> is used with `Comparable`, we get #== for free, but when it operates using run_at, it has
44
+ # the undesirable side-effect that when Array#delete is called with an element, all other elements with
45
+ # the same run_at are deleted, too (since #== is used by Array#delete).
46
+ #
47
+ def == (other)
48
+ false
49
+ end
50
+ end
51
+
52
+ def initialize
53
+ @mutex = Mutex.new
54
+ @condvar = ConditionVariable.new
55
+ @work = TomQueue::SortedArray.new
56
+ end
57
+
58
+ # Public: Returns the integer number of elements in the set
59
+ #
60
+ # Returns integer
61
+ def size
62
+ @work.size
63
+ end
64
+
65
+ # Public: Block the calling thread until some work is ready to run
66
+ # or the timeout expires.
67
+ #
68
+ # This is intended to be called from a single worker thread, for the
69
+ # time being, if you try and block on this method concurrently in
70
+ # two threads, it will raise an exception!
71
+ #
72
+ # timeout - (Fixnum, seconds) how long to wait before timing out
73
+ #
74
+ # Returns previously scheduled work, or
75
+ # nil if the thread was interrupted or the timeout expired
76
+ def pop(timeout)
77
+ timeout_end = Time.now + timeout
78
+ returned_work = nil
79
+
80
+ @interrupt = false
81
+
82
+ @mutex.synchronize do
83
+ raise RuntimeError, 'DeferredWorkSet: another thread is already blocked on a pop' unless @blocked_thread.nil?
84
+
85
+ begin
86
+ @blocked_thread = Thread.current
87
+
88
+ begin
89
+ end_time = [next_run_at, timeout_end].compact.min
90
+ delay = end_time - Time.now
91
+ @condvar.wait(@mutex, delay) if delay > 0
92
+ end while Time.now < end_time and @interrupt == false
93
+
94
+ element = earliest_element
95
+ if element && element.run_at < Time.now
96
+ @work.delete(element)
97
+ returned_work = element.work
98
+ end
99
+
100
+ ensure
101
+ @blocked_thread = nil
102
+ end
103
+ end
104
+
105
+ returned_work
106
+ end
107
+
108
+ # Public: Interrupt anything sleeping on this set
109
+ #
110
+ # This is "thread-safe" and is designed to be called from threads
111
+ # to interrupt the work loop thread blocked on a pop.
112
+ #
113
+ def interrupt
114
+ @mutex.synchronize do
115
+ @interrupt = true
116
+ @condvar.signal
117
+ end
118
+ end
119
+
120
+ # Public: Add some work to the set
121
+ #
122
+ # This is "threa-safe" in that it can be (and is intended to
123
+ # be) called from threads other than the one calling pop without
124
+ # any additional synchronization.
125
+ #
126
+ # run_at - (Time) when the work is to be run
127
+ # work - the DeferredWork object
128
+ #
129
+ def schedule(run_at, work)
130
+ @mutex.synchronize do
131
+ new_element = Element.new(run_at, work)
132
+ @work << new_element
133
+ @condvar.signal
134
+ end
135
+ end
136
+
137
+ # Public: Returns the temporally "soonest" element in the set
138
+ # i.e. the work that is next to be run
139
+ #
140
+ # Returns the work Object passed to schedule instance or
141
+ # nil if there is no work in the set
142
+ def earliest
143
+ e = earliest_element
144
+ e && e.work
145
+ end
146
+
147
+ # Internal: The next time this thread should next wake up for an element
148
+ #
149
+ # If there are elements in the work set, this will correspond to time of the soonest.
150
+ #
151
+ # Returns a Time object, or nil if there are no elements stored.
152
+ def next_run_at
153
+ e = earliest_element
154
+ e && e.run_at
155
+ end
156
+
157
+ # Internal: The Element object wrapping the work with the soonest run_at value
158
+ #
159
+ # Returns Element object, or nil if the work set is empty
160
+ def earliest_element
161
+ @work.first
162
+ end
163
+ end
164
+
165
+ end