tina4ruby 3.13.93 → 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 +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- 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/dev_mailbox.rb +5 -1
- 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 +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- 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 +317 -82
- 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 +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
|
@@ -16,11 +16,36 @@ module Tina4
|
|
|
16
16
|
@channel = @connection.create_channel
|
|
17
17
|
@queues = {}
|
|
18
18
|
@exchanges = {}
|
|
19
|
+
@max_retries = options[:max_retries] || 3
|
|
19
20
|
rescue LoadError
|
|
20
21
|
raise "RabbitMQ backend requires the 'bunny' gem. Install with: gem install bunny"
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# Queue propagates its own configuration onto the backend after construction.
|
|
25
|
+
attr_accessor :max_retries
|
|
26
|
+
|
|
23
27
|
def enqueue(message)
|
|
28
|
+
if message.priority.to_i > 0
|
|
29
|
+
raise NotImplementedError,
|
|
30
|
+
"The rabbitmq queue backend cannot honour push(priority): RabbitMQ " \
|
|
31
|
+
"orders a queue FIFO. Native priority needs the queue DECLARED " \
|
|
32
|
+
"with an x-max-priority argument, and an existing queue cannot " \
|
|
33
|
+
"be redeclared with one (the broker answers PRECONDITION_FAILED), " \
|
|
34
|
+
"so enabling it would break every queue already in service. Use " \
|
|
35
|
+
"the file or mongodb backend for prioritised jobs."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if message.available_at
|
|
39
|
+
raise NotImplementedError,
|
|
40
|
+
"The rabbitmq queue backend cannot honour push(delay_seconds): " \
|
|
41
|
+
"RabbitMQ has no per-message delay in core. The " \
|
|
42
|
+
"rabbitmq_delayed_message_exchange plugin is not part of a standard " \
|
|
43
|
+
"broker, and the TTL + dead-letter workaround head-of-line blocks (a " \
|
|
44
|
+
"long-delayed job holds up every shorter one behind it in the same " \
|
|
45
|
+
"queue). Use the file or mongodb backend for delayed jobs, or " \
|
|
46
|
+
"schedule the push itself."
|
|
47
|
+
end
|
|
48
|
+
|
|
24
49
|
queue = get_queue(message.topic)
|
|
25
50
|
queue.publish(message.to_json, persistent: true)
|
|
26
51
|
end
|
|
@@ -38,10 +63,16 @@ module Tina4
|
|
|
38
63
|
return nil unless payload
|
|
39
64
|
|
|
40
65
|
data = JSON.parse(payload)
|
|
66
|
+
# attempts and error MUST be carried back. Rebuilding the Job from
|
|
67
|
+
# topic/payload/id alone reset attempts to 0 on every redelivery, so
|
|
68
|
+
# fail()'s attempts >= max_retries check could never trip and a poison
|
|
69
|
+
# job would be retried forever instead of dead-lettering.
|
|
41
70
|
msg = Tina4::Job.new(
|
|
42
71
|
topic: data["topic"],
|
|
43
72
|
payload: data["payload"],
|
|
44
|
-
id: data["id"]
|
|
73
|
+
id: data["id"],
|
|
74
|
+
attempts: data["attempts"] || 0,
|
|
75
|
+
error: data["error"]
|
|
45
76
|
)
|
|
46
77
|
@last_delivery_tag = delivery_info.delivery_tag
|
|
47
78
|
msg
|
|
@@ -69,14 +100,149 @@ module Tina4
|
|
|
69
100
|
dlq.publish(message.to_json, persistent: true)
|
|
70
101
|
end
|
|
71
102
|
|
|
103
|
+
# Record a failed attempt, then retry it or dead-letter it.
|
|
104
|
+
#
|
|
105
|
+
# This did not exist. Job#fail guarded on respond_to?(:fail) and silently
|
|
106
|
+
# degraded to in-memory bookkeeping, so job.fail() NEVER reached the
|
|
107
|
+
# broker: the delivery stayed unacked, no dead letter was written, and
|
|
108
|
+
# both failed() and dead_letters() reported nothing. The job was lost as
|
|
109
|
+
# far as the application could see.
|
|
110
|
+
#
|
|
111
|
+
# AMQP basic.nack(requeue=true) returns the ORIGINAL body unmodified --
|
|
112
|
+
# the protocol carries no delivery counter -- so a retry RE-PUBLISHES a
|
|
113
|
+
# body carrying the new count instead. That is what every AMQP client
|
|
114
|
+
# that counts attempts does (Celery, Spring AMQP's
|
|
115
|
+
# RepublishMessageRecoverer, laravel-queue-rabbitmq).
|
|
116
|
+
def fail(job, error = "")
|
|
117
|
+
job.attempts += 1
|
|
118
|
+
job.error = error
|
|
119
|
+
if job.attempts >= @max_retries
|
|
120
|
+
dead_letter(job)
|
|
121
|
+
else
|
|
122
|
+
enqueue(job)
|
|
123
|
+
end
|
|
124
|
+
# Ack LAST: the re-publish (or dead-letter) is durable before the
|
|
125
|
+
# original leaves the queue, so a crash in between redelivers rather
|
|
126
|
+
# than loses. That is at-least-once, which is the contract.
|
|
127
|
+
complete(job)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Explicit re-queue requested by the caller (job.retry). Always
|
|
131
|
+
# re-enqueues regardless of the retry limit -- a manual override,
|
|
132
|
+
# distinct from the automatic fail() path.
|
|
133
|
+
def retry(job, delay_seconds: 0)
|
|
134
|
+
if delay_seconds.to_f > 0
|
|
135
|
+
raise NotImplementedError,
|
|
136
|
+
"The rabbitmq queue backend cannot honour retry(delay_seconds): " \
|
|
137
|
+
"RabbitMQ has no per-message delay in core, for the same reason " \
|
|
138
|
+
"push(delay_seconds) is refused. Re-queueing immediately while " \
|
|
139
|
+
"silently dropping the delay would run the job far sooner than " \
|
|
140
|
+
"asked. Use the file or mongodb backend for delayed retries."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
job.attempts += 1
|
|
144
|
+
job.error = nil
|
|
145
|
+
enqueue(job)
|
|
146
|
+
complete(job)
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def acknowledge(message)
|
|
151
|
+
complete(message)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Dead-lettered jobs, read back from the <topic>.dead_letter queue this
|
|
155
|
+
# backend writes itself. RabbitMQ's own dead-letter EXCHANGE is not
|
|
156
|
+
# queryable, but the queue Tina4 maintains is -- so this ANSWERS rather
|
|
157
|
+
# than refusing, and a dead-letter handler written against the file
|
|
158
|
+
# backend finds the same jobs here (invariant 3).
|
|
159
|
+
#
|
|
160
|
+
# Drain-and-republish: a read must not consume. Every message popped is
|
|
161
|
+
# published straight back, so the queue is unchanged by the read.
|
|
162
|
+
# Returns plain Hashes with string keys, matching the lite backend — the
|
|
163
|
+
# parsed body already carries id/topic/payload/attempts/error, so a caller
|
|
164
|
+
# reads it the same way on every backend.
|
|
165
|
+
def dead_letters(topic, max_retries: 3)
|
|
166
|
+
drain_dead_letters(topic).map do |data|
|
|
167
|
+
data["status"] = "dead"
|
|
168
|
+
data
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def failed(_topic, max_retries: 3)
|
|
173
|
+
raise NotImplementedError,
|
|
174
|
+
"The rabbitmq queue backend cannot answer failed(): a job that " \
|
|
175
|
+
"failed but is still retryable is re-published to the main topic, " \
|
|
176
|
+
"so it cannot be told apart from a normal pending message without " \
|
|
177
|
+
"draining the live queue. Returning an empty list would claim " \
|
|
178
|
+
"nothing has failed. Use dead_letters() for exhausted jobs, or the " \
|
|
179
|
+
"file or mongodb backend to enumerate retryable failures."
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def retry_failed(_topic, max_retries: 3)
|
|
183
|
+
raise NotImplementedError,
|
|
184
|
+
"The rabbitmq queue backend cannot perform retry_failed(): it must " \
|
|
185
|
+
"first enumerate the failed-but-retryable jobs, which are back on " \
|
|
186
|
+
"the main topic and indistinguishable from pending work. Returning " \
|
|
187
|
+
"0 would claim nothing needed retrying. Use retry(job_id) with an " \
|
|
188
|
+
"id you already hold, or the file or mongodb backend."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Move ONE dead-lettered job back to its main topic.
|
|
192
|
+
def retry_job(topic, job_id: nil, delay_seconds: 0)
|
|
193
|
+
found = nil
|
|
194
|
+
keep = []
|
|
195
|
+
drain_dead_letters(topic).each do |data|
|
|
196
|
+
if found.nil? && (job_id.nil? || data["id"].to_s == job_id.to_s)
|
|
197
|
+
found = data
|
|
198
|
+
else
|
|
199
|
+
keep << data
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
keep.each { |data| publish_to("#{topic}.dead_letter", data) }
|
|
203
|
+
return false unless found
|
|
204
|
+
|
|
205
|
+
found["attempts"] = (found["attempts"] || 0) + 1
|
|
206
|
+
found["status"] = "pending"
|
|
207
|
+
found["error"] = nil
|
|
208
|
+
found["topic"] = topic
|
|
209
|
+
publish_to(topic, found)
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Remove messages by status. "dead" empties the dead-letter queue;
|
|
214
|
+
# anything else empties the main queue. Returns the count removed.
|
|
215
|
+
def purge(topic, status)
|
|
216
|
+
name = dead_status?(status) ? "#{topic}.dead_letter" : topic
|
|
217
|
+
queue = get_queue(name)
|
|
218
|
+
count = queue.message_count
|
|
219
|
+
queue.purge
|
|
220
|
+
count
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def clear(topic)
|
|
224
|
+
purge(topic, "pending")
|
|
225
|
+
end
|
|
226
|
+
|
|
72
227
|
def size(topic)
|
|
73
228
|
queue = get_queue(topic)
|
|
74
229
|
queue.message_count
|
|
75
230
|
end
|
|
76
231
|
|
|
232
|
+
# Close the AMQP channel and connection and release the socket.
|
|
233
|
+
#
|
|
234
|
+
# IDEMPOTENT by construction: the handles are dropped in an ensure, so a
|
|
235
|
+
# second close finds nothing and returns. Before 3.13.95 they were left
|
|
236
|
+
# set, and Bunny raises on closing an already-closed channel - so a
|
|
237
|
+
# shutdown path that ran twice crashed on the second pass.
|
|
77
238
|
def close
|
|
78
239
|
@channel&.close
|
|
79
240
|
@connection&.close
|
|
241
|
+
ensure
|
|
242
|
+
@channel = nil
|
|
243
|
+
@connection = nil
|
|
244
|
+
@queues = {}
|
|
245
|
+
@exchanges = {}
|
|
80
246
|
end
|
|
81
247
|
|
|
82
248
|
private
|
|
@@ -84,6 +250,33 @@ module Tina4
|
|
|
84
250
|
def get_queue(topic)
|
|
85
251
|
@queues[topic] ||= @channel.queue(topic, durable: true)
|
|
86
252
|
end
|
|
253
|
+
|
|
254
|
+
def dead_status?(status)
|
|
255
|
+
%w[dead dead_letter deadletter failed].include?(status.to_s.downcase)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Pop every message off <topic>.dead_letter and put it straight back.
|
|
259
|
+
# A READ must not consume: dead_letters() is called by dashboards and
|
|
260
|
+
# health checks, and a read that emptied the queue would destroy the very
|
|
261
|
+
# backlog it reports on. Auto-ack removes each message, and each one is
|
|
262
|
+
# re-published before the method returns.
|
|
263
|
+
def drain_dead_letters(topic)
|
|
264
|
+
name = "#{topic}.dead_letter"
|
|
265
|
+
queue = get_queue(name)
|
|
266
|
+
out = []
|
|
267
|
+
loop do
|
|
268
|
+
_info, _props, payload = queue.pop(manual_ack: false)
|
|
269
|
+
break unless payload
|
|
270
|
+
|
|
271
|
+
out << JSON.parse(payload)
|
|
272
|
+
end
|
|
273
|
+
out.each { |data| publish_to(name, data) }
|
|
274
|
+
out
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def publish_to(name, data)
|
|
278
|
+
get_queue(name).publish(JSON.generate(data), persistent: true)
|
|
279
|
+
end
|
|
87
280
|
end
|
|
88
281
|
end
|
|
89
282
|
end
|