tina4ruby 3.13.32 → 3.13.33
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/job.rb +43 -18
- data/lib/tina4/queue.rb +43 -23
- data/lib/tina4/queue_backends/lite_backend.rb +196 -81
- 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: 637cbcba6a20d87ba864c46ba1a993f839923e70139318c5b0f5096d206dd96f
|
|
4
|
+
data.tar.gz: '0960af3938c2a29b007934cc0e09e6988a701c7abd2af0894dbf0cc281af4a41'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 888be7579dd11c54e73823a1d9fa471814d1839413a95713c665aa105cdfbcdfe1f5d85bfcc09f799cd3a099a8f259755896df8a42f896d0e1c2103640e0dfb1
|
|
7
|
+
data.tar.gz: 07c92f8524fd74d401a466aea1ce23d45a2e439fed77e11aa7b61086cc2cf438110542721b890e6163e3929f77f94df021bb54b091607ec435f4f5e3b109fa48
|
data/lib/tina4/job.rb
CHANGED
|
@@ -4,31 +4,41 @@ require "securerandom"
|
|
|
4
4
|
|
|
5
5
|
module Tina4
|
|
6
6
|
class Job
|
|
7
|
-
attr_reader :id, :topic, :payload, :created_at, :
|
|
8
|
-
attr_accessor :status
|
|
7
|
+
attr_reader :id, :topic, :payload, :created_at, :priority, :available_at
|
|
8
|
+
attr_accessor :status, :attempts, :error, :queue
|
|
9
9
|
|
|
10
|
-
def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil,
|
|
10
|
+
def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil,
|
|
11
|
+
attempts: 0, created_at: nil, error: nil, queue: nil)
|
|
11
12
|
@id = id || SecureRandom.uuid
|
|
12
13
|
@topic = topic
|
|
13
14
|
@payload = payload
|
|
14
|
-
@created_at = Time.now
|
|
15
|
+
@created_at = created_at || Time.now
|
|
15
16
|
@attempts = attempts
|
|
16
17
|
@priority = priority
|
|
17
18
|
@available_at = available_at
|
|
19
|
+
# Populated by fail()/reject() — why the job last died. Surfaces in
|
|
20
|
+
# dead_letters() so consumers can see the failure reason without
|
|
21
|
+
# trawling logs.
|
|
22
|
+
@error = error
|
|
18
23
|
@status = :pending
|
|
19
24
|
@queue = queue
|
|
20
25
|
end
|
|
21
26
|
|
|
22
|
-
# Re-queue this message with
|
|
23
|
-
#
|
|
27
|
+
# Re-queue this message with optional delay. Always re-enqueues regardless
|
|
28
|
+
# of the retry limit — this is a manual override, distinct from the
|
|
29
|
+
# automatic fail() path. Increments attempts.
|
|
24
30
|
def retry(delay_seconds: 0)
|
|
25
31
|
q = @queue
|
|
26
32
|
raise ArgumentError, "No queue reference — set at construction" unless q
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
if q.backend.respond_to?(:retry)
|
|
35
|
+
q.backend.retry(self, delay_seconds: delay_seconds)
|
|
36
|
+
else
|
|
37
|
+
@attempts += 1
|
|
38
|
+
@status = :pending
|
|
39
|
+
@available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
|
|
40
|
+
q.backend.enqueue(self)
|
|
41
|
+
end
|
|
32
42
|
self
|
|
33
43
|
end
|
|
34
44
|
|
|
@@ -41,10 +51,11 @@ module Tina4
|
|
|
41
51
|
id: @id,
|
|
42
52
|
topic: @topic,
|
|
43
53
|
payload: @payload,
|
|
44
|
-
created_at: @created_at.iso8601,
|
|
54
|
+
created_at: @created_at.iso8601(6),
|
|
45
55
|
attempts: @attempts,
|
|
46
56
|
status: @status,
|
|
47
|
-
priority: @priority
|
|
57
|
+
priority: @priority,
|
|
58
|
+
error: @error
|
|
48
59
|
}
|
|
49
60
|
h[:available_at] = @available_at.iso8601 if @available_at
|
|
50
61
|
h
|
|
@@ -58,23 +69,37 @@ module Tina4
|
|
|
58
69
|
@attempts += 1
|
|
59
70
|
end
|
|
60
71
|
|
|
61
|
-
# Mark this job as completed.
|
|
72
|
+
# Mark this job as completed. Terminal — the job is done and removed.
|
|
62
73
|
def complete
|
|
63
74
|
@status = :completed
|
|
75
|
+
@queue.backend.complete(self) if @queue && @queue.backend.respond_to?(:complete)
|
|
76
|
+
self
|
|
64
77
|
end
|
|
65
78
|
|
|
66
|
-
#
|
|
79
|
+
# Record a failed attempt.
|
|
80
|
+
#
|
|
81
|
+
# Increments +attempts+ and stores +reason+. If the job still has retries
|
|
82
|
+
# left (+attempts < max_retries+) it is automatically re-enqueued to the
|
|
83
|
+
# pending queue, so the next pop/consume picks it up again (after the
|
|
84
|
+
# queue's retry_backoff delay, if any). Once it has been attempted
|
|
85
|
+
# +max_retries+ times it is moved to the dead-letter store, where
|
|
86
|
+
# queue.dead_letters returns it. No manual retry_failed is required.
|
|
67
87
|
def fail(reason = "")
|
|
68
|
-
@status = :failed
|
|
69
88
|
@error = reason
|
|
70
|
-
@
|
|
89
|
+
if @queue && @queue.backend.respond_to?(:fail)
|
|
90
|
+
@queue.backend.fail(self, reason)
|
|
91
|
+
@status = @attempts >= @queue.max_retries ? :dead : :pending
|
|
92
|
+
else
|
|
93
|
+
# No backend reference — degrade to in-memory bookkeeping only.
|
|
94
|
+
@attempts += 1
|
|
95
|
+
@status = :failed
|
|
96
|
+
end
|
|
97
|
+
self
|
|
71
98
|
end
|
|
72
99
|
|
|
73
100
|
# Reject this job with a reason. Alias for fail().
|
|
74
101
|
def reject(reason = "")
|
|
75
102
|
fail(reason)
|
|
76
103
|
end
|
|
77
|
-
|
|
78
|
-
attr_reader :error
|
|
79
104
|
end
|
|
80
105
|
end
|
data/lib/tina4/queue.rb
CHANGED
|
@@ -17,11 +17,14 @@ 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
|
|
20
|
+
attr_reader :topic, :max_retries, :retry_backoff
|
|
21
21
|
|
|
22
|
-
def initialize(topic:, backend: nil, max_retries: 3)
|
|
22
|
+
def initialize(topic:, backend: nil, max_retries: 3, retry_backoff: 0)
|
|
23
23
|
@topic = topic
|
|
24
24
|
@max_retries = max_retries
|
|
25
|
+
# Seconds to wait before a failed job is re-attempted (lite backend).
|
|
26
|
+
# Default 0 = retry on the very next pop/consume iteration.
|
|
27
|
+
@retry_backoff = retry_backoff
|
|
25
28
|
@backend = resolve_backend_arg(backend)
|
|
26
29
|
end
|
|
27
30
|
|
|
@@ -37,22 +40,25 @@ module Tina4
|
|
|
37
40
|
|
|
38
41
|
# Pop the next available job. Returns Job or nil.
|
|
39
42
|
def pop # -> Job|None
|
|
40
|
-
@backend.dequeue(@topic)
|
|
43
|
+
attach(@backend.dequeue(@topic))
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
# Pop up to count jobs at once. Returns a partial batch if fewer available.
|
|
44
47
|
def pop_batch(count)
|
|
45
|
-
|
|
46
|
-
@backend.dequeue_batch
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
jobs =
|
|
49
|
+
if @backend.respond_to?(:dequeue_batch)
|
|
50
|
+
@backend.dequeue_batch(@topic, count)
|
|
51
|
+
else
|
|
52
|
+
collected = []
|
|
53
|
+
count.times do
|
|
54
|
+
job = @backend.dequeue(@topic)
|
|
55
|
+
break if job.nil?
|
|
56
|
+
collected << job
|
|
57
|
+
end
|
|
58
|
+
collected
|
|
53
59
|
end
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
jobs.each { |job| attach(job) }
|
|
61
|
+
jobs
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
# Clear all pending jobs from this queue's topic. Returns count removed.
|
|
@@ -157,7 +163,7 @@ module Tina4
|
|
|
157
163
|
end
|
|
158
164
|
else
|
|
159
165
|
loop do
|
|
160
|
-
job = @backend.dequeue(topic)
|
|
166
|
+
job = attach(@backend.dequeue(topic))
|
|
161
167
|
if job.nil?
|
|
162
168
|
break if poll_interval <= 0
|
|
163
169
|
sleep(poll_interval)
|
|
@@ -172,7 +178,7 @@ module Tina4
|
|
|
172
178
|
Enumerator.new do |yielder|
|
|
173
179
|
consumed = 0
|
|
174
180
|
loop do
|
|
175
|
-
job = @backend.dequeue(topic)
|
|
181
|
+
job = attach(@backend.dequeue(topic))
|
|
176
182
|
if job.nil?
|
|
177
183
|
break if poll_interval <= 0
|
|
178
184
|
sleep(poll_interval)
|
|
@@ -186,10 +192,12 @@ module Tina4
|
|
|
186
192
|
end
|
|
187
193
|
end
|
|
188
194
|
|
|
189
|
-
# Pop a specific job by ID from the queue.
|
|
190
|
-
|
|
195
|
+
# Pop a specific job by ID from the queue. Searches the pending queue for
|
|
196
|
+
# the given topic (defaults to this queue's topic). Returns the matching
|
|
197
|
+
# Job (claimed/removed from the queue) or nil.
|
|
198
|
+
def pop_by_id(topic = nil, id)
|
|
191
199
|
return nil unless @backend.respond_to?(:find_by_id)
|
|
192
|
-
@backend.find_by_id(@topic, id)
|
|
200
|
+
attach(@backend.find_by_id(topic || @topic, id))
|
|
193
201
|
end
|
|
194
202
|
|
|
195
203
|
# Get the number of messages by status.
|
|
@@ -259,12 +267,12 @@ module Tina4
|
|
|
259
267
|
end
|
|
260
268
|
|
|
261
269
|
# Resolve the default backend from env vars.
|
|
262
|
-
def self.resolve_backend(name = nil)
|
|
270
|
+
def self.resolve_backend(name = nil, max_retries: 3, retry_backoff: 0)
|
|
263
271
|
chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "file").downcase.strip
|
|
264
272
|
|
|
265
273
|
case chosen.to_s
|
|
266
274
|
when "lite", "file", "default"
|
|
267
|
-
Tina4::QueueBackends::LiteBackend.new
|
|
275
|
+
Tina4::QueueBackends::LiteBackend.new(max_retries: max_retries, retry_backoff: retry_backoff)
|
|
268
276
|
when "rabbitmq"
|
|
269
277
|
config = resolve_rabbitmq_config
|
|
270
278
|
Tina4::QueueBackends::RabbitmqBackend.new(config)
|
|
@@ -281,11 +289,23 @@ module Tina4
|
|
|
281
289
|
|
|
282
290
|
private
|
|
283
291
|
|
|
292
|
+
# Stamp the queue reference onto a popped job so job.fail / job.retry /
|
|
293
|
+
# job.complete can reach the backend. Returns the job (or nil) unchanged.
|
|
294
|
+
def attach(job)
|
|
295
|
+
job.queue = self if job
|
|
296
|
+
job
|
|
297
|
+
end
|
|
298
|
+
|
|
284
299
|
def resolve_backend_arg(backend)
|
|
285
|
-
# If a backend instance is passed directly (legacy), use it
|
|
286
|
-
|
|
300
|
+
# If a backend instance is passed directly (legacy), use it. Best-effort
|
|
301
|
+
# propagate the queue's retry policy if the instance exposes setters.
|
|
302
|
+
if backend && !backend.is_a?(Symbol) && !backend.is_a?(String)
|
|
303
|
+
backend.max_retries = @max_retries if backend.respond_to?(:max_retries=)
|
|
304
|
+
backend.retry_backoff = @retry_backoff if backend.respond_to?(:retry_backoff=)
|
|
305
|
+
return backend
|
|
306
|
+
end
|
|
287
307
|
# If a symbol or string name is passed, resolve it
|
|
288
|
-
Queue.resolve_backend(backend)
|
|
308
|
+
Queue.resolve_backend(backend, max_retries: @max_retries, retry_backoff: @retry_backoff)
|
|
289
309
|
end
|
|
290
310
|
|
|
291
311
|
def self.resolve_rabbitmq_config
|
|
@@ -5,10 +5,29 @@ require "time"
|
|
|
5
5
|
|
|
6
6
|
module Tina4
|
|
7
7
|
module QueueBackends
|
|
8
|
+
# File-based queue backend — JSON files on disk. Zero dependencies.
|
|
9
|
+
#
|
|
10
|
+
# Each job is stored as a separate .json file under <dir>/<topic>/.
|
|
11
|
+
# Dead-lettered jobs (those that exhausted their retries) live under the
|
|
12
|
+
# shared <dir>/dead_letter/ directory, tagged with their topic.
|
|
13
|
+
#
|
|
14
|
+
# Dequeue policy: highest priority first, ties broken oldest-first by the
|
|
15
|
+
# stored created_at (ISO-8601, so lexicographic == chronological). The
|
|
16
|
+
# file name is NOT the ordering key — the stored priority/created_at are.
|
|
8
17
|
class LiteBackend
|
|
18
|
+
# Retry policy — settable so a Queue can propagate its own max_retries /
|
|
19
|
+
# retry_backoff onto a backend instance passed directly (legacy path).
|
|
20
|
+
attr_accessor :max_retries, :retry_backoff
|
|
21
|
+
|
|
9
22
|
def initialize(options = {})
|
|
10
23
|
@dir = options[:dir] || File.join(Dir.pwd, ".queue")
|
|
11
24
|
@dead_letter_dir = File.join(@dir, "dead_letter")
|
|
25
|
+
# Retry policy. Mirrors the Python lite backend: a failed job is
|
|
26
|
+
# re-enqueued while attempts < max_retries, then dead-lettered.
|
|
27
|
+
@max_retries = options[:max_retries] || 3
|
|
28
|
+
# Seconds to delay a job's next attempt when fail() re-enqueues it.
|
|
29
|
+
# 0 (default) = retry on the very next pop/consume iteration.
|
|
30
|
+
@retry_backoff = options[:retry_backoff] || 0
|
|
12
31
|
FileUtils.mkdir_p(@dir)
|
|
13
32
|
FileUtils.mkdir_p(@dead_letter_dir)
|
|
14
33
|
@mutex = Mutex.new
|
|
@@ -25,94 +44,85 @@ module Tina4
|
|
|
25
44
|
|
|
26
45
|
def dequeue(topic)
|
|
27
46
|
@mutex.synchronize do
|
|
28
|
-
|
|
29
|
-
return nil unless
|
|
47
|
+
candidate = available_candidates(topic).first
|
|
48
|
+
return nil unless candidate
|
|
30
49
|
|
|
31
|
-
|
|
32
|
-
|
|
50
|
+
File.delete(candidate[:file])
|
|
51
|
+
job_from_data(candidate[:data], topic)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
end
|
|
41
|
-
candidates << { file: f, data: data, priority: data["priority"] || 0, mtime: File.mtime(f) }
|
|
42
|
-
rescue JSON::ParserError
|
|
43
|
-
next
|
|
55
|
+
def dequeue_batch(topic, count)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
chosen = available_candidates(topic).first(count)
|
|
58
|
+
chosen.map do |c|
|
|
59
|
+
File.delete(c[:file])
|
|
60
|
+
job_from_data(c[:data], topic)
|
|
44
61
|
end
|
|
45
|
-
|
|
46
|
-
return nil if candidates.empty?
|
|
47
|
-
|
|
48
|
-
# Sort by priority descending (higher first), then by mtime ascending (oldest first)
|
|
49
|
-
candidates.sort_by! { |c| [-c[:priority], c[:mtime]] }
|
|
50
|
-
|
|
51
|
-
chosen = candidates.first
|
|
52
|
-
File.delete(chosen[:file])
|
|
53
|
-
data = chosen[:data]
|
|
54
|
-
|
|
55
|
-
Tina4::Job.new(
|
|
56
|
-
topic: data["topic"] || topic.to_s,
|
|
57
|
-
payload: data["payload"],
|
|
58
|
-
id: data["id"],
|
|
59
|
-
priority: data["priority"] || 0,
|
|
60
|
-
available_at: data["available_at"] ? Time.parse(data["available_at"]) : nil,
|
|
61
|
-
attempts: data["attempts"] || 0
|
|
62
|
-
)
|
|
63
62
|
end
|
|
64
63
|
end
|
|
65
64
|
|
|
66
|
-
|
|
65
|
+
# Find a specific pending job by id, claim it (delete the file) and
|
|
66
|
+
# return it. Returns nil when no pending job with that id exists.
|
|
67
|
+
def find_by_id(topic, id)
|
|
67
68
|
@mutex.synchronize do
|
|
68
69
|
dir = topic_path(topic)
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
now = Time.now
|
|
72
|
-
candidates = []
|
|
70
|
+
return nil unless Dir.exist?(dir)
|
|
73
71
|
|
|
72
|
+
target = id.to_s
|
|
74
73
|
Dir.glob(File.join(dir, "*.json")).each do |f|
|
|
75
74
|
data = JSON.parse(File.read(f))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
candidates << { file: f, data: data, priority: data["priority"] || 0, mtime: File.mtime(f) }
|
|
75
|
+
next unless data["id"].to_s == target
|
|
76
|
+
|
|
77
|
+
File.delete(f)
|
|
78
|
+
return job_from_data(data, topic)
|
|
81
79
|
rescue JSON::ParserError
|
|
82
80
|
next
|
|
83
81
|
end
|
|
84
|
-
|
|
85
|
-
return [] if candidates.empty?
|
|
86
|
-
|
|
87
|
-
candidates.sort_by! { |c| [-c[:priority], c[:mtime]] }
|
|
88
|
-
chosen = candidates.first(count)
|
|
89
|
-
|
|
90
|
-
chosen.map do |c|
|
|
91
|
-
File.delete(c[:file])
|
|
92
|
-
data = c[:data]
|
|
93
|
-
Tina4::Job.new(
|
|
94
|
-
topic: data["topic"] || topic.to_s,
|
|
95
|
-
payload: data["payload"],
|
|
96
|
-
id: data["id"],
|
|
97
|
-
priority: data["priority"] || 0,
|
|
98
|
-
available_at: data["available_at"] ? Time.parse(data["available_at"]) : nil,
|
|
99
|
-
attempts: data["attempts"] || 0
|
|
100
|
-
)
|
|
101
|
-
end
|
|
82
|
+
nil
|
|
102
83
|
end
|
|
103
84
|
end
|
|
104
85
|
|
|
105
86
|
def acknowledge(message)
|
|
106
|
-
# File already deleted on dequeue
|
|
87
|
+
# File already deleted on dequeue — nothing to do.
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def complete(message)
|
|
91
|
+
# Job file was already deleted on dequeue. complete() is terminal:
|
|
92
|
+
# the job is done and gone.
|
|
107
93
|
end
|
|
108
94
|
|
|
109
95
|
def requeue(message)
|
|
110
96
|
enqueue(message)
|
|
111
97
|
end
|
|
112
98
|
|
|
99
|
+
# Record a failed attempt. Increments attempts and stores the error.
|
|
100
|
+
# While attempts < max_retries the job is re-enqueued to pending (after
|
|
101
|
+
# the configured retry_backoff). Once attempts >= max_retries it is moved
|
|
102
|
+
# to the dead-letter store.
|
|
103
|
+
def fail(job, error = "")
|
|
104
|
+
job.attempts += 1
|
|
105
|
+
job.error = error
|
|
106
|
+
if job.attempts < @max_retries
|
|
107
|
+
requeue_job(job, delay_seconds: @retry_backoff, error: error)
|
|
108
|
+
else
|
|
109
|
+
move_to_dead_letter(job, error)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Explicit re-queue requested by the caller (job.retry()). Always
|
|
114
|
+
# re-enqueues regardless of the retry limit — a manual override, distinct
|
|
115
|
+
# from the automatic fail() path. Increments attempts, clears the error.
|
|
116
|
+
def retry(job, delay_seconds: 0)
|
|
117
|
+
job.attempts += 1
|
|
118
|
+
requeue_job(job, delay_seconds: delay_seconds, error: nil)
|
|
119
|
+
end
|
|
120
|
+
|
|
113
121
|
def dead_letter(message)
|
|
114
122
|
path = File.join(@dead_letter_dir, "#{message.id}.json")
|
|
115
|
-
|
|
123
|
+
data = message.to_hash
|
|
124
|
+
data[:status] = "dead"
|
|
125
|
+
File.write(path, JSON.generate(data))
|
|
116
126
|
end
|
|
117
127
|
|
|
118
128
|
def size(topic)
|
|
@@ -143,6 +153,7 @@ module Tina4
|
|
|
143
153
|
end
|
|
144
154
|
|
|
145
155
|
# Get dead letter jobs for a topic — messages that exceeded max retries.
|
|
156
|
+
# Returns Hashes (raw job data with status "dead").
|
|
146
157
|
def dead_letters(topic, max_retries: 3)
|
|
147
158
|
return [] unless Dir.exist?(@dead_letter_dir)
|
|
148
159
|
|
|
@@ -152,6 +163,7 @@ module Tina4
|
|
|
152
163
|
files.each do |file|
|
|
153
164
|
data = JSON.parse(File.read(file))
|
|
154
165
|
next unless data["topic"] == topic.to_s
|
|
166
|
+
next if (data["attempts"] || 0) < max_retries
|
|
155
167
|
data["status"] = "dead"
|
|
156
168
|
jobs << data
|
|
157
169
|
rescue JSON::ParserError
|
|
@@ -161,14 +173,13 @@ module Tina4
|
|
|
161
173
|
jobs
|
|
162
174
|
end
|
|
163
175
|
|
|
164
|
-
# Delete messages by status
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
# Returns the number of jobs purged.
|
|
176
|
+
# Delete messages by status. For "failed"/"dead"/"dead_letter", removes
|
|
177
|
+
# from the dead_letter directory. For "completed"/"pending", removes
|
|
178
|
+
# matching jobs from the topic (pending) directory. Returns count purged.
|
|
168
179
|
def purge(topic, status)
|
|
169
180
|
count = 0
|
|
170
181
|
|
|
171
|
-
if status
|
|
182
|
+
if dead_status?(status)
|
|
172
183
|
return 0 unless Dir.exist?(@dead_letter_dir)
|
|
173
184
|
|
|
174
185
|
Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
|
|
@@ -180,13 +191,13 @@ module Tina4
|
|
|
180
191
|
rescue JSON::ParserError
|
|
181
192
|
next
|
|
182
193
|
end
|
|
183
|
-
|
|
194
|
+
else
|
|
184
195
|
dir = topic_path(topic)
|
|
185
196
|
return 0 unless Dir.exist?(dir)
|
|
186
197
|
|
|
187
198
|
Dir.glob(File.join(dir, "*.json")).each do |file|
|
|
188
199
|
data = JSON.parse(File.read(file))
|
|
189
|
-
if data["status"] == status.to_s
|
|
200
|
+
if data["status"].to_s == status.to_s
|
|
190
201
|
File.delete(file)
|
|
191
202
|
count += 1
|
|
192
203
|
end
|
|
@@ -198,7 +209,7 @@ module Tina4
|
|
|
198
209
|
count
|
|
199
210
|
end
|
|
200
211
|
|
|
201
|
-
#
|
|
212
|
+
# Revive dead-letter jobs (under max_retries) back to pending.
|
|
202
213
|
# Returns the number of jobs re-queued.
|
|
203
214
|
def retry_failed(topic, max_retries: 3)
|
|
204
215
|
return 0 unless Dir.exist?(@dead_letter_dir)
|
|
@@ -207,18 +218,18 @@ module Tina4
|
|
|
207
218
|
FileUtils.mkdir_p(dir)
|
|
208
219
|
count = 0
|
|
209
220
|
|
|
210
|
-
# Dead letter directory contains messages that the Consumer moved there.
|
|
211
|
-
# Only retry those whose attempts are under max_retries.
|
|
212
221
|
Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
|
|
213
222
|
data = JSON.parse(File.read(file))
|
|
214
223
|
next unless data["topic"] == topic.to_s
|
|
215
224
|
next if (data["attempts"] || 0) >= max_retries
|
|
216
225
|
|
|
217
|
-
data["status"] = "pending"
|
|
218
226
|
msg = Tina4::Job.new(
|
|
219
227
|
topic: data["topic"],
|
|
220
228
|
payload: data["payload"],
|
|
221
|
-
id: data["id"]
|
|
229
|
+
id: data["id"],
|
|
230
|
+
priority: data["priority"] || 0,
|
|
231
|
+
attempts: data["attempts"] || 0,
|
|
232
|
+
error: data["error"]
|
|
222
233
|
)
|
|
223
234
|
enqueue(msg)
|
|
224
235
|
File.delete(file)
|
|
@@ -242,14 +253,20 @@ module Tina4
|
|
|
242
253
|
count
|
|
243
254
|
end
|
|
244
255
|
|
|
245
|
-
#
|
|
256
|
+
# Jobs that have failed at least once but are still being retried.
|
|
257
|
+
#
|
|
258
|
+
# Under the auto-retry lifecycle a failed-but-retryable job lives in the
|
|
259
|
+
# pending queue (not the dead-letter dir), so this scans the topic dir
|
|
260
|
+
# for jobs with 0 < attempts < max_retries. Dead-lettered jobs are
|
|
261
|
+
# returned by dead_letters(). Returns Hashes.
|
|
246
262
|
def failed(topic, max_retries: 3)
|
|
247
|
-
|
|
263
|
+
dir = topic_path(topic)
|
|
264
|
+
return [] unless Dir.exist?(dir)
|
|
248
265
|
jobs = []
|
|
249
|
-
Dir.glob(File.join(
|
|
266
|
+
Dir.glob(File.join(dir, "*.json")).sort_by { |f| File.mtime(f) }.each do |file|
|
|
250
267
|
data = JSON.parse(File.read(file))
|
|
251
|
-
|
|
252
|
-
next
|
|
268
|
+
attempts = data["attempts"] || 0
|
|
269
|
+
next unless attempts > 0 && attempts < max_retries
|
|
253
270
|
jobs << data
|
|
254
271
|
rescue JSON::ParserError
|
|
255
272
|
next
|
|
@@ -257,7 +274,12 @@ module Tina4
|
|
|
257
274
|
jobs
|
|
258
275
|
end
|
|
259
276
|
|
|
260
|
-
#
|
|
277
|
+
# Revive a specific dead-letter job by id back to the pending queue.
|
|
278
|
+
# When job_id is nil, revives every dead-letter for the topic.
|
|
279
|
+
#
|
|
280
|
+
# This is a manual override (Queue#retry(job_id)) — it always revives a
|
|
281
|
+
# dead-letter regardless of attempt count, mirroring job.retry. Returns
|
|
282
|
+
# true if any job was re-queued.
|
|
261
283
|
def retry_job(topic, job_id: nil, delay_seconds: 0)
|
|
262
284
|
return false unless Dir.exist?(@dead_letter_dir)
|
|
263
285
|
|
|
@@ -273,8 +295,10 @@ module Tina4
|
|
|
273
295
|
topic: data["topic"],
|
|
274
296
|
payload: data["payload"],
|
|
275
297
|
id: data["id"],
|
|
298
|
+
priority: data["priority"] || 0,
|
|
276
299
|
attempts: (data["attempts"] || 0) + 1,
|
|
277
|
-
available_at: available_at
|
|
300
|
+
available_at: available_at,
|
|
301
|
+
error: nil
|
|
278
302
|
)
|
|
279
303
|
enqueue(msg)
|
|
280
304
|
File.delete(file)
|
|
@@ -289,6 +313,97 @@ module Tina4
|
|
|
289
313
|
|
|
290
314
|
private
|
|
291
315
|
|
|
316
|
+
DEAD_STATES = %w[failed dead dead_letter].freeze
|
|
317
|
+
|
|
318
|
+
def dead_status?(status)
|
|
319
|
+
DEAD_STATES.include?(status.to_s)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Return [{file:, data:}, ...] for every pending, non-delayed job in the
|
|
323
|
+
# topic, ordered by the dequeue policy: priority DESC, then created_at
|
|
324
|
+
# ASC (oldest first).
|
|
325
|
+
def available_candidates(topic)
|
|
326
|
+
dir = topic_path(topic)
|
|
327
|
+
return [] unless Dir.exist?(dir)
|
|
328
|
+
|
|
329
|
+
now = Time.now
|
|
330
|
+
candidates = []
|
|
331
|
+
|
|
332
|
+
Dir.glob(File.join(dir, "*.json")).each do |f|
|
|
333
|
+
data = JSON.parse(File.read(f))
|
|
334
|
+
# Skip messages that are not yet available (delayed).
|
|
335
|
+
if data["available_at"]
|
|
336
|
+
available_at = Time.parse(data["available_at"])
|
|
337
|
+
next if available_at > now
|
|
338
|
+
end
|
|
339
|
+
candidates << { file: f, data: data }
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
next
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# priority DESC, then created_at ASC. created_at is an ISO-8601 string
|
|
345
|
+
# so lexicographic order == chronological order. Fall back to the file
|
|
346
|
+
# name for jobs written before created_at was persisted.
|
|
347
|
+
candidates.sort_by do |c|
|
|
348
|
+
[-(c[:data]["priority"] || 0), c[:data]["created_at"].to_s, c[:file]]
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def job_from_data(data, topic)
|
|
353
|
+
Tina4::Job.new(
|
|
354
|
+
topic: data["topic"] || topic.to_s,
|
|
355
|
+
payload: data["payload"],
|
|
356
|
+
id: data["id"],
|
|
357
|
+
priority: data["priority"] || 0,
|
|
358
|
+
available_at: data["available_at"] ? Time.parse(data["available_at"]) : nil,
|
|
359
|
+
attempts: data["attempts"] || 0,
|
|
360
|
+
created_at: data["created_at"] ? Time.parse(data["created_at"]) : nil,
|
|
361
|
+
error: data["error"]
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Write the job back to the pending queue with a fresh created_at (so
|
|
366
|
+
# within a priority tier it sorts behind jobs not yet attempted) and the
|
|
367
|
+
# current attempts/error carried over.
|
|
368
|
+
def requeue_job(job, delay_seconds: 0, error: nil)
|
|
369
|
+
available_at = delay_seconds > 0 ? (Time.now + delay_seconds).iso8601(6) : nil
|
|
370
|
+
data = {
|
|
371
|
+
id: job.id,
|
|
372
|
+
topic: job.topic,
|
|
373
|
+
payload: job.payload,
|
|
374
|
+
status: "pending",
|
|
375
|
+
priority: job.priority,
|
|
376
|
+
attempts: job.attempts,
|
|
377
|
+
error: error,
|
|
378
|
+
created_at: Time.now.iso8601(6)
|
|
379
|
+
}
|
|
380
|
+
data[:available_at] = available_at if available_at
|
|
381
|
+
@mutex.synchronize do
|
|
382
|
+
topic_dir = topic_path(job.topic)
|
|
383
|
+
FileUtils.mkdir_p(topic_dir)
|
|
384
|
+
File.write(File.join(topic_dir, "#{job.id}.json"), JSON.generate(data))
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Move a failed job to the dead-letter directory. Terminal until a manual
|
|
389
|
+
# retry_failed/retry revives it.
|
|
390
|
+
def move_to_dead_letter(job, error = "")
|
|
391
|
+
data = {
|
|
392
|
+
id: job.id,
|
|
393
|
+
topic: job.topic,
|
|
394
|
+
payload: job.payload,
|
|
395
|
+
status: "dead",
|
|
396
|
+
priority: job.priority,
|
|
397
|
+
attempts: job.attempts,
|
|
398
|
+
error: error,
|
|
399
|
+
failed_at: Time.now.iso8601(6)
|
|
400
|
+
}
|
|
401
|
+
@mutex.synchronize do
|
|
402
|
+
FileUtils.mkdir_p(@dead_letter_dir)
|
|
403
|
+
File.write(File.join(@dead_letter_dir, "#{job.id}.json"), JSON.generate(data))
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
292
407
|
def topic_path(topic)
|
|
293
408
|
safe_topic = topic.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
294
409
|
File.join(@dir, safe_topic)
|
data/lib/tina4/version.rb
CHANGED