tina4ruby 3.2.1 → 3.10.0

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.
data/lib/tina4/queue.rb CHANGED
@@ -4,27 +4,42 @@ require "securerandom"
4
4
 
5
5
  module Tina4
6
6
  class QueueMessage
7
- attr_reader :id, :topic, :payload, :created_at, :attempts
7
+ attr_reader :id, :topic, :payload, :created_at, :attempts, :priority, :available_at
8
8
  attr_accessor :status
9
9
 
10
- def initialize(topic:, payload:, id: nil)
10
+ def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil, attempts: 0)
11
11
  @id = id || SecureRandom.uuid
12
12
  @topic = topic
13
13
  @payload = payload
14
14
  @created_at = Time.now
15
- @attempts = 0
15
+ @attempts = attempts
16
+ @priority = priority
17
+ @available_at = available_at
16
18
  @status = :pending
17
19
  end
18
20
 
21
+ # Re-queue this message with incremented attempts.
22
+ # Delegates to the queue's backend via the queue reference.
23
+ def retry(queue:, delay_seconds: 0)
24
+ @attempts += 1
25
+ @status = :pending
26
+ @available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
27
+ queue.backend.enqueue(self)
28
+ self
29
+ end
30
+
19
31
  def to_hash
20
- {
32
+ h = {
21
33
  id: @id,
22
34
  topic: @topic,
23
35
  payload: @payload,
24
36
  created_at: @created_at.iso8601,
25
37
  attempts: @attempts,
26
- status: @status
38
+ status: @status,
39
+ priority: @priority
27
40
  }
41
+ h[:available_at] = @available_at.iso8601 if @available_at
42
+ h
28
43
  end
29
44
 
30
45
  def to_json(*_args)
@@ -34,28 +49,25 @@ module Tina4
34
49
  def increment_attempts!
35
50
  @attempts += 1
36
51
  end
37
- end
38
52
 
39
- class Producer
40
- def initialize(backend: nil)
41
- @backend = backend || Tina4::Queue.resolve_backend
53
+ # Mark this job as completed.
54
+ def complete
55
+ @status = :completed
42
56
  end
43
57
 
44
- def publish(topic, payload)
45
- message = QueueMessage.new(topic: topic, payload: payload)
46
- @backend.enqueue(message)
47
- Tina4::Log.debug("Message published to #{topic}: #{message.id}")
48
- message
58
+ # Mark this job as failed with a reason.
59
+ def fail(reason = "")
60
+ @status = :failed
61
+ @error = reason
62
+ @attempts += 1
49
63
  end
50
64
 
51
- def publish_batch(topic, payloads)
52
- payloads.map { |p| publish(topic, p) }
65
+ # Reject this job with a reason. Alias for fail().
66
+ def reject(reason = "")
67
+ fail(reason)
53
68
  end
54
69
 
55
- # Unified push method matching the cross-framework API.
56
- def push(topic, payload)
57
- publish(topic, payload)
58
- end
70
+ attr_reader :error
59
71
  end
60
72
 
61
73
  # Queue — unified wrapper for queue management operations.
@@ -80,8 +92,11 @@ module Tina4
80
92
  end
81
93
 
82
94
  # Push a job onto the queue. Returns the QueueMessage.
83
- def push(payload)
84
- message = QueueMessage.new(topic: @topic, payload: payload)
95
+ # priority: higher-priority messages are dequeued first (default 0).
96
+ # delay_seconds: delay before the message becomes available (default 0).
97
+ def push(payload, priority: 0, delay_seconds: 0)
98
+ available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
99
+ message = QueueMessage.new(topic: @topic, payload: payload, priority: priority, available_at: available_at)
85
100
  @backend.enqueue(message)
86
101
  message
87
102
  end
@@ -110,9 +125,77 @@ module Tina4
110
125
  @backend.retry_failed(@topic, max_retries: @max_retries)
111
126
  end
112
127
 
113
- # Get the number of pending messages.
114
- def size
115
- @backend.size(@topic)
128
+ # Produce a message onto a topic. Convenience wrapper around push().
129
+ def produce(topic, payload)
130
+ message = QueueMessage.new(topic: topic, payload: payload)
131
+ @backend.enqueue(message)
132
+ message
133
+ end
134
+
135
+ # Consume jobs from a topic using an Enumerator (yield pattern).
136
+ #
137
+ # Usage:
138
+ # queue.consume("emails") do |job|
139
+ # process(job)
140
+ # end
141
+ #
142
+ # # Consume a specific job by ID:
143
+ # queue.consume("emails", id: "abc-123") do |job|
144
+ # process(job)
145
+ # end
146
+ #
147
+ # # Or as an enumerator:
148
+ # queue.consume("emails").each { |job| process(job) }
149
+ #
150
+ def consume(topic = nil, id: nil, &block)
151
+ topic ||= @topic
152
+
153
+ if block_given?
154
+ if id
155
+ job = pop_by_id(topic, id)
156
+ yield job if job
157
+ else
158
+ while (job = @backend.dequeue(topic))
159
+ yield job
160
+ end
161
+ end
162
+ else
163
+ # Return an Enumerator when no block given
164
+ Enumerator.new do |yielder|
165
+ if id
166
+ job = pop_by_id(topic, id)
167
+ yielder << job if job
168
+ else
169
+ while (job = @backend.dequeue(topic))
170
+ yielder << job
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ # Pop a specific job by ID from the queue.
178
+ def pop_by_id(topic, id)
179
+ return nil unless @backend.respond_to?(:find_by_id)
180
+ @backend.find_by_id(topic, id)
181
+ end
182
+
183
+ # Get the number of messages by status.
184
+ # status: "pending" (default) counts pending messages in the topic queue.
185
+ # status: "failed" or "dead" counts messages in the dead_letter directory.
186
+ def size(status: "pending")
187
+ case status.to_s
188
+ when "pending"
189
+ @backend.size(@topic)
190
+ when "failed", "dead"
191
+ if @backend.respond_to?(:dead_letter_count)
192
+ @backend.dead_letter_count(@topic)
193
+ else
194
+ 0
195
+ end
196
+ else
197
+ @backend.size(@topic)
198
+ end
116
199
  end
117
200
 
118
201
  # Get the underlying backend instance.
@@ -121,9 +204,8 @@ module Tina4
121
204
  end
122
205
 
123
206
  # Resolve the default backend from env vars.
124
- # Class method so Producer can also use it.
125
207
  def self.resolve_backend(name = nil)
126
- chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "lite").downcase.strip
208
+ chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "file").downcase.strip
127
209
 
