tina4ruby 0.5.2 → 3.2.1
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 +1 -1
- data/README.md +434 -544
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +389 -97
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +144 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1497 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +562 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +463 -35
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +162 -6
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +331 -27
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +551 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +118 -21
- metadata +68 -8
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
data/lib/tina4/queue.rb
CHANGED
|
@@ -38,25 +38,181 @@ module Tina4
|
|
|
38
38
|
|
|
39
39
|
class Producer
|
|
40
40
|
def initialize(backend: nil)
|
|
41
|
-
@backend = backend || Tina4::
|
|
41
|
+
@backend = backend || Tina4::Queue.resolve_backend
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def publish(topic, payload)
|
|
45
45
|
message = QueueMessage.new(topic: topic, payload: payload)
|
|
46
46
|
@backend.enqueue(message)
|
|
47
|
-
Tina4::
|
|
47
|
+
Tina4::Log.debug("Message published to #{topic}: #{message.id}")
|
|
48
48
|
message
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def publish_batch(topic, payloads)
|
|
52
52
|
payloads.map { |p| publish(topic, p) }
|
|
53
53
|
end
|
|
54
|
+
|
|
55
|
+
# Unified push method matching the cross-framework API.
|
|
56
|
+
def push(topic, payload)
|
|
57
|
+
publish(topic, payload)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Queue — unified wrapper for queue management operations.
|
|
62
|
+
# Auto-detects backend from TINA4_QUEUE_BACKEND env var.
|
|
63
|
+
#
|
|
64
|
+
# Usage:
|
|
65
|
+
# # Auto-detect from env (default: lite/file backend)
|
|
66
|
+
# queue = Queue.new(topic: "tasks")
|
|
67
|
+
#
|
|
68
|
+
# # Explicit backend
|
|
69
|
+
# queue = Queue.new(topic: "tasks", backend: :rabbitmq)
|
|
70
|
+
#
|
|
71
|
+
# # Or pass a backend instance directly (legacy)
|
|
72
|
+
# queue = Queue.new(topic: "tasks", backend: my_backend)
|
|
73
|
+
class Queue
|
|
74
|
+
attr_reader :topic, :max_retries
|
|
75
|
+
|
|
76
|
+
def initialize(topic:, backend: nil, max_retries: 3)
|
|
77
|
+
@topic = topic
|
|
78
|
+
@max_retries = max_retries
|
|
79
|
+
@backend = resolve_backend_arg(backend)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Push a job onto the queue. Returns the QueueMessage.
|
|
83
|
+
def push(payload)
|
|
84
|
+
message = QueueMessage.new(topic: @topic, payload: payload)
|
|
85
|
+
@backend.enqueue(message)
|
|
86
|
+
message
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Pop the next available job. Returns QueueMessage or nil.
|
|
90
|
+
def pop
|
|
91
|
+
@backend.dequeue(@topic)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get dead letter jobs — messages that exceeded max retries.
|
|
95
|
+
def dead_letters
|
|
96
|
+
return [] unless @backend.respond_to?(:dead_letters)
|
|
97
|
+
@backend.dead_letters(@topic, max_retries: @max_retries)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Delete messages by status (completed, failed, dead).
|
|
101
|
+
def purge(status)
|
|
102
|
+
return 0 unless @backend.respond_to?(:purge)
|
|
103
|
+
@backend.purge(@topic, status)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Re-queue failed messages (under max_retries) back to pending.
|
|
107
|
+
# Returns the number of jobs re-queued.
|
|
108
|
+
def retry_failed
|
|
109
|
+
return 0 unless @backend.respond_to?(:retry_failed)
|
|
110
|
+
@backend.retry_failed(@topic, max_retries: @max_retries)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get the number of pending messages.
|
|
114
|
+
def size
|
|
115
|
+
@backend.size(@topic)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get the underlying backend instance.
|
|
119
|
+
def backend
|
|
120
|
+
@backend
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resolve the default backend from env vars.
|
|
124
|
+
# Class method so Producer can also use it.
|
|
125
|
+
def self.resolve_backend(name = nil)
|
|
126
|
+
chosen = name || ENV.fetch("TINA4_QUEUE_BACKEND", "lite").downcase.strip
|
|
127
|
+
|
|
128
|
+
case chosen.to_s
|
|
129
|
+
when "lite", "file", "default"
|
|
130
|
+
Tina4::QueueBackends::LiteBackend.new
|
|
131
|
+
when "rabbitmq"
|
|
132
|
+
config = resolve_rabbitmq_config
|
|
133
|
+
Tina4::QueueBackends::RabbitmqBackend.new(config)
|
|
134
|
+
when "kafka"
|
|
135
|
+
config = resolve_kafka_config
|
|
136
|
+
Tina4::QueueBackends::KafkaBackend.new(config)
|
|
137
|
+
else
|
|
138
|
+
raise ArgumentError, "Unknown queue backend: #{chosen.inspect}. Use 'lite', 'rabbitmq', or 'kafka'."
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def resolve_backend_arg(backend)
|
|
145
|
+
# If a backend instance is passed directly (legacy), use it
|
|
146
|
+
return backend if backend && !backend.is_a?(Symbol) && !backend.is_a?(String)
|
|
147
|
+
# If a symbol or string name is passed, resolve it
|
|
148
|
+
Queue.resolve_backend(backend)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def self.resolve_rabbitmq_config
|
|
152
|
+
config = {}
|
|
153
|
+
url = ENV["TINA4_QUEUE_URL"]
|
|
154
|
+
if url
|
|
155
|
+
config = parse_amqp_url(url)
|
|
156
|
+
end
|
|
157
|
+
config[:host] ||= ENV.fetch("TINA4_RABBITMQ_HOST", "localhost")
|
|
158
|
+
config[:port] ||= (ENV["TINA4_RABBITMQ_PORT"] || 5672).to_i
|
|
159
|
+
config[:username] ||= ENV.fetch("TINA4_RABBITMQ_USERNAME", "guest")
|
|
160
|
+
config[:password] ||= ENV.fetch("TINA4_RABBITMQ_PASSWORD", "guest")
|
|
161
|
+
config[:vhost] ||= ENV.fetch("TINA4_RABBITMQ_VHOST", "/")
|
|
162
|
+
config
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.resolve_kafka_config
|
|
166
|
+
config = {}
|
|
167
|
+
url = ENV["TINA4_QUEUE_URL"]
|
|
168
|
+
if url
|
|
169
|
+
config[:brokers] = url.sub("kafka://", "")
|
|
170
|
+
end
|
|
171
|
+
brokers = ENV["TINA4_KAFKA_BROKERS"]
|
|
172
|
+
config[:brokers] = brokers if brokers
|
|
173
|
+
config[:brokers] ||= "localhost:9092"
|
|
174
|
+
config[:group_id] = ENV.fetch("TINA4_KAFKA_GROUP_ID", "tina4_consumer_group")
|
|
175
|
+
config
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.parse_amqp_url(url)
|
|
179
|
+
config = {}
|
|
180
|
+
url = url.sub("amqp://", "").sub("amqps://", "")
|
|
181
|
+
|
|
182
|
+
if url.include?("@")
|
|
183
|
+
creds, rest = url.split("@", 2)
|
|
184
|
+
if creds.include?(":")
|
|
185
|
+
config[:username], config[:password] = creds.split(":", 2)
|
|
186
|
+
else
|
|
187
|
+
config[:username] = creds
|
|
188
|
+
end
|
|
189
|
+
else
|
|
190
|
+
rest = url
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if rest.include?("/")
|
|
194
|
+
hostport, vhost = rest.split("/", 2)
|
|
195
|
+
config[:vhost] = vhost.start_with?("/") ? vhost : "/#{vhost}" if vhost && !vhost.empty?
|
|
196
|
+
else
|
|
197
|
+
hostport = rest
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if hostport.include?(":")
|
|
201
|
+
host, port = hostport.split(":", 2)
|
|
202
|
+
config[:host] = host
|
|
203
|
+
config[:port] = port.to_i
|
|
204
|
+
elsif hostport && !hostport.empty?
|
|
205
|
+
config[:host] = hostport
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
config
|
|
209
|
+
end
|
|
54
210
|
end
|
|
55
211
|
|
|
56
212
|
class Consumer
|
|
57
213
|
def initialize(topic:, backend: nil, max_retries: 3)
|
|
58
214
|
@topic = topic
|
|
59
|
-
@backend = backend || Tina4::
|
|
215
|
+
@backend = backend || Tina4::Queue.resolve_backend
|
|
60
216
|
@max_retries = max_retries
|
|
61
217
|
@handlers = []
|
|
62
218
|
@running = false
|
|
@@ -68,7 +224,7 @@ module Tina4
|
|
|
68
224
|
|
|
69
225
|
def start(poll_interval: 1)
|
|
70
226
|
@running = true
|
|
71
|
-
Tina4::
|
|
227
|
+
Tina4::Log.info("Consumer started for topic: #{@topic}")
|
|
72
228
|
|
|
73
229
|
while @running
|
|
74
230
|
message = @backend.dequeue(@topic)
|
|
@@ -82,7 +238,7 @@ module Tina4
|
|
|
82
238
|
|
|
83
239
|
def stop
|
|
84
240
|
@running = false
|
|
85
|
-
Tina4::
|
|
241
|
+
Tina4::Log.info("Consumer stopped for topic: #{@topic}")
|
|
86
242
|
end
|
|
87
243
|
|
|
88
244
|
def process_one
|
|
@@ -103,7 +259,7 @@ module Tina4
|
|
|
103
259
|
message.status = :completed
|
|
104
260
|
@backend.acknowledge(message)
|
|
105
261
|
rescue => e
|
|
106
|
-
Tina4::
|
|
262
|
+
Tina4::Log.error("Queue message failed: #{message.id} - #{e.message}")
|
|
107
263
|
message.status = :failed
|
|
108
264
|
|
|
109
265
|
if message.attempts < @max_retries
|
|
@@ -68,6 +68,94 @@ module Tina4
|
|
|
68
68
|
.select { |d| File.directory?(File.join(@dir, d)) }
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# Get dead letter jobs for a topic — messages that exceeded max retries.
|
|
72
|
+
def dead_letters(topic, max_retries: 3)
|
|
73
|
+
return [] unless Dir.exist?(@dead_letter_dir)
|
|
74
|
+
|
|
75
|
+
files = Dir.glob(File.join(@dead_letter_dir, "*.json")).sort_by { |f| File.mtime(f) }
|
|
76
|
+
jobs = []
|
|
77
|
+
|
|
78
|
+
files.each do |file|
|
|
79
|
+
data = JSON.parse(File.read(file))
|
|
80
|
+
next unless data["topic"] == topic.to_s
|
|
81
|
+
data["status"] = "dead"
|
|
82
|
+
jobs << data
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
jobs
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Delete messages by status (completed, failed, dead).
|
|
91
|
+
# For 'dead', removes from the dead_letter directory.
|
|
92
|
+
# For 'failed', removes from the topic directory (re-queued failed messages).
|
|
93
|
+
# Returns the number of jobs purged.
|
|
94
|
+
def purge(topic, status)
|
|
95
|
+
count = 0
|
|
96
|
+
|
|
97
|
+
if status.to_s == "dead"
|
|
98
|
+
return 0 unless Dir.exist?(@dead_letter_dir)
|
|
99
|
+
|
|
100
|
+
Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
|
|
101
|
+
data = JSON.parse(File.read(file))
|
|
102
|
+
if data["topic"] == topic.to_s
|
|
103
|
+
File.delete(file)
|
|
104
|
+
count += 1
|
|
105
|
+
end
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
elsif status.to_s == "failed" || status.to_s == "completed" || status.to_s == "pending"
|
|
110
|
+
dir = topic_path(topic)
|
|
111
|
+
return 0 unless Dir.exist?(dir)
|
|
112
|
+
|
|
113
|
+
Dir.glob(File.join(dir, "*.json")).each do |file|
|
|
114
|
+
data = JSON.parse(File.read(file))
|
|
115
|
+
if data["status"] == status.to_s
|
|
116
|
+
File.delete(file)
|
|
117
|
+
count += 1
|
|
118
|
+
end
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
count
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Re-queue failed messages (under max_retries) back to pending.
|
|
128
|
+
# Returns the number of jobs re-queued.
|
|
129
|
+
def retry_failed(topic, max_retries: 3)
|
|
130
|
+
return 0 unless Dir.exist?(@dead_letter_dir)
|
|
131
|
+
|
|
132
|
+
dir = topic_path(topic)
|
|
133
|
+
FileUtils.mkdir_p(dir)
|
|
134
|
+
count = 0
|
|
135
|
+
|
|
136
|
+
# Dead letter directory contains messages that the Consumer moved there.
|
|
137
|
+
# Only retry those whose attempts are under max_retries.
|
|
138
|
+
Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
|
|
139
|
+
data = JSON.parse(File.read(file))
|
|
140
|
+
next unless data["topic"] == topic.to_s
|
|
141
|
+
next if (data["attempts"] || 0) >= max_retries
|
|
142
|
+
|
|
143
|
+
data["status"] = "pending"
|
|
144
|
+
msg = Tina4::QueueMessage.new(
|
|
145
|
+
topic: data["topic"],
|
|
146
|
+
payload: data["payload"],
|
|
147
|
+
id: data["id"]
|
|
148
|
+
)
|
|
149
|
+
enqueue(msg)
|
|
150
|
+
File.delete(file)
|
|
151
|
+
count += 1
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
count
|
|
157
|
+
end
|
|
158
|
+
|
|
71
159
|
private
|
|
72
160
|
|
|
73
161
|
def topic_path(topic)
|