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 +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
|