128
210
  case chosen.to_s
129
211
  when "lite", "file", "default"
@@ -134,8 +216,11 @@ module Tina4
134
216
  when "kafka"
135
217
  config = resolve_kafka_config
136
218
  Tina4::QueueBackends::KafkaBackend.new(config)
219
+ when "mongodb", "mongo"
220
+ config = resolve_mongo_config
221
+ Tina4::QueueBackends::MongoBackend.new(config)
137
222
  else
138
- raise ArgumentError, "Unknown queue backend: #{chosen.inspect}. Use 'lite', 'rabbitmq', or 'kafka'."
223
+ raise ArgumentError, "Unknown queue backend: #{chosen.inspect}. Use 'lite', 'rabbitmq', 'kafka', or 'mongodb'."
139
224
  end
140
225
  end
141
226
 
@@ -175,6 +260,21 @@ module Tina4
175
260
  config
176
261
  end
177
262
 
263
+ def self.resolve_mongo_config
264
+ config = {}
265
+ uri = ENV["TINA4_MONGO_URI"]
266
+ config[:uri] = uri if uri
267
+ config[:host] = ENV.fetch("TINA4_MONGO_HOST", "localhost") unless uri
268
+ config[:port] = (ENV["TINA4_MONGO_PORT"] || 27017).to_i unless uri
269
+ username = ENV["TINA4_MONGO_USERNAME"]
270
+ password = ENV["TINA4_MONGO_PASSWORD"]
271
+ config[:username] = username if username
272
+ config[:password] = password if password
273
+ config[:db] = ENV.fetch("TINA4_MONGO_DB", "tina4")
274
+ config[:collection] = ENV.fetch("TINA4_MONGO_COLLECTION", "tina4_queue")
275
+ config
276
+ end
277
+
178
278
  def self.parse_amqp_url(url)
