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.
@@ -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 acked afterwards.
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
- # The handler receives a Queekiq::Message and may return anything. If the
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
- # If the handler raises, the error is logged, `on_error` is called, and the
19
- # message is left unacked: it will be redelivered once the queue's
20
- # reclaim_after_ms has passed. A handler that must not be retried forever
21
- # should rescue its own errors and return an outcome instead.
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 - anything responding to #call(message); alternatively pass a block.
30
- # consumer - unique per worker process (default: hostname + pid).
31
- # defer_on - outcomes that trigger Queue#defer.
32
- # block_ms - how long one read blocks; also the worst-case stop latency.
33
- # on_error - optional #call(error, message), e.g. to report to an error tracker.
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, &block)
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 its current iteration and return.
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
- queue.ensure_group!
67
- logger.info("[queekiq] #{consumer} started on #{queue.name}")
68
-
69
- while @running
70
- queue.promote_due!
71
- handle(queue.reclaim(consumer: consumer))
72
- handle(queue.read(consumer: consumer, block_ms: @block_ms))
73
- end
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
- outcome = @handler.call(message)
88
- queue.defer(message.payload) if @defer_on.include?(outcome)
89
- logger.info("[queekiq] #{consumer} payload=#{message.payload} outcome=#{outcome.inspect}")
90
- queue.ack(message)
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
- logger.error("[queekiq] #{consumer} payload=#{message.payload} failed: #{e.class}: #{e.message}")
93
- @on_error&.call(e, message)
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.1.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, reclaiming of abandoned messages, delayed
34
- re-enqueueing (deferral) and a graceful consumer loop.'
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: 4.0.22
76
+ rubygems_version: 3.6.9
72
77
  specification_version: 4
73
- summary: Durable Redis Streams work queue with consumer groups, deferral and a worker
74
- loop.
78
+ summary: Durable Redis Streams work queue with retries, dead letters, deferral and
79
+ a resilient worker loop.
75
80
  test_files: []