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.
Files changed (69) 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 +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  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/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. 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