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.
data/lib/queekiq/queue.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  module Queekiq
2
- # A durable work queue: a Redis Stream read through a consumer group, plus a
3
- # sorted set of "deferred" payloads that are pushed back onto the stream once
4
- # their delay has elapsed.
2
+ # A durable work queue: a Redis Stream read through a consumer group, a sorted
3
+ # set of "deferred" messages that come back once their delay has elapsed, and
4
+ # a "dead" stream for messages that gave up.
5
5
  #
6
6
  # queue = Queekiq::Queue.new("emails")
7
7
  # queue.enqueue("user-42")
@@ -12,51 +12,99 @@ module Queekiq
12
12
  # #reclaim hands pending messages that have been idle too long to another
13
13
  # consumer. Handlers must therefore be idempotent.
14
14
  #
15
+ # Besides the payload, every entry carries a small envelope (attempt, defers,
16
+ # enqueued_at) so retries and deferrals keep their history. Entries written by
17
+ # Queekiq 0.1, or by anything else that only sets the payload field, are read
18
+ # as attempt 1 with no history.
19
+ #
15
20
  # Requires Redis >= 7.0.
16
21
  class Queue
17
22
  DEFAULT_FIELD = "payload".freeze
18
23
  DEFAULT_RECLAIM_AFTER_MS = 30_000
19
- DEFAULT_DEFER_DELAY_MS = 200
24
+ DEFAULT_MAX_ATTEMPTS = 5
25
+ RESERVED_FIELDS = %w[attempt defers enqueued_at].freeze
20
26
 
21
27
  # Moves due members of the deferred set onto the stream in one atomic step,
22
- # so a crash can't lose a payload between "removed" and "re-enqueued", and
28
+ # so a crash can't lose a message between "removed" and "re-enqueued", and
23
29
  # two workers can't both promote the same one.
30
+ # Members look like "attempt|defers|enqueued_at|raw payload". A member that
31
+ # doesn't match (written by 0.1: just the payload) is promoted as a new entry.
24
32
  # KEYS: deferred set, stream ARGV: now_ms, limit, field name
25
33
  PROMOTE_SCRIPT = <<~LUA.freeze
26
34
  local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
27
35
  for _, member in ipairs(due) do
28
36
  redis.call('ZREM', KEYS[1], member)
29
- redis.call('XADD', KEYS[2], '*', ARGV[3], member)
37
+ local attempt, defers, enqueued_at, raw = string.match(member, '^(%d+)|(%d+)|(%d+)|(.*)$')
38
+ if raw then
39
+ redis.call('XADD', KEYS[2], '*', ARGV[3], raw, 'attempt', attempt, 'defers', defers, 'enqueued_at', enqueued_at)
40
+ else
41
+ redis.call('XADD', KEYS[2], '*', ARGV[3], member, 'enqueued_at', ARGV[1])
42
+ end
30
43
  end
31
44
  return #due
32
45
  LUA
33
46
 
34
- attr_reader :name, :stream, :group, :deferred_key, :field, :reclaim_after_ms, :defer_delay_ms
47
+ # Resets a pending message's idle timer, but only while it still belongs to
48
+ # the given consumer, so a worker that stalled and was reclaimed can't steal
49
+ # the message back. Returns 1 if it did, 0 if not.
50
+ # KEYS: stream ARGV: group, consumer, entry id
51
+ TOUCH_SCRIPT = <<~LUA.freeze
52
+ local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[3], ARGV[3], 1)
53
+ if pending[1] and pending[1][2] == ARGV[2] then
54
+ redis.call('XCLAIM', KEYS[1], ARGV[1], ARGV[2], 0, ARGV[3], 'JUSTID')
55
+ return 1
56
+ end
57
+ return 0
58
+ LUA
59
+
60
+ # A message in the dead stream. `raw` is the stored payload (not decoded).
61
+ DeadMessage = Struct.new(:id, :raw, :reason, :error_class, :error_message,
62
+ :attempt, :defers, :enqueued_at, :dead_at, :original_id, keyword_init: true)
63
+
64
+ attr_reader :name, :stream, :group, :deferred_key, :dead_key, :field, :serializer,
65
+ :reclaim_after_ms, :max_attempts, :max_defers, :retry_backoff, :defer_backoff
35
66
 
36
67
  # name - used to derive the default Redis keys.
