tina4ruby 3.13.94 → 3.13.97

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -7,7 +7,9 @@ module Tina4
7
7
  module QueueBackends
8
8
  # File-based queue backend — JSON files on disk. Zero dependencies.
9
9
  #
10
- # Each job is stored as a separate .json file under <dir>/<topic>/.
10
+ # Each job is stored as a separate .queue-data file under <dir>/<topic>/,
11
+ # where <dir> is Tina4::Queue.base_path (TINA4_QUEUE_PATH, else data/queue)
12
+ # — the canonical cross-framework layout Python, PHP and Node all use.
11
13
  # Dead-lettered jobs (those that exhausted their retries) live under the
12
14
  # shared <dir>/dead_letter/ directory, tagged with their topic.
13
15
  #
@@ -15,13 +17,27 @@ module Tina4
15
17
  # stored created_at (ISO-8601, so lexicographic == chronological). The
16
18
  # file name is NOT the ordering key — the stored priority/created_at are.
17
19
  class LiteBackend
20
+ # The store's layout — extension, topic segment, dead-letter directory —
21
+ # is defined once on Tina4::Queue and shared with the dev-admin panel
22
+ # that reads the same files.
23
+ JOB_EXTENSION = Tina4::Queue::JOB_EXTENSION
24
+
25
+ # The pre-alignment Ruby store: Dir.pwd/.queue/<topic>/<id>.json. Read
26
+ # once per backend so an upgrading app's jobs are rescued rather than
27
+ # stranded (see migrate_legacy_store).
28
+ LEGACY_DIRNAME = ".queue"
29
+ LEGACY_JOB_EXTENSION = ".json"
30
+
18
31
  # Retry policy — settable so a Queue can propagate its own max_retries /
19
32
  # retry_backoff onto a backend instance passed directly (legacy path).
20
33
  attr_accessor :max_retries, :retry_backoff
21
34
 
22
35
  def initialize(options = {})
23
- @dir = options[:dir] || File.join(Dir.pwd, ".queue")
24
- @dead_letter_dir = File.join(@dir, "dead_letter")
36
+ # An explicit dir: is the caller's own store — take it as given. Only a
37
+ # store we RESOLVED is one the legacy rescue may touch.
38
+ resolved_store = options[:dir].nil?
39
+ @dir = options[:dir] || Tina4::Queue.base_path
40
+ @dead_letter_dir = File.join(@dir, Tina4::Queue::DEAD_LETTER_DIRNAME)
25
41
  # Retry policy. Mirrors the Python lite backend: a failed job is
26
42
  # re-enqueued while attempts < max_retries, then dead-lettered.
27
43
  @max_retries = options[:max_retries] || 3
@@ -39,6 +55,7 @@ module Tina4
39
55
  FileUtils.mkdir_p(@dir)
40
56
  FileUtils.mkdir_p(@dead_letter_dir)
41
57
  @mutex = Mutex.new
58
+ migrate_legacy_store if resolved_store
42
59
  end
43
60
 
44
61
  # Retry/visibility policy is settable so a Queue can propagate its own
@@ -49,7 +66,7 @@ module Tina4
49
66
  @mutex.synchronize do
50
67
  topic_dir = topic_path(message.topic)
51
68
  FileUtils.mkdir_p(topic_dir)
52
- path = File.join(topic_dir, "#{message.id}.json")
69
+ path = job_file(topic_dir, message.id)
53
70
  File.write(path, message.to_json)
54
71
  end
55
72
  end
@@ -98,7 +115,7 @@ module Tina4
98
115
  return nil unless Dir.exist?(dir)
99
116
 
100
117
  target = id.to_s
101
- Dir.glob(File.join(dir, "*.json")).each do |f|
118
+ job_files(dir).each do |f|
102
119
  data = JSON.parse(File.read(f))
103
120
  next unless data["id"].to_s == target
104
121
 
@@ -155,7 +172,7 @@ module Tina4
155
172
  end
156
173
 
157
174
  def dead_letter(message)
158
- path = File.join(@dead_letter_dir, "#{message.id}.json")
175
+ path = job_file(@dead_letter_dir, message.id)
159
176
  data = message.to_hash
160
177
  data[:status] = "dead"
161
178
  File.write(path, JSON.generate(data))
@@ -164,14 +181,23 @@ module Tina4
164
181
  def size(topic)
165
182
  dir = topic_path(topic)
