tina4ruby 3.13.94 → 3.13.96
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/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- 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 .
|
|
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
|
-
|
|
24
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ==
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
464
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
217
|
-
|
|
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,
|
|
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.
|