37
- # stream/group/deferred_key
68
+ # stream/group/deferred_key/dead_key
38
69
  # - override the derived names, e.g. to adopt keys an
39
- # application already uses. (On Redis Cluster the stream
40
- # and deferred key must share a hash tag.)
70
+ # application already uses. (On Redis Cluster the stream,
71
+ # deferred and dead keys must share a hash tag.)
41
72
  # field - the stream field holding the payload.
73
+ # serializer - see Queekiq::Serializers (default: Raw, plain strings).
42
74
  # reclaim_after_ms - idle time after which a pending message may be reclaimed.
43
- # defer_delay_ms - default delay for #defer.
75
+ # Workers heartbeat while a handler runs, so this only has
76
+ # to cover a worker dying, not your slowest job.
77
+ # max_attempts - how many times a message may be handled before it is
78
+ # dead-lettered; nil retries forever.
79
+ # max_defers - how many times a message may be deferred before it is
80
+ # dead-lettered; nil (default) defers forever.
81
+ # retry_backoff - delay before retrying a failed message.
82
+ # defer_backoff - delay before a deferred message comes back.
44
83
  # delete_on_ack - also XDEL entries once acked, so the stream doesn't
45
84
  # grow forever. Only safe with a single consumer group
46
85
  # (which is what Queekiq uses).
47
86
  # redis - a client for this queue; defaults to Queekiq.redis.