179
279
  config = {}
180
280
  url = url.sub("amqp://", "").sub("amqps://", "")
@@ -208,66 +308,4 @@ module Tina4
208
308
  config
209
309
  end
210
310
  end
211
-
212
- class Consumer
213
- def initialize(topic:, backend: nil, max_retries: 3)
214
- @topic = topic
215
- @backend = backend || Tina4::Queue.resolve_backend
216
- @max_retries = max_retries
217
- @handlers = []
218
- @running = false
219
- end
220
-
221
- def on_message(&block)
222
- @handlers << block
223
- end
224
-
225
- def start(poll_interval: 1)
226
- @running = true
227
- Tina4::Log.info("Consumer started for topic: #{@topic}")
228
-
229
- while @running
230
- message = @backend.dequeue(@topic)
231
- if message
232
- process_message(message)
233
- else
234
- sleep(poll_interval)
235
- end
236
- end
237
- end
238
-
239
- def stop
240
- @running = false
241
- Tina4::Log.info("Consumer stopped for topic: #{@topic}")
242
- end
243
-
244
- def process_one
245
- message = @backend.dequeue(@topic)
246
- process_message(message) if message
247
- end
248
-
249
- private
250
-
251
- def process_message(message)
252
- message.increment_attempts!
253
- message.status = :processing
254
-
255
- @handlers.each do |handler|
256
- handler.call(message)
257
- end
258
-
259
- message.status = :completed
260
- @backend.acknowledge(message)
261
- rescue => e
262
- Tina4::Log.error("Queue message failed: #{message.id} - #{e.message}")
263
- message.status = :failed
264
-
265
- if message.attempts < @max_retries
266
- message.status = :pending
267
- @backend.requeue(message)
268
- else
269
- @backend.dead_letter(message)
270
- end
271
- end
272
- end
273
311
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
3
  require "fileutils"
4
+ require "time"
4
5
 
5
6
  module Tina4
6
7
  module QueueBackends
@@ -27,17 +28,37 @@ module Tina4
27
28
  dir = topic_path(topic)
28
29
  return nil unless Dir.exist?(dir)
29
30
 
30
- files = Dir.glob(File.join(dir, "*.json")).sort_by { |f| File.mtime(f) }
31
- return nil if files.empty?
31
+ now = Time.now
32
+ candidates = []
32
33
 
33
- file = files.first
34
- data = JSON.parse(File.read(file))
35
- File.delete(file)
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
44
+ 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]
36
54
 
37
55
  Tina4::QueueMessage.new(
38
- topic: data["topic"],
56
+ topic: data["topic"] || topic.to_s,
39
57
  payload: data["payload"],
40
- id: data["id"]
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
41
62
  )
42
63
  end
43
64
  end
@@ -61,6 +82,20 @@ module Tina4
61
82
  Dir.glob(File.join(dir, "*.json")).length
62
83
  end
63
84
 
85
+ # Count dead-letter / failed messages for a topic.
86
+ def dead_letter_count(topic)
87
+ return 0 unless Dir.exist?(@dead_letter_dir)
88
+
89
+ count = 0
90
+ Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
91
+ data = JSON.parse(File.read(file))
92
+ count += 1 if data["topic"] == topic.to_s
93
+ rescue JSON::ParserError
94
+ next
95
+ end
96
+ count
97
+ end
98
+
64
99
  def topics
65
100
  return [] unless Dir.exist?(@dir)
66
101
  Dir.children(@dir)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module QueueBackends