166
183
  return 0 unless Dir.exist?(dir)
167
- Dir.glob(File.join(dir, "*.json")).length
184
+ job_files(dir).length
168
185
  end
169
186
 
187
+ # No-op: the file backend holds no connection to release.
188
+ #
189
+ # It exists so Queue#close can call ONE method on every backend instead of
190
+ # testing for it, and so switching TINA4_QUEUE_BACKEND to "lite" never
191
+ # turns a working close into a NoMethodError. It was the ONLY backend
192
+ # without one, so every `close if respond_to?(:close)` guard in the tree
193
+ # silently did nothing here. Idempotent by construction - nothing to drop.
194
+ def close; end
195
+
170
196
  # Count currently-reserved (in-flight) jobs for a topic.
171
197
  def reserved_count(topic)
172
198
  dir = reserved_path(topic)
173
199
  return 0 unless Dir.exist?(dir)
174
- Dir.glob(File.join(dir, "*.json")).length
200
+ job_files(dir).length
175
201
  end
176
202
 
177
203
  # Count dead-letter / failed messages for a topic.
@@ -179,7 +205,7 @@ module Tina4
179
205
  return 0 unless Dir.exist?(@dead_letter_dir)
180
206
 
181
207
  count = 0
182
- Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
208
+ job_files(@dead_letter_dir).each do |file|
183
209
  data = JSON.parse(File.read(file))
184
210
  count += 1 if data["topic"] == topic.to_s
185
211
  rescue JSON::ParserError
@@ -191,7 +217,7 @@ module Tina4
191
217
  def topics
192
218
  return [] unless Dir.exist?(@dir)
193
219
  Dir.children(@dir)
194
- .reject { |d| d == "dead_letter" }
220
+ .reject { |d| d == Tina4::Queue::DEAD_LETTER_DIRNAME }
195
221
  .select { |d| File.directory?(File.join(@dir, d)) }
196
222
  end
197
223
 
@@ -200,7 +226,7 @@ module Tina4
200
226
  def dead_letters(topic, max_retries: 3)
201
227
  return [] unless Dir.exist?(@dead_letter_dir)
202
228
 
203
- files = Dir.glob(File.join(@dead_letter_dir, "*.json")).sort_by { |f| File.mtime(f) }
229
+ files = job_files(@dead_letter_dir).sort_by { |f| File.mtime(f) }
204
230
  jobs = []
205
231
 
206
232
  files.each do |file|
@@ -225,7 +251,7 @@ module Tina4
225
251
  if dead_status?(status)
226
252
  return 0 unless Dir.exist?(@dead_letter_dir)
227
253
 
228
- Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
254
+ job_files(@dead_letter_dir).each do |file|
229
255
  data = JSON.parse(File.read(file))
230
256
  if data["topic"] == topic.to_s
231
257
  File.delete(file)
@@ -238,7 +264,7 @@ module Tina4
238
264
  dir = topic_path(topic)
239
265
  return 0 unless Dir.exist?(dir)
240
266
 
241
- Dir.glob(File.join(dir, "*.json")).each do |file|
267
+ job_files(dir).each do |file|
242
268
  data = JSON.parse(File.read(file))
243
269
  if data["status"].to_s == status.to_s
244
270
  File.delete(file)
@@ -261,7 +287,7 @@ module Tina4
261
287
  FileUtils.mkdir_p(dir)
262
288
  count = 0
263
289
 
264
- Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
290
+ job_files(@dead_letter_dir).each do |file|
265
291
  data = JSON.parse(File.read(file))
266
292
  next unless data["topic"] == topic.to_s
267
293
  next if (data["attempts"] || 0) >= max_retries
@@ -290,7 +316,7 @@ module Tina4
290
316
  count = 0
291
317
  [topic_path(topic), reserved_path(topic)].each do |dir|
292
318
  next unless Dir.exist?(dir)
293
- Dir.glob(File.join(dir, "*.json")).each do |file|
319
+ job_files(dir).each do |file|
294
320
  File.delete(file)
295
321
  count += 1
296
322
  rescue Errno::ENOENT
@@ -310,7 +336,7 @@ module Tina4
310
336
  dir = topic_path(topic)
311
337
  return [] unless Dir.exist?(dir)
312
338
  jobs = []
313
- Dir.glob(File.join(dir, "*.json")).sort_by { |f| File.mtime(f) }.each do |file|
339
+ job_files(dir).sort_by { |f| File.mtime(f) }.each do |file|
314
340
  data = JSON.parse(File.read(file))
