tina4ruby 3.13.94 → 3.13.97

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. 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,167 @@ 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
+ # Not performable on RabbitMQ - raises naming the backend and the operation.
214
+ #
215
+ # purge(status) removes jobs SELECTED BY STATUS. RabbitMQ cannot address
216
+ # messages by status: basic.get pops the head of the queue and the only
217
+ # bulk operation is queue.purge, which empties the WHOLE live queue
218
+ # regardless of status. This used to drain that queue on any non-dead
219
+ # status, destroying every pending job - the destructive no-op ADR-0022
220
+ # invariant 6 forbids. Refusing by name is the honest answer.
221
+ def purge(_topic, _status)
222
+ raise NotImplementedError,
223
+ "The rabbitmq queue backend cannot perform purge(): RabbitMQ " \
224
+ "cannot address messages by status (basic.get pops the head of " \
225
+ "the queue), so a status-addressed purge would have to drain the " \
226
+ "entire live queue and destroy pending work. Use the file or " \
227
+ "mongodb backend."
228
+ end
229
+
230
+ # Not performable on RabbitMQ - raises naming the backend and the operation.
231
+ #
232
+ # clear() empties the queue. RabbitMQ cannot address messages by status,
233
+ # so the only thing it could do is queue.purge the WHOLE live queue. This
234
+ # used to do exactly that and return 0, silently destroying every pending
235
+ # job. Draining a live broker on a status-addressed clear is data loss.
236
+ def clear(_topic)
237
+ raise NotImplementedError,
238
+ "The rabbitmq queue backend cannot perform clear(): RabbitMQ " \
239
+ "cannot address messages by status (basic.get pops the head of " \
240
+ "the queue), so a status-addressed clear would have to drain the " \
241
+ "entire live queue and destroy pending work. Use the file or " \
242
+ "mongodb backend."
243
+ end
244
+
72
245
  def size(topic)
73
246
  queue = get_queue(topic)
74
247
  queue.message_count
75
248
  end
76
249
 
250
+ # Close the AMQP channel and connection and release the socket.
251
+ #
252
+ # IDEMPOTENT by construction: the handles are dropped in an ensure, so a
253
+ # second close finds nothing and returns. Before 3.13.95 they were left
254
+ # set, and Bunny raises on closing an already-closed channel - so a
255
+ # shutdown path that ran twice crashed on the second pass.
77
256
  def close
78
257
  @channel&.close
79
258
  @connection&.close
259
+ ensure
260
+ @channel = nil
261
+ @connection = nil
262
+ @queues = {}
263
+ @exchanges = {}
80
264
  end
81
265
 
82
266
  private
@@ -84,6 +268,29 @@ module Tina4
84
268
  def get_queue(topic)
85
269
  @queues[topic] ||= @channel.queue(topic, durable: true)
86
270
  end
271
+
272
+ # Pop every message off <topic>.dead_letter and put it straight back.
273
+ # A READ must not consume: dead_letters() is called by dashboards and
274
+ # health checks, and a read that emptied the queue would destroy the very
275
+ # backlog it reports on. Auto-ack removes each message, and each one is
276
+ # re-published before the method returns.
277
+ def drain_dead_letters(topic)
278
+ name = "#{topic}.dead_letter"
279
+ queue = get_queue(name)
280
+ out = []
281
+ loop do
282
+ _info, _props, payload = queue.pop(manual_ack: false)
283
+ break unless payload
284
+
285
+ out << JSON.parse(payload)
286
+ end
287
+ out.each { |data| publish_to(name, data) }
288
+ out
289
+ end
290
+
291
+ def publish_to(name, data)
292
+ get_queue(name).publish(JSON.generate(data), persistent: true)
293
+ end
87
294
  end
88
295
  end
89
296
  end