tina4ruby 3.0.0 → 3.9.2

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -32
  3. data/lib/tina4/auth.rb +137 -27
  4. data/lib/tina4/auto_crud.rb +55 -3
  5. data/lib/tina4/cli.rb +228 -28
  6. data/lib/tina4/cors.rb +1 -1
  7. data/lib/tina4/database.rb +230 -26
  8. data/lib/tina4/database_result.rb +122 -8
  9. data/lib/tina4/dev_mailbox.rb +1 -1
  10. data/lib/tina4/env.rb +1 -1
  11. data/lib/tina4/frond.rb +314 -7
  12. data/lib/tina4/gallery/queue/meta.json +1 -1
  13. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
  14. data/lib/tina4/localization.rb +1 -1
  15. data/lib/tina4/messenger.rb +111 -33
  16. data/lib/tina4/middleware.rb +349 -1
  17. data/lib/tina4/migration.rb +132 -11
  18. data/lib/tina4/orm.rb +149 -18
  19. data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
  20. data/lib/tina4/public/js/tina4js.min.js +47 -0
  21. data/lib/tina4/query_builder.rb +374 -0
  22. data/lib/tina4/queue.rb +219 -61
  23. data/lib/tina4/queue_backends/lite_backend.rb +42 -7
  24. data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
  25. data/lib/tina4/rack_app.rb +200 -11
  26. data/lib/tina4/request.rb +14 -1
  27. data/lib/tina4/response.rb +26 -0
  28. data/lib/tina4/response_cache.rb +446 -29
  29. data/lib/tina4/router.rb +127 -0
  30. data/lib/tina4/service_runner.rb +1 -1
  31. data/lib/tina4/session.rb +6 -1
  32. data/lib/tina4/session_handlers/database_handler.rb +66 -0
  33. data/lib/tina4/swagger.rb +1 -1
  34. data/lib/tina4/templates/errors/404.twig +2 -2
  35. data/lib/tina4/templates/errors/500.twig +1 -1
  36. data/lib/tina4/validator.rb +174 -0
  37. data/lib/tina4/version.rb +1 -1
  38. data/lib/tina4/websocket.rb +23 -4
  39. data/lib/tina4/websocket_backplane.rb +118 -0
  40. data/lib/tina4.rb +126 -5
  41. metadata +40 -3
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,34 +49,61 @@ 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::QueueBackends::LiteBackend.new
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
69
+
70
+ attr_reader :error
54
71
  end
55
72
 
56
- # Queue — convenience wrapper for queue management operations.
57
- # Provides dead letter inspection, purging, and retry capabilities.
73
+ # Queue — unified wrapper for queue management operations.
74
+ # Auto-detects backend from TINA4_QUEUE_BACKEND env var.
75
+ #
76
+ # Usage:
77
+ # # Auto-detect from env (default: lite/file backend)
78
+ # queue = Queue.new(topic: "tasks")
79
+ #
80
+ # # Explicit backend
81
+ # queue = Queue.new(topic: "tasks", backend: :rabbitmq)
82
+ #
83
+ # # Or pass a backend instance directly (legacy)
84
+ # queue = Queue.new(topic: "tasks", backend: my_backend)
58
85
  class Queue
59
86
  attr_reader :topic, :max_retries
60
87
 
61
88
  def initialize(topic:, backend: nil, max_retries: 3)
62
89
  @topic = topic
63
- @backend = backend || Tina4::QueueBackends::LiteBackend.new
64
90
  @max_retries = max_retries
91
+ @backend = resolve_backend_arg(backend)
92
+ end
93
+
94
+ # Push a job onto the queue. Returns the QueueMessage.
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)
100
+ @backend.enqueue(message)
101
+ message
102
+ end
103
+
104
+ # Pop the next available job. Returns QueueMessage or nil.
105
+ def pop
106
+ @backend.dequeue(@topic)
65
107
  end
66
108
 
67
109
  # Get dead letter jobs — messages that exceeded max retries.
@@ -83,71 +125,187 @@ module Tina4
83
125
  @backend.retry_failed(@topic, max_retries: @max_retries)
84
126
  end
85
127
 
86
- # Get the number of pending messages.
87
- def size
88
- @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
89
133
  end
90
- end
91
134
 
92
- class Consumer
93
- def initialize(topic:, backend: nil, max_retries: 3)
94
- @topic = topic
95
- @backend = backend || Tina4::QueueBackends::LiteBackend.new
96
- @max_retries = max_retries
97
- @handlers = []
98
- @running = false
99
- end
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
100
152
 
101
- def on_message(&block)
102
- @handlers << block
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
103
175
  end
104
176
 
105
- def start(poll_interval: 1)
106
- @running = true
107
- Tina4::Log.info("Consumer started for topic: #{@topic}")
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
108
182
 
109
- while @running
110
- message = @backend.dequeue(@topic)
111
- if message
112
- process_message(message)
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)
113
193
  else
