tina4ruby 3.13.40 → 3.13.41

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