48
- def initialize(name, stream: nil, group: nil, deferred_key: nil, field: DEFAULT_FIELD,
49
- reclaim_after_ms: DEFAULT_RECLAIM_AFTER_MS, defer_delay_ms: DEFAULT_DEFER_DELAY_MS,
87
+ def initialize(name, stream: nil, group: nil, deferred_key: nil, dead_key: nil, field: DEFAULT_FIELD,
88
+ serializer: Serializers::Raw, reclaim_after_ms: DEFAULT_RECLAIM_AFTER_MS,
89
+ max_attempts: DEFAULT_MAX_ATTEMPTS, max_defers: nil,
90
+ retry_backoff: Backoff.new(base_ms: 1_000, max_ms: 300_000),
91
+ defer_backoff: Backoff.new(base_ms: 200, max_ms: 5_000),
50
92
  delete_on_ack: false, redis: nil)
51
93
  raise ArgumentError, "queue name can't be blank" if name.to_s.strip.empty?
94
+ raise ArgumentError, "field can't be one of #{RESERVED_FIELDS.join(", ")}" if RESERVED_FIELDS.include?(field.to_s)
52
95
 
53
96
  @name = name.to_s
54
97
  @stream = stream || "queekiq:{#{@name}}:stream"
55
98
  @group = group || "queekiq-#{@name}"
56
99
  @deferred_key = deferred_key || "queekiq:{#{@name}}:deferred"
57
- @field = field
100
+ @dead_key = dead_key || "queekiq:{#{@name}}:dead"
101
+ @field = field.to_s
102
+ @serializer = serializer
58
103
  @reclaim_after_ms = reclaim_after_ms
59
- @defer_delay_ms = defer_delay_ms
104
+ @max_attempts = max_attempts
105
+ @max_defers = max_defers
106
+ @retry_backoff = retry_backoff
107
+ @defer_backoff = defer_backoff
60
108
  @delete_on_ack = delete_on_ack
61
109
  @redis = redis
62
110
  end
@@ -65,25 +113,79 @@ module Queekiq
65
113
  @redis || Queekiq.redis
66
114
  end
67
115
 
68
- # Appends a message to the stream. `payload` is stored as a string (use
69
- # JSON yourself for structured data). Returns the stream entry id.
116
+ # --- producing -----------------------------------------------------------
117
+
118
+ # Appends a message to the stream and returns its entry id. The payload goes
119
+ # through the serializer (the default one stores `payload.to_s`).
70
120
  def enqueue(payload)
71
- redis.xadd(stream, { field => payload.to_s })
121
+ redis.xadd(stream, { field => serializer.dump(payload), "enqueued_at" => now_ms })
122
+ end
123
+
124
+ # --- moving messages around ----------------------------------------------
125
+
126
+ # Schedules a message to come back later. Given a delivered Message, the
127
+ # delivery is acked in the same atomic step, so the message is never both
128
+ # pending and deferred. Given a bare payload it just schedules a new one.
129
+ # The delay comes from defer_backoff (by how often it was deferred before)
130
+ # unless `delay_ms` is given. Returns the delay used.
131
+ def defer(message_or_payload, delay_ms: nil)
132
+ message = as_message(message_or_payload)
133
+ defers = message.defers + 1
134
+ delay = delay_ms || defer_backoff.delay_ms(defers)
135
+ schedule(message, delay, attempt: message.attempt, defers: defers)
136
+ delay
137
+ end
138
+
139
+ # Schedules a failed message for another attempt (attempt + 1) and acks the
140
+ # current delivery, atomically. The delay comes from retry_backoff unless
141
+ # `delay_ms` is given. Returns the delay used.
142
+ def retry_later(message, delay_ms: nil)
143
+ delay = delay_ms || retry_backoff.delay_ms(message.attempt)
144
+ schedule(message, delay, attempt: message.attempt + 1, defers: message.defers)
145
+ delay
146
+ end
147
+
148
+ # Moves a message to the dead stream, with the reason and the error that
149
+ # caused it, and acks the delivery, atomically.
150
+ def dead_letter(message, reason:, error: nil)
151
+ entry = { field => message.raw, "reason" => reason.to_s, "attempt" => message.attempt,
152
+ "defers" => message.defers, "enqueued_at" => message.enqueued_at,
153
+ "dead_at" => now_ms, "original_id" => message.id }
154
+ if error
155
+ entry["error_class"] = error.class.name
156
+ entry["error_message"] = error.message.to_s[0, 1_000]
157
+ end
158
+ redis.multi do |tx|
159
+ tx.xadd(dead_key, entry.compact)
160
+ ack_commands(tx, message.id) if message.id
161
+ end
162
+ end
163
+
164
+ # True once a message has used up its attempts (attempt > max_attempts), e.g.
165
+ # because it keeps crashing its workers. Workers dead-letter these without
166
+ # running the handler again.
167
+ def attempts_exhausted?(message)
168
+ !max_attempts.nil? && message.attempt > max_attempts
72
169
  end
73
170
 
74
- # Schedules `payload` to be re-enqueued after `delay_ms`. The deferred set
75
- # holds one entry per distinct payload: deferring the same payload twice
76
- # just moves its due time.
77
- def defer(payload, delay_ms: defer_delay_ms)
78
- redis.zadd(deferred_key, now_ms + delay_ms, payload.to_s)
171
+ # True if the message is on its last allowed attempt, so a failure now is final.
172
+ def last_attempt?(message)
173
+ !max_attempts.nil? && message.attempt >= max_attempts
79
174
  end
80
175
 
81
- # Re-enqueues up to `limit` deferred payloads whose delay has elapsed.
176
+ # True if the message has already been deferred max_defers times.
177
+ def defers_exhausted?(message)
178
+ !max_defers.nil? && message.defers >= max_defers
179
+ end
180
+
181
+ # Re-enqueues up to `limit` deferred messages whose delay has elapsed.
82
182
  # Returns how many were promoted.
83
183
  def promote_due!(limit: 50)
84
184
  redis.eval(PROMOTE_SCRIPT, keys: [ deferred_key, stream ], argv: [ now_ms, limit, field ])
85
185
  end
86
186
 
187
+ # --- consuming -----------------------------------------------------------
188
+
87
189
  # Creates the consumer group (and the stream if needed). Safe to call
88
190
  # repeatedly. The group starts at the beginning of the stream, so messages
89
191
  # enqueued before the group existed are still delivered.
@@ -98,37 +200,97 @@ module Queekiq
98
200
  # pending until #ack.
99
201
  def read(consumer:, count: 1, block_ms: 5_000)
100
202
  result = redis.xreadgroup(group, consumer, stream, ">", count: count, block: block_ms)
101
- build_messages(result&.dig(stream) || [])
203
+ (result&.dig(stream) || []).map { |id, fields| build_message(id, fields) }
102
204
  end
103
205
 
104
206
  # Takes over pending messages that have been idle for reclaim_after_ms
105
- # (their consumer probably died) and returns them as Messages.
207
+ # (their consumer probably died) and returns them as Messages. Their
208
+ # `attempt` includes the deliveries that never finished.
106
209
  def reclaim(consumer:, count: 10)
107
210
  result = redis.xautoclaim(stream, group, consumer, reclaim_after_ms, "0-0", count: count)
108
211
  entries = result.is_a?(Hash) ? result["entries"] : result[1]
109
- build_messages(entries || [])
212
+ (entries || []).map { |id, fields| build_message(id, fields, deliveries: delivery_count(id)) }
213
+ end
214
+
215
+ # Heartbeat: resets the idle timer of a message this consumer is still
216
+ # working on, so it isn't reclaimed. Returns false if the message no longer
217
+ # belongs to this consumer (it was acked or reclaimed meanwhile).
218
+ def touch(message_or_id, consumer:)
219
+ id = message_or_id.respond_to?(:id) ? message_or_id.id : message_or_id
220
+ redis.eval(TOUCH_SCRIPT, keys: [ stream ], argv: [ group, consumer, id ]) == 1
110
221
  end
111
222
 
112
223
  # Marks a message as done. Accepts a Message or a bare stream entry id.
113
224
  def ack(message_or_id)
114
225
  id = message_or_id.respond_to?(:id) ? message_or_id.id : message_or_id
115
- redis.xack(stream, group, id)
116
- redis.xdel(stream, id) if @delete_on_ack
226
+ redis.multi { |tx| ack_commands(tx, id) }
117
227
  end
118
228
 
119
- # Number of entries currently in the stream.
229
+ # --- inspecting ----------------------------------------------------------
230
+
231
+ # Number of entries currently in the stream (including acked ones that
232
+ # haven't been deleted).
120
233
  def size
121
234
  redis.xlen(stream)
122
235
  end
123
236
 
124
- # Number of payloads waiting in the deferred set.
237
+ # Number of messages waiting in the deferred set.
125
238
  def deferred_size
126
239
  redis.zcard(deferred_key)
127
240
  end
128
241
 
129
- # Deletes the stream (and its group) and the deferred set. Meant for tests.
242
+ # Number of messages in the dead stream.
243
+ def dead_size
244
+ redis.xlen(dead_key)
245
+ end
246
+
247
+ # A snapshot for dashboards and alerts:
248
+ # backlog - entries not yet delivered to the group (nil if Redis can't tell)
249
+ # pending - delivered but not acked
250
+ # oldest_pending_idle_ms - how long the oldest pending message has been
251
+ # idle; large values mean stuck or dead workers
252
+ # deferred / dead - sizes of those sets
253
+ # consumers - [{ name:, pending:, idle_ms: }] per consumer
254
+ def stats
255
+ info = group_info
256
+ {
257
+ backlog: info && info["lag"],
258
+ pending: info ? info["pending"] : 0,
259
+ oldest_pending_idle_ms: info && info["pending"].positive? ? oldest_pending_idle_ms : nil,
260
+ deferred: deferred_size,
261
+ dead: dead_size,
262
+ consumers: info ? consumers : []
263
+ }
264
+ end
265
+
266
+ # The oldest `count` dead messages, as DeadMessage structs.
267
+ def dead_messages(count: 100)
268
+ redis.xrange(dead_key, "-", "+", count: count).map { |id, fields| build_dead_message(id, fields) }
269
+ end
270
+
271
+ # Puts a dead message back on the stream with a fresh attempt counter and
272
+ # removes it from the dead stream. Returns false if there's no such entry.
273
+ def requeue_dead(id)
274
+ entry = redis.xrange(dead_key, id, id).first
275
+ return false unless entry
276
+
277
+ raw = entry.last.fetch(field)
278
+ redis.multi do |tx|
279
+ tx.xadd(stream, { field => raw, "enqueued_at" => now_ms })
280
+ tx.xdel(dead_key, id)
281
+ end
282
+ true
283
+ end
284
+
285
+ # Removes a message from the dead stream for good.
286
+ def delete_dead(id)
287
+ redis.xdel(dead_key, id).positive?
288
+ end
289
+
290
+ # Deletes the stream (and its group), the deferred set and the dead stream.
291
+ # Meant for tests.
130
292
  def clear!
131
- redis.del(stream, deferred_key)
293
+ redis.del(stream, deferred_key, dead_key)
132
294
  end
133
295
 
134
296
  private
@@ -137,11 +299,63 @@ module Queekiq
137
299
  (Time.now.to_f * 1000).to_i
138
300
  end
139
301
 
140
- def build_messages(entries)
141
- entries.map do |id, fields|
142
- payload = fields.fetch(field) { raise Error, "stream entry #{id} has no #{field.inspect} field" }
143
- Message.new(id: id, payload: payload)
302
+ def as_message(message_or_payload)
303
+ return message_or_payload if message_or_payload.is_a?(Message)
304
+
305
+ Message.new(id: nil, payload: message_or_payload, serializer: serializer, enqueued_at: now_ms)
306
+ end
307
+
308
+ # Writes the message into the deferred set and acks its delivery, atomically.
309
+ def schedule(message, delay_ms, attempt:, defers:)
310
+ member = [ attempt, defers, message.enqueued_at || now_ms, message.raw ].join("|")
311
+ redis.multi do |tx|
312
+ tx.zadd(deferred_key, now_ms + delay_ms, member)
313
+ ack_commands(tx, message.id) if message.id
144
314
  end
145
315
  end
316
+
317
+ def ack_commands(tx, id)
318
+ tx.xack(stream, group, id)
319
+ tx.xdel(stream, id) if @delete_on_ack
320
+ end
321
+
322
+ def build_message(id, fields, deliveries: 1)
323
+ raw = fields.fetch(field) { raise Error, "stream entry #{id} has no #{field.inspect} field" }
324
+ carried = fields["attempt"] ? fields["attempt"].to_i : 1
325
+ Message.new(id: id, raw: raw, serializer: serializer,
326
+ attempt: carried + deliveries - 1,
327
+ defers: fields["defers"].to_i,
328
+ enqueued_at: fields["enqueued_at"]&.to_i)
329
+ end
330
+
331
+ def build_dead_message(id, fields)
332
+ DeadMessage.new(id: id, raw: fields[field], reason: fields["reason"],
333
+ error_class: fields["error_class"], error_message: fields["error_message"],
334
+ attempt: fields["attempt"]&.to_i, defers: fields["defers"]&.to_i,
335
+ enqueued_at: fields["enqueued_at"]&.to_i, dead_at: fields["dead_at"]&.to_i,
336
+ original_id: fields["original_id"])
337
+ end
338
+
339
+ # How many times Redis has delivered this entry (XAUTOCLAIM counts each claim).
340
+ def delivery_count(id)
341
+ pending = redis.xpending(stream, group, id, id, 1).first
342
+ pending ? pending["count"] : 1
343
+ end
344
+
345
+ def group_info
346
+ redis.xinfo(:groups, stream).find { |g| g["name"] == group }
347
+ rescue Redis::CommandError => e
348
+ raise unless e.message.include?("no such key")
349
+
350
+ nil
351
+ end
352
+
353
+ def consumers
354
+ redis.xinfo(:consumers, stream, group).map { |c| { name: c["name"], pending: c["pending"], idle_ms: c["idle"] } }
355
+ end
356
+
357
+ def oldest_pending_idle_ms
358
+ redis.xpending(stream, group, "-", "+", 1).first&.fetch("elapsed")
359
+ end
146
360
  end
147
361
  end
@@ -0,0 +1,36 @@
1
+ require "json"
2
+
3
+ module Queekiq
4
+ # A serializer is any object with `dump(object) -> String` and
5
+ # `load(String) -> object`. Queue#enqueue stores what `dump` returns;
6
+ # Message#payload returns what `load` makes of it.
7
+ module Serializers
8
+ # Stores payloads as plain strings (`to_s`) and hands them back untouched.
9
+ # This is the default and matches how the stream looks without Queekiq.
10
+ module Raw
11
+ def self.dump(object)
12
+ object.to_s
13
+ end
14
+
15
+ def self.load(string)
16
+ string
17
+ end
18
+ end
19
+
20
+ # Stores payloads as JSON, so hashes, arrays, numbers etc. round-trip.
21
+ # Hash keys come back as strings.
22
+ module JSON
23
+ def self.dump(object)
24
+ ::JSON.generate(object)
25
+ rescue ::JSON::JSONError => e
26
+ raise SerializationError, "can't serialize #{object.class} as JSON: #{e.message}"
27
+ end
28
+
29
+ def self.load(string)
30
+ ::JSON.parse(string)
31
+ rescue ::JSON::JSONError => e
32
+ raise SerializationError, "payload is not valid JSON: #{e.message}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,3 +1,3 @@
1
1
  module Queekiq
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end