114
- sleep(poll_interval)
194
+ 0
115
195
  end
196
+ else
197
+ @backend.size(@topic)
116
198
  end
117
199
  end
118
200
 
119
- def stop
120
- @running = false
121
- Tina4::Log.info("Consumer stopped for topic: #{@topic}")
201
+ # Get the underlying backend instance.
202
+ def backend
203
+ @backend
122
204
  end
123
205
 
124
- def process_one
125
- message = @backend.dequeue(@topic)
126
- process_message(message) if message
206
+ # Resolve the default backend from env vars.
207
+ def self.resolve_backend(name = nil)
208
+ chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "file").downcase.strip
209
+
210
+ case chosen.to_s
211
+ when "lite", "file", "default"
212
+ Tina4::QueueBackends::LiteBackend.new
213
+ when "rabbitmq"
214
+ config = resolve_rabbitmq_config
215
+ Tina4::QueueBackends::RabbitmqBackend.new(config)
216
+ when "kafka"
217
+ config = resolve_kafka_config
218
+ Tina4::QueueBackends::KafkaBackend.new(config)
219
+ when "mongodb", "mongo"
220
+ config = resolve_mongo_config
221
+ Tina4::QueueBackends::MongoBackend.new(config)
222
+ else
223
+ raise ArgumentError, "Unknown queue backend: #{chosen.inspect}. Use 'lite', 'rabbitmq', 'kafka', or 'mongodb'."
224
+ end
127
225
  end
128
226
 
129
227
  private
130
228
 
131
- def process_message(message)
132
- message.increment_attempts!
133
- message.status = :processing
229
+ def resolve_backend_arg(backend)
230
+ # If a backend instance is passed directly (legacy), use it
231
+ return backend if backend && !backend.is_a?(Symbol) && !backend.is_a?(String)
232
+ # If a symbol or string name is passed, resolve it
233
+ Queue.resolve_backend(backend)
234
+ end
235
+
236
+ def self.resolve_rabbitmq_config
237
+ config = {}
238
+ url = ENV["TINA4_QUEUE_URL"]
239
+ if url
240
+ config = parse_amqp_url(url)
241
+ end
242
+ config[:host] ||= ENV.fetch("TINA4_RABBITMQ_HOST", "localhost")
243
+ config[:port] ||= (ENV["TINA4_RABBITMQ_PORT"] || 5672).to_i
244
+ config[:username] ||= ENV.fetch("TINA4_RABBITMQ_USERNAME", "guest")
245
+ config[:password] ||= ENV.fetch("TINA4_RABBITMQ_PASSWORD", "guest")
246
+ config[:vhost] ||= ENV.fetch("TINA4_RABBITMQ_VHOST", "/")
247
+ config
248
+ end
134
249
 
135
- @handlers.each do |handler|
136
- handler.call(message)
250
+ def self.resolve_kafka_config
251
+ config = {}
252
+ url = ENV["TINA4_QUEUE_URL"]
253
+ if url
254
+ config[:brokers] = url.sub("kafka://", "")
137
255
  end
256
+ brokers = ENV["TINA4_KAFKA_BROKERS"]
257
+ config[:brokers] = brokers if brokers
258
+ config[:brokers] ||= "localhost:9092"
259
+ config[:group_id] = ENV.fetch("TINA4_KAFKA_GROUP_ID", "tina4_consumer_group")
260
+ config
261
+ end
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
+
278
+ def self.parse_amqp_url(url)
279
+ config = {}
280
+ url = url.sub("amqp://", "").sub("amqps://", "")
138
281
 
139
- message.status = :completed
140
- @backend.acknowledge(message)
141
- rescue => e
142
- Tina4::Log.error("Queue message failed: #{message.id} - #{e.message}")
143
- message.status = :failed
282
+ if url.include?("@")
283
+ creds, rest = url.split("@", 2)
284
+ if creds.include?(":")
285
+ config[:username], config[:password] = creds.split(":", 2)
286
+ else
287
+ config[:username] = creds
288
+ end
289
+ else
290
+ rest = url
291
+ end
144
292
 
145
- if message.attempts < @max_retries
146
- message.status = :pending
147
- @backend.requeue(message)
293
+ if rest.include?("/")
294
+ hostport, vhost = rest.split("/", 2)
295
+ config[:vhost] = vhost.start_with?("/") ? vhost : "/#{vhost}" if vhost && !vhost.empty?
148
296
  else
149
- @backend.dead_letter(message)
297
+ hostport = rest
150
298
  end
299
+
300
+ if hostport.include?(":")
301
+ host, port = hostport.split(":", 2)
302
+ config[:host] = host
303
+ config[:port] = port.to_i
304
+ elsif hostport && !hostport.empty?
305
+ config[:host] = hostport
306
+ end
307
+
308
+ config
151
309
  end
152
310
  end
153
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