315
341
  attempts = data["attempts"] || 0
316
342
  next unless attempts > 0 && attempts < max_retries
@@ -333,7 +359,7 @@ module Tina4
333
359
  available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
334
360
  count = 0
335
361
 
336
- Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
362
+ job_files(@dead_letter_dir).each do |file|
337
363
  data = JSON.parse(File.read(file))
338
364
  next unless data["topic"] == topic.to_s
339
365
  next if job_id && data["id"] != job_id.to_s
@@ -376,7 +402,7 @@ module Tina4
376
402
  now = Time.now
377
403
  candidates = []
378
404
 
379
- Dir.glob(File.join(dir, "*.json")).each do |f|
405
+ job_files(dir).each do |f|
380
406
  data = JSON.parse(File.read(f))
381
407
  # Skip messages that are not yet available (delayed).
382
408
  if data["available_at"]
@@ -434,7 +460,7 @@ module Tina4
434
460
  data[:available_at] = available_at if available_at
435
461
  topic_dir = topic_path(job.topic)
436
462
  FileUtils.mkdir_p(topic_dir)
437
- File.write(File.join(topic_dir, "#{job.id}.json"), JSON.generate(data))
463
+ File.write(job_file(topic_dir, job.id), JSON.generate(data))
438
464
  end
439
465
 
440
466
  # Move a failed job to the dead-letter directory. Terminal until a manual
@@ -456,12 +482,82 @@ module Tina4
456
482
  failed_at: Time.now.iso8601(6)
457
483
  }
458
484
  FileUtils.mkdir_p(@dead_letter_dir)
459
- File.write(File.join(@dead_letter_dir, "#{job.id}.json"), JSON.generate(data))
485
+ File.write(job_file(@dead_letter_dir, job.id), JSON.generate(data))
486
+ end
487
+
488
+ # One place decides what a job file is called and where a topic lives, so
489
+ # no call site here can be left addressing a directory or an extension
490
+ # the dev-admin panel does not also read.
491
+ def job_files(dir)
492
+ Tina4::Queue.job_files(dir)
493
+ end
494
+
495
+ def job_file(dir, id)
496
+ Tina4::Queue.job_file(dir, id)
460
497
  end
461
498
 
462
499
  def topic_path(topic)
463
- safe_topic = topic.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
464
- File.join(@dir, safe_topic)
500
+ File.join(@dir, Tina4::Queue.topic_dirname(topic))
501
+ end
502
+
503
+ # Rescue a pre-alignment store, once, on the way past.
504
+ #
505
+ # Until this change the Ruby file backend stored jobs at
506
+ # Dir.pwd/.queue/<topic>/<id>.json and read TINA4_QUEUE_PATH NOWHERE, so
507
+ # EVERY existing Ruby app has its jobs there — including apps that had
508
+ # TINA4_QUEUE_PATH set, because the variable did nothing at all. Moving
509
+ # to the canonical <TINA4_QUEUE_PATH|data/queue>/<topic>/*.queue-data
510
+ # layout without this would strand all of them, and a stranded job is
511
+ # pending work somebody is still waiting on: data loss, not cosmetics.
512
+ #
513
+ # MOVE, never copy — a copy would leave the same job in two stores and
514
+ # let it be delivered twice. Each file is moved individually and never
515
+ # over an existing destination, so concurrent processes can each only win
516
+ # a given file once and neither can overwrite a job the other placed.
517
+ # FileUtils.mv (not File.rename) because the new store may be on another
518
+ # filesystem, which is the whole point of TINA4_QUEUE_PATH.
519
+ #
520
+ # The trigger is the legacy directory EXISTING, not the new one being
521
+ # absent: during a rolling upgrade an old instance can still be writing
522
+ # to .queue after the new store exists, and those jobs must be collected
523
+ # too. Emptied directories are pruned so the steady-state cost is the one
524
+ # Dir.exist? below.
525
+ def migrate_legacy_store
526
+ legacy_root = File.expand_path(File.join(Dir.pwd, LEGACY_DIRNAME))
527
+ return unless Dir.exist?(legacy_root)
528
+ return if legacy_root == File.expand_path(@dir)
529
+
530
+ moved = 0
531
+ Dir.glob(File.join(legacy_root, "**", "*#{LEGACY_JOB_EXTENSION}")).sort.each do |source|
532
+ relative = source.delete_prefix("#{legacy_root}#{File::SEPARATOR}")
533
+ destination = File.join(@dir, "#{relative.delete_suffix(LEGACY_JOB_EXTENSION)}#{JOB_EXTENSION}")
534
+ next if File.exist?(destination)
535
+
536
+ FileUtils.mkdir_p(File.dirname(destination))
537
+ FileUtils.mv(source, destination)
538
+ moved += 1
539
+ rescue SystemCallError
540
+ next # another process won the race, or the file is not ours to move
541
+ end
542
+
543
+ prune_empty_dirs(legacy_root)
544
+ return if moved.zero?
545
+
546
+ Tina4::Log.info(
547
+ "Queue: moved #{moved} job(s) from the legacy #{LEGACY_DIRNAME}/ store to #{@dir} " \
548
+ "(now #{JOB_EXTENSION} files, matching Python/PHP/Node)"
549
+ )
550
+ end
551
+
552
+ # Remove +root+ and every directory under it that is empty, deepest
553
+ # first, so the migration's fast path stops firing once it has nothing
554
+ # left to do. Anything still holding a file is left exactly as it is.
555
+ def prune_empty_dirs(root)
556
+ Dir.glob(File.join(root, "**", "*"))
557
+ .select { |path| File.directory?(path) }
558
+ .sort_by { |path| -path.length }
559
+ .each { |path| Dir.rmdir(path) rescue nil }
560
+ Dir.rmdir(root) rescue nil
465
561
  end
