queekiq 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +170 -52
- data/lib/queekiq/backoff.rb +31 -0
- data/lib/queekiq/configuration.rb +7 -1
- data/lib/queekiq/instrumenter.rb +54 -0
- data/lib/queekiq/message.rb +48 -3
- data/lib/queekiq/queue.rb +251 -37
- data/lib/queekiq/serializers.rb +36 -0
- data/lib/queekiq/version.rb +1 -1
- data/lib/queekiq/worker.rb +236 -32
- data/lib/queekiq.rb +25 -2
- metadata +12 -7
data/lib/queekiq/worker.rb
CHANGED
|
@@ -3,36 +3,55 @@ require "socket"
|
|
|
3
3
|
module Queekiq
|
|
4
4
|
# The consumer loop. Each iteration it promotes due deferrals, picks up
|
|
5
5
|
# messages other consumers abandoned, then blocks briefly for new ones; every
|
|
6
|
-
# message goes through the handler and is
|
|
6
|
+
# message goes through the handler and is then settled.
|
|
7
7
|
#
|
|
8
8
|
# worker = Queekiq::Worker.new(queue: queue) do |message|
|
|
9
9
|
# process(message.payload) # return an outcome, e.g. :done or :deferred
|
|
10
10
|
# end
|
|
11
11
|
# worker.start # traps INT/TERM and runs until stopped
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
# outcome is one of `defer_on` (default: :deferred) the payload is put on the
|
|
15
|
-
# queue's deferred set to be retried later. After the handler returns, the
|
|
16
|
-
# message is acked whatever the outcome.
|
|
13
|
+
# What happens to a message after the handler:
|
|
17
14
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
15
|
+
# * returns an outcome in `defer_on` (default :deferred): the message is
|
|
16
|
+
# scheduled to come back later (Queue#defer), or dead-lettered once it has
|
|
17
|
+
# been deferred max_defers times;
|
|
18
|
+
# * returns anything else: it is acked;
|
|
19
|
+
# * raises: the error is logged and passed to `on_error`, then the message is
|
|
20
|
+
# retried after a backoff (Queue#retry_later), or dead-lettered once it has
|
|
21
|
+
# used up max_attempts.
|
|
22
|
+
#
|
|
23
|
+
# While the handler runs, a heartbeat keeps the message from being reclaimed,
|
|
24
|
+
# so slow jobs are fine. If the worker process dies, the heartbeat stops and
|
|
25
|
+
# another worker reclaims the message after reclaim_after_ms.
|
|
26
|
+
#
|
|
27
|
+
# Lost connections are retried with backoff instead of crashing the worker,
|
|
28
|
+
# and a stream or group that vanished (Redis restarted empty) is recreated.
|
|
22
29
|
class Worker
|
|
30
|
+
Failure = Struct.new(:error)
|
|
31
|
+
private_constant :Failure
|
|
32
|
+
|
|
33
|
+
DEFAULT_SHUTDOWN_TIMEOUT = 25
|
|
34
|
+
|
|
23
35
|
attr_reader :queue, :consumer
|
|
24
36
|
|
|
25
37
|
def self.default_consumer
|
|
26
38
|
"#{Socket.gethostname}-#{Process.pid}"
|
|
27
39
|
end
|
|
28
40
|
|
|
29
|
-
# handler
|
|
30
|
-
# consumer
|
|
31
|
-
# defer_on
|
|
32
|
-
# block_ms
|
|
33
|
-
# on_error
|
|
41
|
+
# handler - anything responding to #call(message); alternatively pass a block.
|
|
42
|
+
# consumer - unique per worker process (default: hostname + pid).
|
|
43
|
+
# defer_on - outcomes that trigger Queue#defer.
|
|
44
|
+
# block_ms - how long one read blocks.
|
|
45
|
+
# on_error - optional #call(error, message), e.g. to report to an error tracker.
|
|
46
|
+
# heartbeat_interval_ms - how often to touch the message being handled; default is a third
|
|
47
|
+
# of the queue's reclaim_after_ms, false disables it.
|
|
48
|
+
# shutdown_timeout - seconds #stop waits for the message in hand before abandoning it
|
|
49
|
+
# (it is then reclaimed by another worker); nil waits forever.
|
|
50
|
+
# reconnect_backoff - pause between attempts while Redis is unreachable.
|
|
34
51
|
def initialize(queue:, handler: nil, consumer: self.class.default_consumer, logger: nil,
|
|
35
|
-
defer_on: [ :deferred ], block_ms: 1_000, on_error: nil,
|
|
52
|
+
defer_on: [ :deferred ], block_ms: 1_000, on_error: nil, heartbeat_interval_ms: nil,
|
|
53
|
+
shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
|
|
54
|
+
reconnect_backoff: Backoff.new(base_ms: 500, max_ms: 30_000), &block)
|
|
36
55
|
@handler = handler || block
|
|
37
56
|
raise ArgumentError, "pass a handler: or a block" unless @handler
|
|
38
57
|
|
|
@@ -42,6 +61,9 @@ module Queekiq
|
|
|
42
61
|
@defer_on = Array(defer_on)
|
|
43
62
|
@block_ms = block_ms
|
|
44
63
|
@on_error = on_error
|
|
64
|
+
@heartbeat_interval_ms = heartbeat_interval_ms
|
|
65
|
+
@shutdown_timeout = shutdown_timeout
|
|
66
|
+
@reconnect_backoff = reconnect_backoff
|
|
45
67
|
@running = true
|
|
46
68
|
end
|
|
47
69
|
|
|
@@ -49,9 +71,12 @@ module Queekiq
|
|
|
49
71
|
@logger || Queekiq.logger
|
|
50
72
|
end
|
|
51
73
|
|
|
52
|
-
# Ask the loop to finish
|
|
74
|
+
# Ask the loop to finish the message in hand and return. If that takes longer
|
|
75
|
+
# than shutdown_timeout, the message is abandoned: it stays pending and
|
|
76
|
+
# another worker reclaims it. Safe to call from a signal handler.
|
|
53
77
|
def stop
|
|
54
78
|
@running = false
|
|
79
|
+
start_watchdog
|
|
55
80
|
end
|
|
56
81
|
|
|
57
82
|
# Convenience for worker scripts: unbuffered stdout, graceful stop on
|
|
@@ -63,16 +88,14 @@ module Queekiq
|
|
|
63
88
|
end
|
|
64
89
|
|
|
65
90
|
def run
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
logger.info("[queekiq] #{consumer} stopped")
|
|
91
|
+
@worker_thread = Thread.current
|
|
92
|
+
log(:info, "started")
|
|
93
|
+
supervise
|
|
94
|
+
log(:info, "stopped")
|
|
95
|
+
rescue ShutdownTimeout
|
|
96
|
+
log(:warn, "shutdown_timeout", timeout: @shutdown_timeout)
|
|
97
|
+
ensure
|
|
98
|
+
@watchdog&.kill
|
|
76
99
|
end
|
|
77
100
|
|
|
78
101
|
# Processes messages in the order given. Public so it can be unit tested
|
|
@@ -83,14 +106,195 @@ module Queekiq
|
|
|
83
106
|
|
|
84
107
|
private
|
|
85
108
|
|
|
109
|
+
# The loop, plus recovery from Redis going away.
|
|
110
|
+
def supervise
|
|
111
|
+
ready = false
|
|
112
|
+
failures = 0
|
|
113
|
+
while @running
|
|
114
|
+
begin
|
|
115
|
+
ready ||= prepare
|
|
116
|
+
iterate
|
|
117
|
+
failures = 0
|
|
118
|
+
rescue Redis::BaseConnectionError => e
|
|
119
|
+
ready = false
|
|
120
|
+
failures += 1
|
|
121
|
+
wait_for_redis(e, failures)
|
|
122
|
+
rescue Redis::CommandError => e
|
|
123
|
+
# NOGROUP: the stream or group is gone. UNBLOCKED: it was deleted while
|
|
124
|
+
# we were blocked reading it. Either way, recreate it and carry on.
|
|
125
|
+
raise unless e.message.start_with?("NOGROUP", "UNBLOCKED")
|
|
126
|
+
|
|
127
|
+
ready = false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def prepare
|
|
133
|
+
queue.ensure_group!
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def iterate
|
|
138
|
+
queue.promote_due!
|
|
139
|
+
reclaimed = queue.reclaim(consumer: consumer)
|
|
140
|
+
instrument("reclaim.queekiq", count: reclaimed.size) unless reclaimed.empty?
|
|
141
|
+
handle(reclaimed)
|
|
142
|
+
handle(queue.read(consumer: consumer, block_ms: @block_ms))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def wait_for_redis(error, failures)
|
|
146
|
+
delay = @reconnect_backoff.delay_ms(failures)
|
|
147
|
+
instrument("connection_error.queekiq", error: error, retry_in_ms: delay, failures: failures)
|
|
148
|
+
log(:warn, "redis_unavailable", error: "#{error.class}: #{error.message}", retry_in_ms: delay)
|
|
149
|
+
pause(delay)
|
|
150
|
+
end
|
|
151
|
+
|
|
86
152
|
def process(message)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
153
|
+
return dead_letter(message, :max_attempts) if queue.attempts_exhausted?(message)
|
|
154
|
+
|
|
155
|
+
outcome = run_handler(message)
|
|
156
|
+
return fail_message(message, outcome.error) if outcome.is_a?(Failure)
|
|
157
|
+
|
|
158
|
+
settle(message, outcome)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def run_handler(message)
|
|
162
|
+
instrument("process.queekiq", event(message, latency_ms: message.latency_ms)) do |payload|
|
|
163
|
+
outcome = with_heartbeat(message) { @handler.call(message) }
|
|
164
|
+
payload[:outcome] = outcome
|
|
165
|
+
end
|
|
91
166
|
rescue StandardError => e
|
|
92
|
-
|
|
93
|
-
|
|
167
|
+
Failure.new(e)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def settle(message, outcome)
|
|
171
|
+
if @defer_on.include?(outcome)
|
|
172
|
+
return dead_letter(message, :max_defers) if queue.defers_exhausted?(message)
|
|
173
|
+
|
|
174
|
+
delay = queue.defer(message)
|
|
175
|
+
instrument("defer.queekiq", event(message, delay_ms: delay))
|
|
176
|
+
log(:info, "deferred", message, outcome: outcome, delay_ms: delay)
|
|
177
|
+
else
|
|
178
|
+
queue.ack(message)
|
|
179
|
+
log(:info, "processed", message, outcome: outcome)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def fail_message(message, error)
|
|
184
|
+
log(:error, "failed", message, error: "#{error.class}: #{error.message}")
|
|
185
|
+
report(error, message)
|
|
186
|
+
return dead_letter(message, :max_attempts, error: error) if queue.last_attempt?(message)
|
|
187
|
+
|
|
188
|
+
delay = queue.retry_later(message)
|
|
189
|
+
instrument("retry.queekiq", event(message, delay_ms: delay, error: error))
|
|
190
|
+
log(:warn, "retry", message, delay_ms: delay)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def dead_letter(message, reason, error: nil)
|
|
194
|
+
queue.dead_letter(message, reason: reason, error: error)
|
|
195
|
+
instrument("dead_letter.queekiq", event(message, reason: reason, error: error))
|
|
196
|
+
log(:error, "dead_letter", message, reason: reason)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def report(error, message)
|
|
200
|
+
@on_error&.call(error, message)
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
log(:error, "on_error_failed", message, error: "#{e.class}: #{e.message}")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --- heartbeat -----------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def heartbeat_ms
|
|
208
|
+
return nil if @heartbeat_interval_ms == false
|
|
209
|
+
|
|
210
|
+
@heartbeat_interval_ms || (queue.reclaim_after_ms / 3)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Runs the block while a background thread keeps touching the message. The
|
|
214
|
+
# thread is stopped by a flag (never killed mid-command), so it can't leave
|
|
215
|
+
# the shared Redis connection in a half-read state.
|
|
216
|
+
def with_heartbeat(message)
|
|
217
|
+
interval = heartbeat_ms
|
|
218
|
+
return yield unless interval
|
|
219
|
+
|
|
220
|
+
lock = Mutex.new
|
|
221
|
+
wake = ConditionVariable.new
|
|
222
|
+
finished = false
|
|
223
|
+
thread = Thread.new do
|
|
224
|
+
Thread.current.report_on_exception = false
|
|
225
|
+
lock.synchronize do
|
|
226
|
+
until finished
|
|
227
|
+
wake.wait(lock, interval / 1000.0)
|
|
228
|
+
break if finished || !beat(message)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
begin
|
|
233
|
+
yield
|
|
234
|
+
ensure
|
|
235
|
+
lock.synchronize do
|
|
236
|
+
finished = true
|
|
237
|
+
wake.signal
|
|
238
|
+
end
|
|
239
|
+
thread.join
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Returns false when the message is no longer ours (stop beating), true
|
|
244
|
+
# otherwise, including on errors: a Redis blip shouldn't end the heartbeat.
|
|
245
|
+
def beat(message)
|
|
246
|
+
return true if queue.touch(message, consumer: consumer)
|
|
247
|
+
|
|
248
|
+
log(:warn, "heartbeat_lost", message)
|
|
249
|
+
false
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
log(:warn, "heartbeat_failed", message, error: "#{e.class}: #{e.message}")
|
|
252
|
+
true
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# --- shutdown ------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def start_watchdog
|
|
258
|
+
worker = @worker_thread
|
|
259
|
+
return if @watchdog || !@shutdown_timeout || !worker&.alive?
|
|
260
|
+
|
|
261
|
+
@watchdog = Thread.new do
|
|
262
|
+
sleep @shutdown_timeout
|
|
263
|
+
worker.raise(ShutdownTimeout, "worker did not stop within #{@shutdown_timeout}s") if worker.alive?
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Sleeps up to ms milliseconds, waking early if the worker is stopped.
|
|
268
|
+
def pause(ms)
|
|
269
|
+
deadline = monotonic + (ms / 1000.0)
|
|
270
|
+
sleep([ 0.1, deadline - monotonic ].min) while @running && monotonic < deadline
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def monotonic
|
|
274
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# --- events and logs -----------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def instrument(name, payload = {}, &block)
|
|
280
|
+
Queekiq.instrumenter.instrument(name, { queue: queue.name, consumer: consumer }.merge(payload), &block)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def event(message, **extra)
|
|
284
|
+
{ message: message }.merge(extra)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# One logfmt-style line: [queekiq] event=processed queue=... id=... outcome=done
|
|
288
|
+
def log(level, event, message = nil, **fields)
|
|
289
|
+
parts = [ "[queekiq]", "event=#{event}", "queue=#{queue.name}", "consumer=#{consumer}" ]
|
|
290
|
+
parts << "id=#{message.id}" << "payload=#{format_value(message.raw[0, 100])}" << "attempt=#{message.attempt}" if message
|
|
291
|
+
fields.each { |key, value| parts << "#{key}=#{format_value(value)}" }
|
|
292
|
+
logger.public_send(level, parts.join(" "))
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def format_value(value)
|
|
296
|
+
text = value.nil? ? "nil" : value.to_s
|
|
297
|
+
text.match?(%r{\A[\w.:@/-]+\z}) ? text : text.inspect
|
|
94
298
|
end
|
|
95
299
|
end
|
|
96
300
|
end
|
data/lib/queekiq.rb
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
require "queekiq/version"
|
|
2
|
+
|
|
3
|
+
module Queekiq
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# A payload couldn't be encoded or decoded by the queue's serializer.
|
|
7
|
+
class SerializationError < Error; end
|
|
8
|
+
|
|
9
|
+
# Raised inside a worker whose shutdown_timeout ran out while a message was
|
|
10
|
+
# still being handled. Inherits from Exception so a handler's own
|
|
11
|
+
# `rescue StandardError` can't swallow it.
|
|
12
|
+
class ShutdownTimeout < Exception; end # rubocop:disable Lint/InheritException
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require "queekiq/serializers"
|
|
16
|
+
require "queekiq/backoff"
|
|
17
|
+
require "queekiq/instrumenter"
|
|
2
18
|
require "queekiq/configuration"
|
|
3
19
|
require "queekiq/message"
|
|
4
20
|
require "queekiq/queue"
|
|
5
21
|
require "queekiq/worker"
|
|
6
22
|
|
|
7
23
|
module Queekiq
|
|
8
|
-
class Error < StandardError; end
|
|
9
|
-
|
|
10
24
|
class << self
|
|
11
25
|
def configuration
|
|
12
26
|
@configuration ||= Configuration.new
|
|
@@ -25,6 +39,15 @@ module Queekiq
|
|
|
25
39
|
configuration.logger
|
|
26
40
|
end
|
|
27
41
|
|
|
42
|
+
def instrumenter
|
|
43
|
+
configuration.instrumenter
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Shorthand for Queekiq.instrumenter.subscribe(...).
|
|
47
|
+
def subscribe(pattern = nil, &block)
|
|
48
|
+
instrumenter.subscribe(pattern, &block)
|
|
49
|
+
end
|
|
50
|
+
|
|
28
51
|
# Drops all configuration, including the memoized Redis client.
|
|
29
52
|
def reset!
|
|
30
53
|
@configuration = nil
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: queekiq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marco Aviles
|
|
@@ -29,9 +29,11 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '7'
|
|
32
|
-
description: 'Queekiq is a small, framework-agnostic job queue built on Redis Streams
|
|
33
|
-
consumer groups for parallel workers,
|
|
34
|
-
|
|
32
|
+
description: 'Queekiq is a small, framework-agnostic job queue built on Redis Streams,
|
|
33
|
+
with no dependencies beyond the redis gem: consumer groups for parallel workers,
|
|
34
|
+
reclaiming of abandoned messages, retries with backoff and a dead-letter stream,
|
|
35
|
+
delayed re-enqueueing (deferral), heartbeats for long jobs, graceful shutdown, reconnection
|
|
36
|
+
and built-in instrumentation.'
|
|
35
37
|
email:
|
|
36
38
|
- gdmarav374@gmail.com
|
|
37
39
|
executables: []
|
|
@@ -42,9 +44,12 @@ files:
|
|
|
42
44
|
- LICENSE.txt
|
|
43
45
|
- README.md
|
|
44
46
|
- lib/queekiq.rb
|
|
47
|
+
- lib/queekiq/backoff.rb
|
|
45
48
|
- lib/queekiq/configuration.rb
|
|
49
|
+
- lib/queekiq/instrumenter.rb
|
|
46
50
|
- lib/queekiq/message.rb
|
|
47
51
|
- lib/queekiq/queue.rb
|
|
52
|
+
- lib/queekiq/serializers.rb
|
|
48
53
|
- lib/queekiq/version.rb
|
|
49
54
|
- lib/queekiq/worker.rb
|
|
50
55
|
homepage: https://github.com/mhero/queekiq
|
|
@@ -68,8 +73,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
68
73
|
- !ruby/object:Gem::Version
|
|
69
74
|
version: '0'
|
|
70
75
|
requirements: []
|
|
71
|
-
rubygems_version:
|
|
76
|
+
rubygems_version: 3.6.9
|
|
72
77
|
specification_version: 4
|
|
73
|
-
summary: Durable Redis Streams work queue with
|
|
74
|
-
loop.
|
|
78
|
+
summary: Durable Redis Streams work queue with retries, dead letters, deferral and
|
|
79
|
+
a resilient worker loop.
|
|
75
80
|
test_files: []
|