tina4ruby 3.13.40 → 3.13.41
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/lib/tina4/queue.rb +31 -5
- data/lib/tina4/queue_backends/lite_backend.rb +158 -20
- data/lib/tina4/queue_backends/mongo_backend.rb +76 -2
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74de2870c01871240bb98d6ceeb66cd5bc5bf05c6e1c4e82d19a198d585d83e6
|
|
4
|
+
data.tar.gz: 9d548edf025f53d6547216f3a421d9215eb893cfda89131c342910ebc2c820bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d2db7a1fc7dfcb95848dbc80c4f76664800bd6692a460667f5dfcbb59cb59fcba101737773c4eda4b4731275f735b4ef494164388869ded0849d29bb6de3804
|
|
7
|
+
data.tar.gz: 49e9c4742313a45ebb610cc5840c1b277915db9b1a5eb3a98dcb5b680a93d336b5b1d846698a9ec9121d2d722c905b8e0261b2532d050d0d7805b27a6c2c5228
|
data/lib/tina4/queue.rb
CHANGED
|
@@ -17,17 +17,31 @@ module Tina4
|
|
|
17
17
|
# # Or pass a backend instance directly (legacy)
|
|
18
18
|
# queue = Queue.new(topic: "tasks", backend: my_backend)
|
|
19
19
|
class Queue
|
|
20
|
-
attr_reader :topic, :max_retries, :retry_backoff
|
|
20
|
+
attr_reader :topic, :max_retries, :retry_backoff, :visibility_timeout
|
|
21
21
|
|
|
22
|
-
def initialize(topic:, backend: nil, max_retries: 3, retry_backoff: 0)
|
|
22
|
+
def initialize(topic:, backend: nil, max_retries: 3, retry_backoff: 0, visibility_timeout: nil)
|
|
23
23
|
@topic = topic
|
|
24
24
|
@max_retries = max_retries
|
|
25
25
|
# Seconds to wait before a failed job is re-attempted (lite backend).
|
|
26
26
|
# Default 0 = retry on the very next pop/consume iteration.
|
|
27
27
|
@retry_backoff = retry_backoff
|
|
28
|
+
# Reservation/visibility timeout (seconds). A popped job is reserved for
|
|
29
|
+
# this long; if the consumer dies before complete()/fail() the next pop()
|
|
30
|
+
# reclaims it (at-least-once delivery). Falls back to
|
|
31
|
+
# TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300 (5 min). <= 0 disables reclaim.
|
|
32
|
+
# RabbitMQ/Kafka ignore it — the broker owns redelivery.
|
|
33
|
+
@visibility_timeout =
|
|
34
|
+
visibility_timeout.nil? ? self.class.default_visibility_timeout : visibility_timeout.to_f
|
|
28
35
|
@backend = resolve_backend_arg(backend)
|
|
29
36
|
end
|
|
30
37
|
|
|
38
|
+
# Reservation/visibility timeout in seconds, from env (default 300 = 5 min).
|
|
39
|
+
def self.default_visibility_timeout
|
|
40
|
+
Float(ENV.fetch("TINA4_QUEUE_VISIBILITY_TIMEOUT", "300"))
|
|
41
|
+
rescue ArgumentError, TypeError
|
|
42
|
+
300.0
|
|
43
|
+
end
|
|
44
|
+
|
|
31
45
|
# Push a job onto the queue. Returns the Job.
|
|
32
46
|
# priority: higher-priority messages are dequeued first (default 0).
|
|
33
47
|
# delay_seconds: delay before the message becomes available (default 0).
|
|
@@ -207,6 +221,8 @@ module Tina4
|
|
|
207
221
|
case status.to_s
|
|
208
222
|
when "pending"
|
|
209
223
|
@backend.size(@topic)
|
|
224
|
+
when "reserved"
|
|
225
|
+
@backend.respond_to?(:reserved_count) ? @backend.reserved_count(@topic) : 0
|
|
210
226
|
when "failed", "dead"
|
|
211
227
|
if @backend.respond_to?(:dead_letter_count)
|
|
212
228
|
@backend.dead_letter_count(@topic)
|
|
@@ -267,20 +283,28 @@ module Tina4
|
|
|
267
283
|
end
|
|
268
284
|
|
|
269
285
|
# Resolve the default backend from env vars.
|
|
270
|
-
def self.resolve_backend(name = nil, max_retries: 3, retry_backoff: 0)
|
|
286
|
+
def self.resolve_backend(name = nil, max_retries: 3, retry_backoff: 0, visibility_timeout: nil)
|
|
271
287
|
chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "file").downcase.strip
|
|
288
|
+
vt = visibility_timeout.nil? ? default_visibility_timeout : visibility_timeout
|
|
272
289
|
|
|
273
290
|
case chosen.to_s
|
|
274
291
|
when "lite", "file", "default"
|
|
275
|
-
Tina4::QueueBackends::LiteBackend.new(
|
|
292
|
+
Tina4::QueueBackends::LiteBackend.new(
|
|
293
|
+
max_retries: max_retries, retry_backoff: retry_backoff, visibility_timeout: vt
|
|
294
|
+
)
|
|
276
295
|
when "rabbitmq"
|
|
296
|
+
# Broker manages visibility/redelivery (unacked messages requeue on
|
|
297
|
+
# channel close) — the framework timeout is accepted but not used.
|
|
277
298
|
config = resolve_rabbitmq_config
|
|
278
299
|
Tina4::QueueBackends::RabbitmqBackend.new(config)
|
|
279
300
|
when "kafka"
|
|
301
|
+
# Consumer-group offsets manage redelivery — framework timeout N/A.
|
|
280
302
|
config = resolve_kafka_config
|
|
281
303
|
Tina4::QueueBackends::KafkaBackend.new(config)
|
|
282
304
|
when "mongodb", "mongo"
|
|
283
305
|
config = resolve_mongo_config
|
|
306
|
+
config[:visibility_timeout] = vt
|
|
307
|
+
config[:max_retries] = max_retries
|
|
284
308
|
Tina4::QueueBackends::MongoBackend.new(config)
|
|
285
309
|
else
|
|
286
310
|
raise ArgumentError, "Unknown queue backend: #{chosen.inspect}. Use 'lite', 'rabbitmq', 'kafka', or 'mongodb'."
|
|
@@ -302,10 +326,12 @@ module Tina4
|
|
|
302
326
|
if backend && !backend.is_a?(Symbol) && !backend.is_a?(String)
|
|
303
327
|
backend.max_retries = @max_retries if backend.respond_to?(:max_retries=)
|
|
304
328
|
backend.retry_backoff = @retry_backoff if backend.respond_to?(:retry_backoff=)
|
|
329
|
+
backend.visibility_timeout = @visibility_timeout if backend.respond_to?(:visibility_timeout=)
|
|
305
330
|
return backend
|
|
306
331
|
end
|
|
307
332
|
# If a symbol or string name is passed, resolve it
|
|
308
|
-
Queue.resolve_backend(backend, max_retries: @max_retries, retry_backoff: @retry_backoff
|
|
333
|
+
Queue.resolve_backend(backend, max_retries: @max_retries, retry_backoff: @retry_backoff,
|
|
334
|
+
visibility_timeout: @visibility_timeout)
|
|
309
335
|
end
|
|
310
336
|
|
|
311
337
|
def self.resolve_rabbitmq_config
|
|
@@ -28,11 +28,23 @@ module Tina4
|
|
|
28
28
|
# Seconds to delay a job's next attempt when fail() re-enqueues it.
|
|
29
29
|
# 0 (default) = retry on the very next pop/consume iteration.
|
|
30
30
|
@retry_backoff = options[:retry_backoff] || 0
|
|
31
|
+
# Reservation/visibility timeout (seconds). When a job is popped it is
|
|
32
|
+
# held in <topic>/reserved/ with available_at = now + visibility_timeout.
|
|
33
|
+
# If the consumer dies before complete()/fail() (crash, OOM, k8s
|
|
34
|
+
# eviction) the next pop() reclaims it once the window expires —
|
|
35
|
+
# incrementing attempts and re-enqueuing, or dead-lettering past
|
|
36
|
+
# max_retries. <= 0 disables the reclaim (a reservation then lasts until
|
|
37
|
+
# the consumer acks — the old at-most-once behaviour).
|
|
38
|
+
@visibility_timeout = options[:visibility_timeout] || 300.0
|
|
31
39
|
FileUtils.mkdir_p(@dir)
|
|
32
40
|
FileUtils.mkdir_p(@dead_letter_dir)
|
|
33
41
|
@mutex = Mutex.new
|
|
34
42
|
end
|
|
35
43
|
|
|
44
|
+
# Retry/visibility policy is settable so a Queue can propagate its own
|
|
45
|
+
# configuration onto a backend instance passed directly (legacy path).
|
|
46
|
+
attr_accessor :visibility_timeout
|
|
47
|
+
|
|
36
48
|
def enqueue(message)
|
|
37
49
|
@mutex.synchronize do
|
|
38
50
|
topic_dir = topic_path(message.topic)
|
|
@@ -44,19 +56,35 @@ module Tina4
|
|
|
44
56
|
|
|
45
57
|
def dequeue(topic)
|
|
46
58
|
@mutex.synchronize do
|
|
59
|
+
# First return any reservations whose consumer died mid-flight.
|
|
60
|
+
reclaim_expired(topic)
|
|
47
61
|
candidate = available_candidates(topic).first
|
|
48
62
|
return nil unless candidate
|
|
49
63
|
|
|
50
|
-
|
|
64
|
+
# Write the reservation BEFORE claiming the pending file, so a crash
|
|
65
|
+
# between claim and reserve can never strand the job. Only the worker
|
|
66
|
+
# that wins the delete owns — and returns — it.
|
|
67
|
+
write_reserved(candidate[:data], topic)
|
|
68
|
+
begin
|
|
69
|
+
File.delete(candidate[:file])
|
|
70
|
+
rescue Errno::ENOENT
|
|
71
|
+
return nil # already consumed by another worker
|
|
72
|
+
end
|
|
51
73
|
job_from_data(candidate[:data], topic)
|
|
52
74
|
end
|
|
53
75
|
end
|
|
54
76
|
|
|
55
77
|
def dequeue_batch(topic, count)
|
|
56
78
|
@mutex.synchronize do
|
|
79
|
+
reclaim_expired(topic)
|
|
57
80
|
chosen = available_candidates(topic).first(count)
|
|
58
|
-
chosen.
|
|
59
|
-
|
|
81
|
+
chosen.filter_map do |c|
|
|
82
|
+
write_reserved(c[:data], topic)
|
|
83
|
+
begin
|
|
84
|
+
File.delete(c[:file])
|
|
85
|
+
rescue Errno::ENOENT
|
|
86
|
+
next
|
|
87
|
+
end
|
|
60
88
|
job_from_data(c[:data], topic)
|
|
61
89
|
end
|
|
62
90
|
end
|
|
@@ -74,6 +102,9 @@ module Tina4
|
|
|
74
102
|
data = JSON.parse(File.read(f))
|
|
75
103
|
next unless data["id"].to_s == target
|
|
76
104
|
|
|
105
|
+
# Reserve (so a dead consumer's job is reclaimable) then claim the
|
|
106
|
+
# pending file — mirrors dequeue.
|
|
107
|
+
write_reserved(data, topic)
|
|
77
108
|
File.delete(f)
|
|
78
109
|
return job_from_data(data, topic)
|
|
79
110
|
rescue JSON::ParserError
|
|
@@ -88,8 +119,10 @@ module Tina4
|
|
|
88
119
|
end
|
|
89
120
|
|
|
90
121
|
def complete(message)
|
|
91
|
-
#
|
|
92
|
-
#
|
|
122
|
+
# The pending file was claimed on dequeue and a reservation record
|
|
123
|
+
# written; complete() is terminal, so drop the reservation. The job is
|
|
124
|
+
# done and gone.
|
|
125
|
+
clear_reservation(message.topic, message.id)
|
|
93
126
|
end
|
|
94
127
|
|
|
95
128
|
def requeue(message)
|
|
@@ -101,6 +134,8 @@ module Tina4
|
|
|
101
134
|
# the configured retry_backoff). Once attempts >= max_retries it is moved
|
|
102
135
|
# to the dead-letter store.
|
|
103
136
|
def fail(job, error = "")
|
|
137
|
+
# Clear the reservation — the consumer acknowledged (with a failure).
|
|
138
|
+
clear_reservation(job.topic, job.id)
|
|
104
139
|
job.attempts += 1
|
|
105
140
|
job.error = error
|
|
106
141
|
if job.attempts < @max_retries
|
|
@@ -114,6 +149,7 @@ module Tina4
|
|
|
114
149
|
# re-enqueues regardless of the retry limit — a manual override, distinct
|
|
115
150
|
# from the automatic fail() path. Increments attempts, clears the error.
|
|
116
151
|
def retry(job, delay_seconds: 0)
|
|
152
|
+
clear_reservation(job.topic, job.id)
|
|
117
153
|
job.attempts += 1
|
|
118
154
|
requeue_job(job, delay_seconds: delay_seconds, error: nil)
|
|
119
155
|
end
|
|
@@ -131,6 +167,13 @@ module Tina4
|
|
|
131
167
|
Dir.glob(File.join(dir, "*.json")).length
|
|
132
168
|
end
|
|
133
169
|
|
|
170
|
+
# Count currently-reserved (in-flight) jobs for a topic.
|
|
171
|
+
def reserved_count(topic)
|
|
172
|
+
dir = reserved_path(topic)
|
|
173
|
+
return 0 unless Dir.exist?(dir)
|
|
174
|
+
Dir.glob(File.join(dir, "*.json")).length
|
|
175
|
+
end
|
|
176
|
+
|
|
134
177
|
# Count dead-letter / failed messages for a topic.
|
|
135
178
|
def dead_letter_count(topic)
|
|
136
179
|
return 0 unless Dir.exist?(@dead_letter_dir)
|
|
@@ -241,14 +284,18 @@ module Tina4
|
|
|
241
284
|
count
|
|
242
285
|
end
|
|
243
286
|
|
|
244
|
-
# Remove all pending jobs from a topic
|
|
287
|
+
# Remove all pending jobs from a topic (and any held reservations).
|
|
288
|
+
# Returns count removed.
|
|
245
289
|
def clear(topic)
|
|
246
|
-
dir = topic_path(topic)
|
|
247
|
-
return 0 unless Dir.exist?(dir)
|
|
248
290
|
count = 0
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
291
|
+
[topic_path(topic), reserved_path(topic)].each do |dir|
|
|
292
|
+
next unless Dir.exist?(dir)
|
|
293
|
+
Dir.glob(File.join(dir, "*.json")).each do |file|
|
|
294
|
+
File.delete(file)
|
|
295
|
+
count += 1
|
|
296
|
+
rescue Errno::ENOENT
|
|
297
|
+
next
|
|
298
|
+
end
|
|
252
299
|
end
|
|
253
300
|
count
|
|
254
301
|
end
|
|
@@ -366,6 +413,13 @@ module Tina4
|
|
|
366
413
|
# within a priority tier it sorts behind jobs not yet attempted) and the
|
|
367
414
|
# current attempts/error carried over.
|
|
368
415
|
def requeue_job(job, delay_seconds: 0, error: nil)
|
|
416
|
+
@mutex.synchronize { write_pending(job, delay_seconds: delay_seconds, error: error) }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Mutex-free pending write. Callers already holding @mutex (e.g.
|
|
420
|
+
# reclaim_expired, invoked from within dequeue's locked block) use this
|
|
421
|
+
# directly — Ruby's Mutex is non-reentrant, so re-locking would deadlock.
|
|
422
|
+
def write_pending(job, delay_seconds: 0, error: nil)
|
|
369
423
|
available_at = delay_seconds > 0 ? (Time.now + delay_seconds).iso8601(6) : nil
|
|
370
424
|
data = {
|
|
371
425
|
id: job.id,
|
|
@@ -378,16 +432,19 @@ module Tina4
|
|
|
378
432
|
created_at: Time.now.iso8601(6)
|
|
379
433
|
}
|
|
380
434
|
data[:available_at] = available_at if available_at
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
File.write(File.join(topic_dir, "#{job.id}.json"), JSON.generate(data))
|
|
385
|
-
end
|
|
435
|
+
topic_dir = topic_path(job.topic)
|
|
436
|
+
FileUtils.mkdir_p(topic_dir)
|
|
437
|
+
File.write(File.join(topic_dir, "#{job.id}.json"), JSON.generate(data))
|
|
386
438
|
end
|
|
387
439
|
|
|
388
440
|
# Move a failed job to the dead-letter directory. Terminal until a manual
|
|
389
441
|
# retry_failed/retry revives it.
|
|
390
442
|
def move_to_dead_letter(job, error = "")
|
|
443
|
+
@mutex.synchronize { write_dead_letter(job, error) }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Mutex-free dead-letter write (see write_pending for the re-entrancy note).
|
|
447
|
+
def write_dead_letter(job, error = "")
|
|
391
448
|
data = {
|
|
392
449
|
id: job.id,
|
|
393
450
|
topic: job.topic,
|
|
@@ -398,16 +455,97 @@ module Tina4
|
|
|
398
455
|
error: error,
|
|
399
456
|
failed_at: Time.now.iso8601(6)
|
|
400
457
|
}
|
|
401
|
-
@
|
|
402
|
-
|
|
403
|
-
File.write(File.join(@dead_letter_dir, "#{job.id}.json"), JSON.generate(data))
|
|
404
|
-
end
|
|
458
|
+
FileUtils.mkdir_p(@dead_letter_dir)
|
|
459
|
+
File.write(File.join(@dead_letter_dir, "#{job.id}.json"), JSON.generate(data))
|
|
405
460
|
end
|
|
406
461
|
|
|
407
462
|
def topic_path(topic)
|
|
408
463
|
safe_topic = topic.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
409
464
|
File.join(@dir, safe_topic)
|
|
410
465
|
end
|
|
466
|
+
|
|
467
|
+
# Directory holding a topic's reservation records (in-flight jobs).
|
|
468
|
+
def reserved_path(topic)
|
|
469
|
+
File.join(topic_path(topic), "reserved")
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Persist a reservation record so a dead consumer's job is reclaimable.
|
|
473
|
+
# Stores reserved_at + available_at = now + visibility_timeout. The next
|
|
474
|
+
# dequeue reclaims this job once available_at has passed (see
|
|
475
|
+
# reclaim_expired). complete()/fail()/retry() delete the record.
|
|
476
|
+
def write_reserved(data, topic)
|
|
477
|
+
now = Time.now
|
|
478
|
+
vt = @visibility_timeout || 0
|
|
479
|
+
record = {
|
|
480
|
+
id: data["id"],
|
|
481
|
+
topic: data["topic"] || topic.to_s,
|
|
482
|
+
payload: data["payload"],
|
|
483
|
+
status: "reserved",
|
|
484
|
+
priority: data["priority"] || 0,
|
|
485
|
+
attempts: data["attempts"] || 0,
|
|
486
|
+
error: data["error"],
|
|
487
|
+
reserved_at: now.iso8601(6),
|
|
488
|
+
available_at: (vt > 0 ? (now + vt) : now).iso8601(6),
|
|
489
|
+
created_at: data["created_at"] || now.iso8601(6)
|
|
490
|
+
}
|
|
491
|
+
dir = reserved_path(topic)
|
|
492
|
+
FileUtils.mkdir_p(dir)
|
|
493
|
+
File.write(File.join(dir, "#{record[:id]}.json"), JSON.generate(record))
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Delete a job's reservation record (best-effort).
|
|
497
|
+
def clear_reservation(topic, id)
|
|
498
|
+
File.delete(File.join(reserved_path(topic), "#{id}.json"))
|
|
499
|
+
rescue Errno::ENOENT
|
|
500
|
+
nil
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Return expired reservations to the queue (at-least-once delivery).
|
|
504
|
+
#
|
|
505
|
+
# A reserved job whose available_at <= now means its consumer never
|
|
506
|
+
# acknowledged in time (crash / OOM / pod eviction). Increment attempts and
|
|
507
|
+
# either re-enqueue it (so the next dequeue picks it up) or dead-letter it
|
|
508
|
+
# once it has hit max_retries. Disabled when visibility_timeout <= 0.
|
|
509
|
+
def reclaim_expired(topic)
|
|
510
|
+
return if @visibility_timeout.nil? || @visibility_timeout <= 0
|
|
511
|
+
|
|
512
|
+
dir = reserved_path(topic)
|
|
513
|
+
return unless Dir.exist?(dir)
|
|
514
|
+
|
|
515
|
+
now = Time.now
|
|
516
|
+
Dir.glob(File.join(dir, "*.json")).each do |file|
|
|
517
|
+
data = JSON.parse(File.read(file))
|
|
518
|
+
available_at = data["available_at"] ? Time.parse(data["available_at"]) : now
|
|
519
|
+
next if available_at > now # reservation still valid
|
|
520
|
+
|
|
521
|
+
# Atomically claim the expired reservation by deleting its file.
|
|
522
|
+
begin
|
|
523
|
+
File.delete(file)
|
|
524
|
+
rescue Errno::ENOENT
|
|
525
|
+
next # another worker reclaimed it first
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
attempts = (data["attempts"] || 0) + 1
|
|
529
|
+
error = "reservation timed out - consumer did not acknowledge within the visibility timeout"
|
|
530
|
+
job = Tina4::Job.new(
|
|
531
|
+
topic: data["topic"] || topic.to_s,
|
|
532
|
+
payload: data["payload"],
|
|
533
|
+
id: data["id"],
|
|
534
|
+
priority: data["priority"] || 0,
|
|
535
|
+
attempts: attempts,
|
|
536
|
+
error: error
|
|
537
|
+
)
|
|
538
|
+
# Mutex-free writers: reclaim_expired runs inside dequeue's locked
|
|
539
|
+
# block, and Ruby's Mutex is non-reentrant.
|
|
540
|
+
if attempts >= @max_retries
|
|
541
|
+
write_dead_letter(job, error)
|
|
542
|
+
else
|
|
543
|
+
write_pending(job, delay_seconds: 0, error: error)
|
|
544
|
+
end
|
|
545
|
+
rescue JSON::ParserError
|
|
546
|
+
next
|
|
547
|
+
end
|
|
548
|
+
end
|
|
411
549
|
end
|
|
412
550
|
end
|
|
413
551
|
end
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
module Tina4
|
|
4
4
|
module QueueBackends
|
|
5
5
|
class MongoBackend
|
|
6
|
+
# Reservation/visibility + retry policy (settable so a Queue can propagate
|
|
7
|
+
# its own onto a backend instance passed directly — legacy path).
|
|
8
|
+
attr_accessor :visibility_timeout, :max_retries
|
|
9
|
+
|
|
6
10
|
def initialize(options = {})
|
|
7
11
|
require "mongo"
|
|
12
|
+
@max_retries = options[:max_retries] || 3
|
|
8
13
|
|
|
9
14
|
uri = options[:uri] || ENV["TINA4_MONGO_URI"]
|
|
10
15
|
host = options[:host] || ENV.fetch("TINA4_MONGO_HOST", "localhost")
|
|
@@ -13,6 +18,11 @@ module Tina4
|
|
|
13
18
|
password = options[:password] || ENV["TINA4_MONGO_PASSWORD"]
|
|
14
19
|
db_name = options[:db] || ENV.fetch("TINA4_MONGO_DB", "tina4")
|
|
15
20
|
@collection_name = options[:collection] || ENV.fetch("TINA4_MONGO_COLLECTION", "tina4_queue")
|
|
21
|
+
# Reservation/visibility timeout (seconds): a dequeued message is held
|
|
22
|
+
# reserved (status "processing") with available_at = now + timeout;
|
|
23
|
+
# reclaim_expired returns it once that passes (consumer died mid-flight).
|
|
24
|
+
# <= 0 disables reclaim.
|
|
25
|
+
@visibility_timeout = resolve_visibility_timeout(options[:visibility_timeout])
|
|
16
26
|
|
|
17
27
|
if uri
|
|
18
28
|
@client = Mongo::Client.new(uri)
|
|
@@ -29,6 +39,14 @@ module Tina4
|
|
|
29
39
|
raise "MongoDB backend requires the 'mongo' gem. Install with: gem install mongo"
|
|
30
40
|
end
|
|
31
41
|
|
|
42
|
+
def resolve_visibility_timeout(option)
|
|
43
|
+
return option.to_f unless option.nil?
|
|
44
|
+
|
|
45
|
+
Float(ENV.fetch("TINA4_QUEUE_VISIBILITY_TIMEOUT", "300"))
|
|
46
|
+
rescue ArgumentError, TypeError
|
|
47
|
+
300.0
|
|
48
|
+
end
|
|
49
|
+
|
|
32
50
|
def enqueue(message)
|
|
33
51
|
collection.insert_one(
|
|
34
52
|
_id: message.id,
|
|
@@ -41,9 +59,21 @@ module Tina4
|
|
|
41
59
|
end
|
|
42
60
|
|
|
43
61
|
def dequeue(topic)
|
|
62
|
+
# Reclaim any reservations whose consumer died before acking, then take
|
|
63
|
+
# the next available message (at-least-once delivery).
|
|
64
|
+
reclaim_expired(topic, @max_retries)
|
|
65
|
+
now = Time.now.utc
|
|
66
|
+
# The claim advances available_at to now + visibility_timeout and records
|
|
67
|
+
# reserved_at so reclaim_expired can return the job if the consumer dies
|
|
68
|
+
# before acknowledge/complete. This is the fix for the "reserved forever"
|
|
69
|
+
# bug — previously available_at was left unchanged.
|
|
44
70
|
doc = collection.find_one_and_update(
|
|
45
71
|
{ topic: topic, status: "pending" },
|
|
46
|
-
{ "$set" => {
|
|
72
|
+
{ "$set" => {
|
|
73
|
+
status: "processing",
|
|
74
|
+
reserved_at: now,
|
|
75
|
+
available_at: now + (@visibility_timeout || 0)
|
|
76
|
+
} },
|
|
47
77
|
sort: { created_at: 1 },
|
|
48
78
|
return_document: :after
|
|
49
79
|
)
|
|
@@ -52,10 +82,54 @@ module Tina4
|
|
|
52
82
|
Tina4::Job.new(
|
|
53
83
|
topic: doc["topic"],
|
|
54
84
|
payload: doc["payload"],
|
|
55
|
-
id: doc["_id"]
|
|
85
|
+
id: doc["_id"],
|
|
86
|
+
priority: doc["priority"] || 0,
|
|
87
|
+
attempts: doc["attempts"] || 0
|
|
56
88
|
)
|
|
57
89
|
end
|
|
58
90
|
|
|
91
|
+
# Return reservations whose visibility window expired (at-least-once).
|
|
92
|
+
#
|
|
93
|
+
# A message left "processing" with available_at <= now had a consumer die
|
|
94
|
+
# before acknowledging. Each is atomically flipped back to "pending" with
|
|
95
|
+
# attempts incremented (so the next dequeue re-delivers it); once attempts
|
|
96
|
+
# >= max_retries it is dead-lettered instead. Returns the number reclaimed.
|
|
97
|
+
# Disabled when visibility_timeout <= 0.
|
|
98
|
+
def reclaim_expired(topic, max_retries)
|
|
99
|
+
return 0 if @visibility_timeout.nil? || @visibility_timeout <= 0
|
|
100
|
+
|
|
101
|
+
reclaimed = 0
|
|
102
|
+
loop do
|
|
103
|
+
now = Time.now.utc
|
|
104
|
+
doc = collection.find_one_and_update(
|
|
105
|
+
{ topic: topic, status: "processing", available_at: { "$lte" => now } },
|
|
106
|
+
{ "$set" => { status: "pending", available_at: now, reserved_at: nil },
|
|
107
|
+
"$inc" => { attempts: 1 } },
|
|
108
|
+
sort: { available_at: 1 },
|
|
109
|
+
return_document: :after
|
|
110
|
+
)
|
|
111
|
+
break unless doc
|
|
112
|
+
|
|
113
|
+
reclaimed += 1
|
|
114
|
+
next if (doc["attempts"] || 0) < max_retries
|
|
115
|
+
|
|
116
|
+
# Out of retries — move it to the dead-letter queue and remove the
|
|
117
|
+
# original so it is not re-delivered.
|
|
118
|
+
collection.insert_one(
|
|
119
|
+
_id: "#{doc["_id"]}.dead_letter",
|
|
120
|
+
topic: "#{topic}.dead_letter",
|
|
121
|
+
payload: doc["payload"],
|
|
122
|
+
status: "dead",
|
|
123
|
+
priority: doc["priority"] || 0,
|
|
124
|
+
attempts: doc["attempts"] || 0,
|
|
125
|
+
error: "reservation timed out - consumer did not acknowledge within the visibility timeout",
|
|
126
|
+
created_at: Time.now.utc
|
|
127
|
+
)
|
|
128
|
+
collection.delete_one(_id: doc["_id"], topic: topic)
|
|
129
|
+
end
|
|
130
|
+
reclaimed
|
|
131
|
+
end
|
|
132
|
+
|
|
59
133
|
def acknowledge(message)
|
|
60
134
|
collection.delete_one(_id: message.id)
|
|
61
135
|
end
|
data/lib/tina4/version.rb
CHANGED