466
562
 
467
563
  # Directory holding a topic's reservation records (in-flight jobs).
@@ -490,12 +586,12 @@ module Tina4
490
586
  }
491
587
  dir = reserved_path(topic)
492
588
  FileUtils.mkdir_p(dir)
493
- File.write(File.join(dir, "#{record[:id]}.json"), JSON.generate(record))
589
+ File.write(job_file(dir, record[:id]), JSON.generate(record))
494
590
  end
495
591
 
496
592
  # Delete a job's reservation record (best-effort).
497
593
  def clear_reservation(topic, id)
498
- File.delete(File.join(reserved_path(topic), "#{id}.json"))
594
+ File.delete(job_file(reserved_path(topic), id))
499
595
  rescue Errno::ENOENT
500
596
  nil
501
597
  end
@@ -513,7 +609,7 @@ module Tina4
513
609
  return unless Dir.exist?(dir)
514
610
 
515
611
  now = Time.now
516
- Dir.glob(File.join(dir, "*.json")).each do |file|
612
+ job_files(dir).each do |file|
517
613
  data = JSON.parse(File.read(file))
518
614
  available_at = data["available_at"] ? Time.parse(data["available_at"]) : now
519
615
  next if available_at > now # reservation still valid
@@ -58,12 +58,22 @@ module Tina4
58
58
  end
59
59
 
60
60
  def enqueue(message)
61
+ # Queue#push already resolved delay_seconds into an available_at (nil when
62
+ # undelayed). Persisting it is what makes a delayed job invisible until
63
+ # its time: dequeue filters on it below. Before 3.13.95 this field was
64
+ # never written, so a delayed job on Mongo fired immediately while the
65
+ # same code delayed correctly on the file backend.
61
66
  collection.insert_one(
62
67
  _id: message.id,
63
68
  topic: message.topic,
64
69
  payload: message.payload,
65
70
  created_at: message.created_at.utc,
71
+ available_at: message.available_at&.utc,
66
72
  attempts: message.attempts,
73
+ # Stored so the dequeue sort can order on it. It was read back on the
74
+ # way out (doc["priority"] || 0) but never written, so every job
75
+ # scored 0 and the queue was pure FIFO.
76
+ priority: message.priority,
67
77
  status: "pending"
68
78
  )
69
79
  end
@@ -77,14 +87,27 @@ module Tina4
77
87
  # reserved_at so reclaim_expired can return the job if the consumer dies
78
88
  # before acknowledge/complete. This is the fix for the "reserved forever"
79
89
  # bug — previously available_at was left unchanged.
