tom_queue 0.0.1.dev

Sign up to get free protection for your applications and to get access to all the features.
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