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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02b84dbb5f8e4d009306495921ab7849a28fd769d81d13019b311523d970a0e5
4
- data.tar.gz: e1b298c7bd03444def8787941f87a7e6fd0adcb6be23efe810990c6005d27910
3
+ metadata.gz: 637cbcba6a20d87ba864c46ba1a993f839923e70139318c5b0f5096d206dd96f
4
+ data.tar.gz: '0960af3938c2a29b007934cc0e09e6988a701c7abd2af0894dbf0cc281af4a41'
5
5
  SHA512:
6
- metadata.gz: 8bf774f6208956221c87bb2f546a04aab950a3cae5962dae94bbd430ee55463481886ba315a21f2680b017828d99df0c6e7a54fff331cff0e559b176da45c5a9
7
- data.tar.gz: 5242a9295d6e70763ca68a377f2aa3dfd2ac8b37402b8660f439ea6bc6e0c5ddb1f278d277c2ff4ab8bf62b6d80f0ad316c8c07c099f07ecdee9040bea0ddfe5
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, :attempts, :priority, :available_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, attempts: 0, queue: 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 incremented attempts.
23
- # Uses the stored queue reference (set at construction time).
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
- @attempts += 1
29
- @status = :pending
30
- @available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
31
- q.backend.enqueue(self)
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
- # Mark this job as failed with a reason.
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
- @attempts += 1
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
- if @backend.respond_to?(:dequeue_batch)
46
- @backend.dequeue_batch(@topic, count)
47
- else
48
- jobs = []
49
- count.times do
50
- job = @backend.dequeue(@topic)
51
- break if job.nil?
52
- jobs << job
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
- jobs
55
- end
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
- def pop_by_id(id)
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
- return backend if backend && !backend.is_a?(Symbol) && !backend.is_a?(String)
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
- dir = topic_path(topic)
29
- return nil unless Dir.exist?(dir)
47
+ candidate = available_candidates(topic).first
48
+ return nil unless candidate
30
49
 
31
- now = Time.now
32
- candidates = []
50
+ File.delete(candidate[:file])
51
+ job_from_data(candidate[:data], topic)
52
+ end
53
+ end
33
54
 
34
- Dir.glob(File.join(dir, "*.json")).each do |f|
35
- data = JSON.parse(File.read(f))
36
- # Skip messages that are not yet available (delayed)
37
- if data["available_at"]
38
- available_at = Time.parse(data["available_at"])
39
- next if available_at > now
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
- def dequeue_batch(topic, count)
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 [] unless Dir.exist?(dir)
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
- if data["available_at"]
77
- available_at = Time.parse(data["available_at"])
78
- next if available_at > now
79
- end
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
- File.write(path, message.to_json)
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 (completed, failed, dead).
165
- # For 'dead', removes from the dead_letter directory.
166
- # For 'failed', removes from the topic directory (re-queued failed messages).
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.to_s == "dead"
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
- elsif status.to_s == "failed" || status.to_s == "completed" || status.to_s == "pending"
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
- # Re-queue failed messages (under max_retries) back to pending.
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
- # Get jobs that failed but are still eligible for retry (under max_retries).
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
- return [] unless Dir.exist?(@dead_letter_dir)
263
+ dir = topic_path(topic)
264
+ return [] unless Dir.exist?(dir)
248
265
  jobs = []
249
- Dir.glob(File.join(@dead_letter_dir, "*.json")).sort_by { |f| File.mtime(f) }.each do |file|
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
- next unless data["topic"] == topic.to_s
252
- next if (data["attempts"] || 0) >= max_retries
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
- # Retry all dead letter jobs for this topic. Returns true if any were re-queued.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.32"
4
+ VERSION = "3.13.33"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.32
4
+ version: 3.13.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team