90
+ # A job is claimable only once available_at has passed. The $or arm is not
91
+ # optional: documents enqueued before available_at was written have no
92
+ # such field, and a bare { "$lte" => now } would strand every one of them
93
+ # in the collection forever.
80
94
  doc = collection.find_one_and_update(
81
- { topic: topic, status: "pending" },
95
+ { topic: topic, status: "pending",
96
+ "$or" => [
97
+ { available_at: nil },
98
+ { available_at: { "$exists" => false } },
99
+ { available_at: { "$lte" => now } }
100
+ ] },
82
101
  { "$set" => {
83
102
  status: "processing",
84
103
  reserved_at: now,
85
104
  available_at: now + (@visibility_timeout || 0)
86
105
  } },
87
- sort: { created_at: 1 },
106
+ # Highest priority first, ties broken oldest-first — the same ordering
107
+ # policy LiteBackend applies. Sorting on created_at alone made the
108
+ # backend pure FIFO, so an urgent job queued behind a backlog waited
109
+ # for all of it.
110
+ sort: { priority: -1, created_at: 1 },
88
111
  return_document: :after
89
112
  )
90
113
  return nil unless doc
@@ -162,7 +185,12 @@ module Tina4
162
185
  if job.attempts >= @max_retries
163
186
  collection.find_one_and_update(
164
187
  { _id: job.id },
188
+ # attempts MUST be persisted here. fail() increments the in-memory
189
+ # counter only; without writing it the dead-letter document keeps
190
+ # the value it held BEFORE the final attempt, so dead_letters()
191
+ # under-reported by one on every Mongo dead letter.
165
192
  { "$set" => { status: "dead", topic: "#{job.topic}.dead_letter",
193
+ attempts: job.attempts,
166
194
  error: error, reserved_at: nil } },
167
195
  upsert: true
168
196
  )
@@ -208,18 +236,59 @@ module Tina4
208
236
  )
209
237
  end
210
238
 
239
+ # Remove every pending job for a topic. Queue#clear used to return 0
240
+ # silently here, because this backend had no clear at all and the caller
241
+ # guarded on respond_to?(:clear) - so clearing a mongo-backed queue was a
242
+ # no-op that looked like success.
243
+ def clear(topic)
244
+ result = collection.delete_many(topic: topic, status: "pending")
245
+ result.deleted_count
246
+ end
247
+
248
+ # Claim ONE specific job by id, the same way dequeue claims the head.
249
+ # Queue#pop_by_id used to return nil silently for the same reason.
250
+ def find_by_id(topic, id)
251
+ now = Time.now.utc
252
+ doc = collection.find_one_and_update(
253
+ { _id: id, topic: topic, status: "pending" },
254
+ { "$set" => { status: "processing", reserved_at: now,
255
+ available_at: now + (@visibility_timeout || 0) } },
256
+ return_document: :after
257
+ )
258
+ return nil unless doc
259
+
260
+ Tina4::Job.new(
261
+ topic: doc["topic"],
262
+ payload: doc["payload"],
263
+ id: doc["_id"],
264
+ priority: doc["priority"] || 0,
265
+ attempts: doc["attempts"] || 0
266
+ )
267
+ end
268
+
211
269
  def size(topic)
212
270
  collection.count_documents(topic: topic, status: "pending")
213
271
  end
214
272
 
273
+ # Jobs that failed but are still eligible for retry (under max_retries).
274
+ #
275
+ # Found by the ATTEMPTS COUNTER, not by a "failed" status. fail() under
276
+ # max_retries re-queues the job as "pending" (that is what makes the next
277
+ # dequeue redeliver it), so no document ever carries status="failed" on
278
+ # the normal path. This method did not exist at all, and Queue#failed
279
+ # silently returned [] for it — indistinguishable from "nothing has
280
+ # failed" (ADR-0022 decision 7).
281
+ def failed(topic, max_retries: 3)
282
+ collection.find(
283
+ topic: topic,
284
+ status: { "$in" => %w[pending failed] },
285
+ attempts: { "$gt" => 0, "$lt" => max_retries }
286
+ ).map { |doc| job_from_doc(doc) }
287
+ end
288
+
215
289
  def dead_letters(topic, max_retries: 3)
216
- collection.find(topic: "#{topic}.dead_letter", status: "dead").map do |doc|
217
- Tina4::Job.new(
218
- topic: doc["topic"],
219
- payload: doc["payload"],
220
- id: doc["_id"]
221
- )
222
- end
290
+ collection.find(topic: "#{topic}.dead_letter", status: "dead")
291
+ .map { |doc| job_from_doc(doc) }
223
292
  end
224
293
 
225
294
  def purge(topic, status)
@@ -227,24 +296,91 @@ module Tina4
227
296
  result.deleted_count
228
297
  end