5
+ class MongoBackend
6
+ def initialize(options = {})
7
+ require "mongo"
8
+
9
+ uri = options[:uri] || ENV["TINA4_MONGO_URI"]
10
+ host = options[:host] || ENV.fetch("TINA4_MONGO_HOST", "localhost")
11
+ port = (options[:port] || ENV.fetch("TINA4_MONGO_PORT", 27017)).to_i
12
+ username = options[:username] || ENV["TINA4_MONGO_USERNAME"]
13
+ password = options[:password] || ENV["TINA4_MONGO_PASSWORD"]
14
+ db_name = options[:db] || ENV.fetch("TINA4_MONGO_DB", "tina4")
15
+ @collection_name = options[:collection] || ENV.fetch("TINA4_MONGO_COLLECTION", "tina4_queue")
16
+
17
+ if uri
18
+ @client = Mongo::Client.new(uri)
19
+ else
20
+ conn_options = { database: db_name }
21
+ conn_options[:user] = username if username
22
+ conn_options[:password] = password if password
23
+ @client = Mongo::Client.new(["#{host}:#{port}"], conn_options)
24
+ end
25
+
26
+ @db = @client.database
27
+ create_indexes
28
+ rescue LoadError
29
+ raise "MongoDB backend requires the 'mongo' gem. Install with: gem install mongo"
30
+ end
31
+
32
+ def enqueue(message)
33
+ collection.insert_one(
34
+ _id: message.id,
35
+ topic: message.topic,
36
+ payload: message.payload,
37
+ created_at: message.created_at.utc,
38
+ attempts: message.attempts,
39
+ status: "pending"
40
+ )
41
+ end
42
+
43
+ def dequeue(topic)
44
+ doc = collection.find_one_and_update(
45
+ { topic: topic, status: "pending" },
46
+ { "$set" => { status: "processing" } },
47
+ sort: { created_at: 1 },
48
+ return_document: :after
49
+ )
50
+ return nil unless doc
51
+
52
+ Tina4::QueueMessage.new(
53
+ topic: doc["topic"],
54
+ payload: doc["payload"],
55
+ id: doc["_id"]
56
+ )
57
+ end
58
+
59
+ def acknowledge(message)
60
+ collection.delete_one(_id: message.id)
61
+ end
62
+
63
+ def requeue(message)
64
+ collection.find_one_and_update(
65
+ { _id: message.id },
66
+ { "$set" => { status: "pending" }, "$inc" => { attempts: 1 } },
67
+ upsert: true
68
+ )
69
+ end
70
+
71
+ def dead_letter(message)
72
+ collection.find_one_and_update(
73
+ { _id: message.id },
74
+ { "$set" => { status: "dead", topic: "#{message.topic}.dead_letter" } },
75
+ upsert: true
76
+ )
77
+ end
78
+
79
+ def size(topic)
80
+ collection.count_documents(topic: topic, status: "pending")
81
+ end
82
+
83
+ def dead_letters(topic, max_retries: 3)
84
+ collection.find(topic: "#{topic}.dead_letter", status: "dead").map do |doc|
85
+ Tina4::QueueMessage.new(
86
+ topic: doc["topic"],
87
+ payload: doc["payload"],
88
+ id: doc["_id"]
89
+ )
90
+ end
91
+ end
92
+
93
+ def purge(topic, status)
94
+ result = collection.delete_many(topic: topic, status: status.to_s)
95
+ result.deleted_count
96
+ end
97
+
98
+ def retry_failed(topic, max_retries: 3)
99
+ result = collection.update_many(
100
+ { topic: topic, status: "failed", attempts: { "$lt" => max_retries } },
101
+ { "$set" => { status: "pending" } }
102
+ )
103
+ result.modified_count
104
+ end
105
+
106
+ def close
107
+ @client&.close
108
+ end
109
+
110
+ private
111
+
112
+ def collection
113
+ @db[@collection_name]
114
+ end
115
+
116
+ def create_indexes
117
+ collection.indexes.create_many([
118
+ { key: { topic: 1, status: 1, created_at: 1 } },
119
+ { key: { topic: 1, status: 1, attempts: 1 } }
120
+ ])
121
+ rescue Mongo::Error => e
122
+ Tina4::Log.warning("MongoDB index creation failed: #{e.message}")
123
+ end
124
+ end
125
+ end
126
+ end