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 +56 -0
- data/lib/tom_queue/deferred_work_manager.rb +233 -0
- data/lib/tom_queue/deferred_work_set.rb +165 -0
- data/lib/tom_queue/delayed_job.rb +33 -0
- data/lib/tom_queue/delayed_job/external_messages.rb +56 -0
- data/lib/tom_queue/delayed_job/job.rb +365 -0
- data/lib/tom_queue/external_consumer.rb +136 -0
- data/lib/tom_queue/logging_helper.rb +19 -0
- data/lib/tom_queue/queue_manager.rb +264 -0
- data/lib/tom_queue/sorted_array.rb +69 -0
- data/lib/tom_queue/work.rb +62 -0
- data/spec/database.yml +14 -0
- data/spec/helper.rb +75 -0
- data/spec/tom_queue/deferred_work/deferred_work_manager_integration_spec.rb +186 -0
- data/spec/tom_queue/deferred_work/deferred_work_manager_spec.rb +134 -0
- data/spec/tom_queue/deferred_work/deferred_work_set_spec.rb +134 -0
- data/spec/tom_queue/delayed_job/delayed_job_integration_spec.rb +155 -0
- data/spec/tom_queue/delayed_job/delayed_job_spec.rb +818 -0
- data/spec/tom_queue/external_consumer_integration_spec.rb +225 -0
- data/spec/tom_queue/helper.rb +91 -0
- data/spec/tom_queue/logging_helper_spec.rb +152 -0
- data/spec/tom_queue/queue_manager_spec.rb +218 -0
- data/spec/tom_queue/sorted_array_spec.rb +160 -0
- data/spec/tom_queue/tom_queue_integration_spec.rb +296 -0
- data/spec/tom_queue/tom_queue_spec.rb +30 -0
- data/spec/tom_queue/work_spec.rb +35 -0
- data/tom_queue.gemspec +21 -0
- metadata +137 -0
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
|