229
298
 
299
+ # Re-queue failed-but-retryable jobs back to pending. Returns the count.
300
+ #
301
+ # Matched on the ATTEMPTS COUNTER for the same reason failed() is: the
302
+ # old query was status="failed", which requeue_with_error never writes,
303
+ # so this matched nothing and always returned 0 — silently reporting that
304
+ # there was nothing to retry.
230
305
  def retry_failed(topic, max_retries: 3)
231
306
  result = collection.update_many(
232
- { topic: topic, status: "failed", attempts: { "$lt" => max_retries } },
307
+ { topic: topic,
308
+ # BOTH statuses. "pending" is where the auto-retry fail() path
309
+ # leaves a still-retryable job (it re-queues rather than marking
310
+ # it failed), and matching only "failed" is why this used to
311
+ # return 0 on every real failure. "failed" is still matched
312
+ # because reject(requeue: false) writes it, so a job explicitly
313
+ # parked as failed is retryable too.
314
+ status: { "$in" => %w[pending failed] },
315
+ attempts: { "$gt" => 0, "$lt" => max_retries } },
233
316
  # Reset available_at so re-queued failed jobs are visible again — they
234
317
  # were reserved with available_at in the future at dequeue. Clear
235
318
  # reserved_at too. (Same Bug B reason as requeue/fail.)
319
+ # status MUST be set back to "pending". The match now also picks up
320
+ # docs explicitly parked as "failed" (reject(requeue: false)), and
321
+ # those have to be flipped back or retry_failed reports a re-queue it
322
+ # never performed. A doc already pending is unaffected.
236
323
  { "$set" => { status: "pending", error: nil, reserved_at: nil,
237
324
  available_at: requeue_available_at(@retry_backoff) } }
238
325
  )
239
326
  result.modified_count
240
327
  end
241
328
 
329
+ # Move ONE dead-lettered job back to its main topic as pending.
330
+ # Returns true when a job was revived, false when the id was not found.
331
+ def retry_job(topic, job_id: nil, delay_seconds: 0)
332
+ filter = { topic: "#{topic}.dead_letter", status: "dead" }
333
+ filter[:_id] = job_id if job_id
334
+ doc = collection.find(filter).first
335
+ return false unless doc
336
+
337
+ available = delay_seconds.to_f > 0 ? (Time.now.utc + delay_seconds.to_f) : Time.now.utc
338
+ collection.update_one(
339
+ { _id: doc["_id"] },
340
+ { "$set" => { topic: topic, status: "pending", error: nil,
341
+ reserved_at: nil, available_at: available } }
342
+ )
343
+ true
344
+ end
345
+
346
+ # Close the MongoClient and release its connection pool.
347
+ #
348
+ # IDEMPOTENT by construction: the handles are dropped in an ensure, so a
349
+ # second close finds nothing and returns. Before 3.13.95 the ivars were
350
+ # left set, so a shutdown path that ran twice (an explicit close plus an
351
+ # at_exit / ensure) closed an already-closed client.
242
352
  def close
243
353
  @client&.close
354
+ ensure
355
+ @client = nil
356
+ @db = nil
244
357
  end
245
358
 
246
359
  private
247
360
 
361
+ # A stored document as a plain Hash with string keys, CARRYING attempts
362
+ # and error.
363
+ #
364
+ # Hash, not Job: the lite backend returns plain hashes from failed() and
365
+ # dead_letters(), so returning Job objects here would make the same
366
+ # caller need two different accessors depending on the backend — the very
367
+ # divergence this invariant exists to remove.
368
+ #
369
+ # The old dead_letters() mapped only topic/payload/id, so every Mongo dead
370
+ # letter read back as attempts=0 with no error text while the file backend
371
+ # reported the real values — a handler logging "died after N attempts:
372
+ # <reason>" printed "died after 0 attempts:" on Mongo only.
373
+ def job_from_doc(doc)
374
+ {
375
+ "id" => doc["_id"],
376
+ "topic" => doc["topic"],
377
+ "payload" => doc["payload"],
378
+ "attempts" => doc["attempts"] || 0,
379
+ "status" => doc["status"],
380
+ "error" => doc["error"]
381
+ }
382
+ end
383
+
248
384
  # Re-queue a failed job to pending, carrying the failure reason and
249
385
  # resetting available_at (now, or now + retry_backoff) + clearing
250
386
  # reserved_at so the next dequeue picks it up.