tina4ruby 3.13.141 → 3.13.142

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0aa7d16e6192a9b815d104ed73e28d9dcfc0059017c8980cd2fa6593f23f3c95
4
- data.tar.gz: c9eb3c6c4141f58c7539fc65ad1d7596dca9602968b1094d6c6a3b236492aa6b
3
+ metadata.gz: 7e0e60af31fb00e8d35480634176d6bf978963f0d2e0609ebc9468204d8852d0
4
+ data.tar.gz: c34fbf17ba63d2295c13071cdc82cf1eca291bd7fafa4a1e8d6510083bd95ba1
5
5
  SHA512:
6
- metadata.gz: 11b4e161b6412ca687ff1022e1575336f5aeb6b17c54ee72d05db86d045cba8b5e4e5aa52c126fe7f761a234fcfec5215b8cd8a4ae5284c5738f84a4dea91b13
7
- data.tar.gz: b80d9c080a764c3ecd5912f6a7081e61f5196e1f751a86a52da62612f46223bea3148c203dbc549846ba916aadda20c831a1310472179dcd3f888da016624323
6
+ metadata.gz: 4a290825e4873802ac5311a665c67d19fcb618d77048299601f4bb6d9368a389cb093ce1039eaf7b071f30f1b7f0d7d3d2e297617947198ad9e81912909acb23
7
+ data.tar.gz: 287309debcce829752205d6bafedd17e2849b20726c27e151e69503e31a7656bfb420511946512e6f12b11953ec68e4ed01a1628dbf173d1ad9cdee605e1e49e
data/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.142 — 2026-09-30
10
+
11
+ A release led by real fixes for anyone on WebSockets, sessions, or the gallery queue, then a round of internal tidying and a new metrics gate that holds the line on complexity. The framework still carries no required runtime dependencies.
12
+
13
+ ### Fixes
14
+ - WebSocket now delivers a fragmented message whole. A client that splits a payload across continuation frames (RFC 6455 §5.4) used to have each fragment handed over on its own, so the handler saw pieces instead of the message. The frames are reassembled and delivered once, complete (#87).
15
+ - `session.regenerate` mid-request re-emits the session cookie, so a login that rotates the id keeps the browser in step instead of dropping it on the next request. Parity with the same fix in the php framework (tina4-php#253) (#90).
16
+ - The gallery queue reads a bounded batch instead of draining without limit, and the NATS backplane keeper stops busy-polling and blocks for work instead. Less wheel-spinning, the same behaviour (#89).
17
+ - A flaky serve-debug spec now polls `/__dev` for real readiness and retries a transient boot, so the suite stops tripping over its own startup timing (#91).
18
+
19
+ ### Refactors
20
+ - Cyclomatic complexity comes down across the flagged modules, and all six cross-file duplicate blocks in `lib/tina4` are gone, each one characterised first so the behaviour cannot shift (#86, #88).
21
+
22
+ ### Chore
23
+ - `TINA4_TEST_NATS_URL` joins the canonical test-env set (ADR-0038), so the NATS URL is named the same way everywhere.
24
+ - The nats-pure dependency and its transitive gems were licence-reviewed, unblocking the release-package workflow.
25
+ - CI wires the `tina4 metrics --fail-on-regression` ratchet gate with a committed baseline (ADR-0002), so a new complexity offender fails the build instead of drifting in unseen (#92).
26
+
9
27
  ## 3.13.141 — 2026-09-29
10
28
 
11
29
  A hardening release. The sibling php framework shipped committed absolute symlinks that broke `composer install` on Windows, so every framework now refuses a committed symlink up front. Ruby carries no such link today, so this guard is preventive parity: it keeps the door shut before anything can wander in. The framework still carries no required runtime dependencies.
data/lib/tina4/crud.rb CHANGED
@@ -407,14 +407,12 @@ module Tina4
407
407
  }
408
408
  end
409
409
 
410
- # Escape HTML special characters
410
+ # Escape HTML special characters. Delegates to the framework's one
411
+ # canonical escaper (Frond.escape_html, single-pass, byte-identical
412
+ # & < > " ' -> &amp; &lt; &gt; &quot; &#39;) so CRUD and the error
413
+ # overlay never carry their own private copy of the HTML escape table.
411
414
  def h(text)
412
- text.to_s
413
- .gsub("&", "&amp;")
414
- .gsub("<", "&lt;")
415
- .gsub(">", "&gt;")
416
- .gsub('"', "&quot;")
417
- .gsub("'", "&#39;")
415
+ Tina4::Frond.escape_html(text)
418
416
  end
419
417
 
420
418
  # Pretty label from a column name: "user_name" => "User Name"
@@ -169,13 +169,11 @@ module Tina4
169
169
  key.to_s.match?(SENSITIVE_KEY_RE) ? REDACTED : value
170
170
  end
171
171
 
172
+ # Delegates to the framework's one canonical escaper (Frond.escape_html,
173
+ # single-pass, byte-identical & < > " ' -> &amp; &lt; &gt; &quot; &#39;)
174
+ # rather than keeping a private copy of the escape table here.
172
175
  def esc(text)
173
- text.to_s
174
- .gsub("&", "&amp;")
175
- .gsub("<", "&lt;")
176
- .gsub(">", "&gt;")
177
- .gsub('"', "&quot;")
178
- .gsub("'", "&#39;")
176
+ Tina4::Frond.escape_html(text)
179
177
  end
180
178
 
181
179
  def parse_backtrace_line(line)
@@ -39,6 +39,13 @@ end
39
39
 
40
40
  GALLERY_QUEUE_MAX_RETRIES = 3
41
41
 
42
+ # The next message to consume or fail: the highest-priority pending one whose
43
+ # delay has passed. LIMIT 1 keeps the database from sorting and returning every
44
+ # pending row when the demo only ever wants the first (Carbonah E003).
45
+ GALLERY_QUEUE_NEXT_PENDING_SQL =
46
+ "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' " \
47
+ "AND available_at <= ? ORDER BY priority DESC, id ASC LIMIT 1"
48
+
42
49
  GALLERY_QUEUE_HTML = <<~'HTML'
43
50
  <!DOCTYPE html>
44
51
  <html lang="en">
@@ -273,7 +280,7 @@ Tina4::Router.post("/api/gallery/queue/consume") do |request, response|
273
280
  now = _gallery_queue_now
274
281
 
275
282
  row = db.fetch_one(
276
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
283
+ GALLERY_QUEUE_NEXT_PENDING_SQL,
277
284
  ["gallery-tasks", now]
278
285
  )
279
286
 
@@ -295,7 +302,7 @@ Tina4::Router.post("/api/gallery/queue/fail") do |request, response|
295
302
  now = _gallery_queue_now
296
303
 
297
304
  row = db.fetch_one(
298
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
305
+ GALLERY_QUEUE_NEXT_PENDING_SQL,
299
306
  ["gallery-tasks", now]
300
307
  )
301
308
 
data/lib/tina4/job.rb CHANGED
@@ -13,6 +13,23 @@ module Tina4
13
13
  attr_reader :id, :topic, :payload, :created_at, :priority, :available_at
14
14
  attr_accessor :status, :attempts, :error, :queue
15
15
 
16
+ # Rebuild a Job from a parsed queue payload (a Hash with string keys, as
17
+ # produced by #to_json and read back off the wire). attempts and error MUST
18
+ # ride along: rebuilding from topic/payload/id alone reset attempts to 0 on
19
+ # every redelivery, so fail()'s attempts >= max_retries check could never
20
+ # trip and a poison job would be retried forever instead of dead-lettering.
21
+ # Shared by the broker backends (kafka, rabbitmq) whose wire format is this
22
+ # JSON.
23
+ def self.from_payload(data)
24
+ new(
25
+ topic: data["topic"],
26
+ payload: data["payload"],
27
+ id: data["id"],
28
+ attempts: data["attempts"] || 0,
29
+ error: data["error"]
30
+ )
31
+ end
32
+
16
33
  def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil,
17
34
  attempts: 0, created_at: nil, error: nil, queue: nil)
18
35
  @id = id || SecureRandom.uuid
@@ -492,56 +492,23 @@ module Tina4
492
492
 
493
493
  # Block comment /* … */ — stripped.
494
494
  if ch == "/" && i + 1 < n && sql[i + 1] == "*"
495
- endpos = sql.index("*/", i + 2)
496
- i = endpos ? endpos + 2 : n
495
+ i = skip_block_comment(sql, i, n)
497
496
  next
498
497
  end
499
498
 
500
499
  # Line comment -- … — stripped to end of line; the newline is left for the
501
500
  # next iteration so line structure (and NEXT-line boundaries) survive.
502
501
  if ch == "-" && i + 1 < n && sql[i + 1] == "-"
503
- endpos = sql.index("\n", i + 2)
504
- i = endpos || n
502
+ i = skip_line_comment(sql, i, n)
505
503
  next
506
504
  end
507
505
 
508
- # Single-quoted string literal — '' escapes a quote. Copied verbatim.
509
- if ch == "'"
510
- current << "'"
511
- i += 1
512
- while i < n
513
- if sql[i] == "'" && i + 1 < n && sql[i + 1] == "'"
514
- current << "''"
515
- i += 2
516
- elsif sql[i] == "'"
517
- current << "'"
518
- i += 1
519
- break
520
- else
521
- current << sql[i]
522
- i += 1
523
- end
524
- end
525
- next
526
- end
527
-
528
- # Double-quoted identifier — "" escapes a quote. Same verbatim handling.
529
- if ch == '"'
530
- current << '"'
531
- i += 1
532
- while i < n
533
- if sql[i] == '"' && i + 1 < n && sql[i + 1] == '"'
534
- current << '""'
535
- i += 2
536
- elsif sql[i] == '"'
537
- current << '"'
538
- i += 1
539
- break
540
- else
541
- current << sql[i]
542
- i += 1
543
- end
544
- end
506
+ # '…' single-quoted string or "…" double-quoted identifier — copied
507
+ # verbatim, honouring the SQL doubled-quote escape ('' / ""). A ;, -- or
508
+ # /* inside a literal is data, not a delimiter or comment.
509
+ if ch == "'" || ch == '"'
510
+ literal, i = read_quoted_literal(sql, i, ch)
511
+ current << literal
545
512
  next
546
513
  end
547
514
 
@@ -592,6 +559,47 @@ module Tina4
592
559
  m && m[1]
593
560
  end
594
561
 
562
+ # Skip a /* … */ block comment that opens at +start_index+. Returns the index
563
+ # just past the closing "*/", or +length+ when the comment is unterminated.
564
+ def skip_block_comment(sql, start_index, length)
565
+ endpos = sql.index("*/", start_index + 2)
566
+ endpos ? endpos + 2 : length
567
+ end
568
+
569
+ # Skip a -- … line comment that opens at +start_index+. Returns the index of
570
+ # the terminating newline (left for the next iteration so line structure
571
+ # survives), or +length+ when the comment runs to the end of the script.
572
+ def skip_line_comment(sql, start_index, length)
573
+ sql.index("\n", start_index + 2) || length
574
+ end
575
+
576
+ # Read a '…' string literal or "…" quoted identifier that opens at the
577
+ # +quote+ at +start_index+. A doubled quote ('' or "") is an escaped quote,
578
+ # not a terminator, so it is copied and scanning continues. An unterminated
579
+ # literal is read to the end of the script (matching the raw scan it replaces).
580
+ #
581
+ # @return [Array(String, Integer)] the verbatim literal (both quotes included)
582
+ # and the index just past the closing quote.
583
+ def read_quoted_literal(sql, start_index, quote)
584
+ n = sql.length
585
+ literal = quote.dup
586
+ i = start_index + 1
587
+ while i < n
588
+ if sql[i] == quote && i + 1 < n && sql[i + 1] == quote
589
+ literal << quote << quote
590
+ i += 2
591
+ elsif sql[i] == quote
592
+ literal << quote
593
+ i += 1
594
+ break
595
+ else
596
+ literal << sql[i]
597
+ i += 1
598
+ end
599
+ end
600
+ [literal, i]
601
+ end
602
+
595
603
  def execute_sql_file(file)
596
604
  sql = File.read(file)
597
605
  statements = split_sql_statements(sql)
@@ -7,10 +7,15 @@
7
7
 
8
8
 
9
9
  require_relative "../parse_json"
10
+ require_relative "republish_retry_policy"
10
11
 
11
12
  module Tina4
12
13
  module QueueBackends
13
14
  class KafkaBackend
15
+ # Shared at-least-once fail() (ADR-0022): re-publish carrying the new
16
+ # attempt count, dead-letter at the limit, ack last. See RabbitmqBackend.
17
+ include RepublishRetryPolicy
18
+
14
19
  def initialize(options = {})
15
20
  require "rdkafka"
16
21
  @brokers = options[:brokers] || "localhost:9092"
@@ -131,17 +136,10 @@ module Tina4
131
136
  data = Tina4.parse_json(msg.payload)
132
137
  @last_message = msg
133
138
 
134
- # attempts and error MUST be carried back. Rebuilding from
135
- # topic/payload/id alone reset attempts to 0 on every redelivery, so
136
- # fail()'s attempts >= max_retries check could never trip and a poison
137
- # record would be re-produced forever instead of dead-lettering.
138
- Tina4::Job.new(
139
- topic: data["topic"],
140
- payload: data["payload"],
141
- id: data["id"],
142
- attempts: data["attempts"] || 0,
143
- error: data["error"]
144
- )
139
+ # attempts and error MUST be carried back (Job.from_payload does this);
140
+ # rebuilding from topic/payload/id alone reset attempts to 0 on every
141
+ # redelivery, so a poison record would be re-produced forever.
142
+ Tina4::Job.from_payload(data)
145
143
  rescue Rdkafka::RdkafkaError
146
144
  nil
147
145
  end
@@ -186,26 +184,9 @@ module Tina4
186
184
  acknowledge(message)
187
185
  end
188
186
 
189
- # Record a failed attempt, then retry it or dead-letter it.
190
- #
191
- # This did not exist. Job#fail guarded on respond_to?(:fail) and silently
192
- # degraded to in-memory bookkeeping, so job.fail() NEVER reached Kafka:
193
- # the offset was never committed and no dead letter was produced.
194
- #
195
- # A retry RE-PRODUCES a record carrying the new count, because a Kafka
196
- # record is immutable and carries no delivery counter. The offset is
197
- # committed LAST so a crash in between redelivers rather than loses -
198
- # at-least-once, which is the contract.
199
- def fail(job, error = "")
200
- job.attempts += 1
201
- job.error = error
202
- if job.attempts >= @max_retries
203
- dead_letter(job)
204
- else
205
- enqueue(job)
206
- end
207
- acknowledge(job)
208
- end
187
+ # fail() comes from RepublishRetryPolicy (re-produce carrying the new
188
+ # count, dead-letter at the limit, commit the offset LAST). Kafka's
189
+ # +complete+ is +acknowledge+, so the terminal ack commits the offset.
209
190
 
210
191
  def retry(job, delay_seconds: 0)
211
192
  if delay_seconds.to_f > 0
@@ -7,10 +7,15 @@
7
7
 
8
8
 
9
9
  require_relative "../parse_json"
10
+ require_relative "republish_retry_policy"
10
11
 
11
12
  module Tina4
12
13
  module QueueBackends
13
14
  class RabbitmqBackend
15
+ # Shared at-least-once fail() (ADR-0022): re-publish carrying the new
16
+ # attempt count, dead-letter at the limit, ack last. See KafkaBackend.
17
+ include RepublishRetryPolicy
18
+
14
19
  def initialize(options = {})
15
20
  require "bunny"
16
21
  @connection = Bunny.new(
@@ -71,17 +76,10 @@ module Tina4
71
76
  return nil unless payload
72
77
 
73
78
  data = Tina4.parse_json(payload)
74
- # attempts and error MUST be carried back. Rebuilding the Job from
75
- # topic/payload/id alone reset attempts to 0 on every redelivery, so
76
- # fail()'s attempts >= max_retries check could never trip and a poison
77
- # job would be retried forever instead of dead-lettering.
78
- msg = Tina4::Job.new(
79
- topic: data["topic"],
80
- payload: data["payload"],
81
- id: data["id"],
82
- attempts: data["attempts"] || 0,
83
- error: data["error"]
84
- )
79
+ # attempts and error MUST be carried back (Job.from_payload does this);
80
+ # rebuilding from topic/payload/id alone reset attempts to 0 on every
81
+ # redelivery, so a poison job would be retried forever.
82
+ msg = Tina4::Job.from_payload(data)
85
83
  @last_delivery_tag = delivery_info.delivery_tag
86
84
  msg
87
85
  end
@@ -108,32 +106,12 @@ module Tina4
108
106
  dlq.publish(message.to_json, persistent: true)
109
107
  end
110
108
 
111
- # Record a failed attempt, then retry it or dead-letter it.
112
- #
113
- # This did not exist. Job#fail guarded on respond_to?(:fail) and silently
114
- # degraded to in-memory bookkeeping, so job.fail() NEVER reached the
115
- # broker: the delivery stayed unacked, no dead letter was written, and
116
- # both failed() and dead_letters() reported nothing. The job was lost as
117
- # far as the application could see.
118
- #
119
- # AMQP basic.nack(requeue=true) returns the ORIGINAL body unmodified --
120
- # the protocol carries no delivery counter -- so a retry RE-PUBLISHES a
121
- # body carrying the new count instead. That is what every AMQP client
122
- # that counts attempts does (Celery, Spring AMQP's
123
- # RepublishMessageRecoverer, laravel-queue-rabbitmq).
124
- def fail(job, error = "")
125
- job.attempts += 1
126
- job.error = error
127
- if job.attempts >= @max_retries
128
- dead_letter(job)
129
- else
130
- enqueue(job)
131
- end
132
- # Ack LAST: the re-publish (or dead-letter) is durable before the
133
- # original leaves the queue, so a crash in between redelivers rather
134
- # than loses. That is at-least-once, which is the contract.
135
- complete(job)
136
- end
109
+ # fail() comes from RepublishRetryPolicy. AMQP basic.nack(requeue=true)
110
+ # returns the ORIGINAL body unmodified -- the protocol carries no delivery
111
+ # counter -- so the shared policy RE-PUBLISHES a body carrying the new
112
+ # count, then complete() acks LAST. That is what every AMQP client that
113
+ # counts attempts does (Celery, Spring AMQP's RepublishMessageRecoverer,
114
+ # laravel-queue-rabbitmq).
137
115
 
138
116
  # Explicit re-queue requested by the caller (job.retry). Always
139
117
  # re-enqueues regardless of the retry limit -- a manual override,
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ module Tina4
9
+ module QueueBackends
10
+ # The at-least-once fail policy (ADR-0022) shared by the broker backends
11
+ # whose protocol carries no delivery counter (kafka, rabbitmq). A Kafka
12
+ # record and an AMQP body are both immutable, so recording a failed attempt
13
+ # means RE-PUBLISHING a body that carries the incremented count rather than
14
+ # mutating the message in place.
15
+ #
16
+ # Increment attempts, record the error, dead-letter at the retry limit else
17
+ # re-enqueue, then ack LAST -- so a crash between the re-publish and the ack
18
+ # redelivers the job rather than losing it, which is the contract.
19
+ #
20
+ # The host backend must define +enqueue+, +dead_letter+, +complete+ (its
21
+ # terminal ack) and an +@max_retries+.
22
+ module RepublishRetryPolicy
23
+ def fail(job, error = "")
24
+ job.attempts += 1
25
+ job.error = error
26
+ if job.attempts >= @max_retries
27
+ dead_letter(job)
28
+ else
29
+ enqueue(job)
30
+ end
31
+ complete(job)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -7,6 +7,7 @@
7
7
 
8
8
  require "json"
9
9
  require_relative "resp_client"
10
+ require_relative "resp_session_handler"
10
11
 
11
12
  module Tina4
12
13
  module SessionHandlers
@@ -16,7 +17,13 @@ module Tina4
16
17
  # backend did the latter and was retired 2026-07-31 as drift); otherwise
17
18
  # speaks raw RESP over a TCP socket via RespClient — zero dependencies, so a
18
19
  # Tina4 app stores sessions in Redis with no extra gem.
20
+ #
21
+ # read/write/destroy/cleanup and the gem-client build come from
22
+ # RespSessionHandler (shared byte-for-byte with ValkeyHandler); only the
23
+ # env-var namespace below differs.
19
24
  class RedisHandler
25
+ include RespSessionHandler
26
+
20
27
  # Connection is configured from TINA4_SESSION_REDIS_* env vars (parity with
21
28
  # Python's RedisSessionHandler and the Ruby ValkeyHandler shape) so
22
29
  # TINA4_SESSION_BACKEND=redis can actually be pointed at a server by env.
@@ -33,57 +40,6 @@ module Tina4
33
40
  @redis = build_gem_client
34
41
  @resp = @redis ? nil : RespClient.new(host: @host, port: @port, password: @password, db: @db)
35
42
  end
36
-
37
- def read(session_id)
38
- key = "#{@prefix}#{session_id}"
39
- data = @redis ? @redis.get(key) : @resp.get(key)
40
- return nil unless data
41
- JSON.parse(data)
42
- rescue JSON::ParserError
43
- nil
44
- end
45
-
46
- # Write session data. A per-call +ttl+ WINS over the handler default, so
47
- # asking for a 60s session really gets 60s; 0 uses the default. Every
48
- # handler in every Tina4 framework takes this third argument -- Session#write
49
- # passes it, and a handler that did not accept it raised ArgumentError,
50
- # which safe_write swallowed into a silent STALE write.
51
- #
52
- # @param session_id [String] the session id
53
- # @param data [Hash] the payload to store
54
- # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
55
- def write(session_id, data, ttl = 0)
56
- key = "#{@prefix}#{session_id}"
57
- payload = JSON.generate(data)
58
- effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
59
- if @redis
60
- @redis.setex(key, effective_ttl, payload)
61
- else
62
- @resp.setex(key, effective_ttl, payload)
63
- end
64
- end
65
-
66
- def destroy(session_id)
67
- key = "#{@prefix}#{session_id}"
68
- @redis ? @redis.del(key) : @resp.del(key)
69
- end
70
-
71
- def cleanup
72
- # Redis handles TTL automatically
73
- end
74
-
75
- private
76
-
77
- # Use the official `redis` gem only when the REAL gem is loadable (guard
78
- # against an in-test fake `Redis` constant that has no VERSION). Returns nil
79
- # when the gem is absent so the caller falls back to the raw RESP client.
80
- def build_gem_client
81
- require "redis"
82
- return nil unless defined?(::Redis::VERSION)
83
- Redis.new(host: @host, port: @port, db: @db, password: @password)
84
- rescue LoadError
85
- nil
86
- end
87
43
  end
88
44
  end
89
45
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ require "json"
9
+
10
+ module Tina4
11
+ module SessionHandlers
12
+ # Shared RESP-protocol session storage. Redis and Valkey speak the same RESP
13
+ # wire protocol, so reading, writing, destroying and cleaning up a session --
14
+ # and building the optional `redis`-gem client -- are byte-identical between
15
+ # them. Only the env-var namespace differs (TINA4_SESSION_REDIS_* vs
16
+ # TINA4_SESSION_VALKEY_*), which each handler sets in its own initialize.
17
+ #
18
+ # A handler includes this and, in initialize, sets @prefix, @ttl, @host,
19
+ # @port, @db, @password, @redis (the gem client or nil) and @resp (the raw
20
+ # RespClient fallback, used when @redis is nil).
21
+ module RespSessionHandler
22
+ def read(session_id)
23
+ key = "#{@prefix}#{session_id}"
24
+ data = @redis ? @redis.get(key) : @resp.get(key)
25
+ return nil unless data
26
+ JSON.parse(data)
27
+ rescue JSON::ParserError
28
+ nil
29
+ end
30
+
31
+ # Write session data. A per-call +ttl+ WINS over the handler default, so
32
+ # asking for a 60s session really gets 60s; 0 uses the default. Every
33
+ # handler in every Tina4 framework takes this third argument -- Session#write
34
+ # passes it, and a handler that did not accept it raised ArgumentError,
35
+ # which safe_write swallowed into a silent STALE write.
36
+ #
37
+ # @param session_id [String] the session id
38
+ # @param data [Hash] the payload to store
39
+ # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
40
+ def write(session_id, data, ttl = 0)
41
+ key = "#{@prefix}#{session_id}"
42
+ payload = JSON.generate(data)
43
+ effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
44
+ if @redis
45
+ @redis.setex(key, effective_ttl, payload)
46
+ else
47
+ @resp.setex(key, effective_ttl, payload)
48
+ end
49
+ end
50
+
51
+ def destroy(session_id)
52
+ key = "#{@prefix}#{session_id}"
53
+ @redis ? @redis.del(key) : @resp.del(key)
54
+ end
55
+
56
+ def cleanup
57
+ # Redis/Valkey expire keys by TTL automatically -- nothing to sweep.
58
+ end
59
+
60
+ private
61
+
62
+ # Use the official `redis` gem (RESP-compatible with both Redis and
63
+ # Valkey) only when the REAL gem is loadable (guard against an in-test fake
64
+ # `Redis` constant that has no VERSION). Returns nil when the gem is absent
65
+ # so the caller falls back to the raw RESP client.
66
+ def build_gem_client
67
+ require "redis"
68
+ return nil unless defined?(::Redis::VERSION)
69
+ Redis.new(host: @host, port: @port, db: @db, password: @password)
70
+ rescue LoadError
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -7,14 +7,19 @@
7
7
 
8
8
  require "json"
9
9
  require_relative "resp_client"
10
+ require_relative "resp_session_handler"
10
11
 
11
12
  module Tina4
12
13
  module SessionHandlers
13
14
  # Valkey-backed session handler. Valkey speaks the RESP protocol, so it works
14
15
  # through the `redis` gem when installed (parity with Python/Node); otherwise
15
16
  # it speaks raw RESP over a TCP socket via RespClient — zero dependencies.
16
- # Same as RedisHandler but reads VALKEY-prefixed configuration variables.
17
+ # Same behaviour as RedisHandler (read/write/destroy/cleanup and the
18
+ # gem-client build come from the shared RespSessionHandler) but reads
19
+ # VALKEY-prefixed configuration variables.
17
20
  class ValkeyHandler
21
+ include RespSessionHandler
22
+
18
23
  def initialize(options = {})
19
24
  @prefix = options[:prefix] || ENV["TINA4_SESSION_VALKEY_PREFIX"] || "tina4:session:"
20
25
  # TINA4_SESSION_VALKEY_TTL stays as the valkey-specific override, but it
@@ -29,56 +34,6 @@ module Tina4
29
34
  @redis = build_gem_client
30
35
  @resp = @redis ? nil : RespClient.new(host: @host, port: @port, password: @password, db: @db)
31
36
  end
32
-
33
- def read(session_id)
34
- key = "#{@prefix}#{session_id}"
35
- data = @redis ? @redis.get(key) : @resp.get(key)
36
- return nil unless data
37
- JSON.parse(data)
38
- rescue JSON::ParserError
39
- nil
40
- end
41
-
42
- # Write session data. A per-call +ttl+ WINS over the handler default, so
43
- # asking for a 60s session really gets 60s; 0 uses the default. Every
44
- # handler in every Tina4 framework takes this third argument -- Session#write
45
- # passes it, and a handler that did not accept it raised ArgumentError,
46
- # which safe_write swallowed into a silent STALE write.
47
- #
48
- # @param session_id [String] the session id
49
- # @param data [Hash] the payload to store
50
- # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
51
- def write(session_id, data, ttl = 0)
52
- key = "#{@prefix}#{session_id}"
53
- payload = JSON.generate(data)
54
- effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
55
- if @redis
56
- @redis.setex(key, effective_ttl, payload)
57
- else
58
- @resp.setex(key, effective_ttl, payload)
59
- end
60
- end
61
-
62
- def destroy(session_id)
63
- key = "#{@prefix}#{session_id}"
64
- @redis ? @redis.del(key) : @resp.del(key)
65
- end
66
-
67
- def cleanup
68
- # Valkey handles TTL automatically (same as Redis)
69
- end
70
-
71
- private
72
-
73
- # Use the official `redis` gem (RESP-compatible with Valkey) only when the
74
- # REAL gem is loadable; otherwise nil so the caller falls back to raw RESP.
75
- def build_gem_client
76
- require "redis"
77
- return nil unless defined?(::Redis::VERSION)
78
- Redis.new(host: @host, port: @port, db: @db, password: @password)
79
- rescue LoadError
80
- nil
81
- end
82
37
  end
83
38
  end
84
39
  end
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.141"
10
+ VERSION = "3.13.142"
11
11
  end
@@ -691,6 +691,11 @@ module Tina4
691
691
 
692
692
  Thread.new do
693
693
  begin
694
+ # A large message (a pasted image in a chat, base64 inside JSON) arrives as a first frame with FIN clear,
695
+ # then CONTINUATION frames (RFC 6455 section 5.4); control frames may come in between. The first piece used
696
+ # to be emitted as the whole message and the rest ignored. Same design as the Python master (_handle_frame).
697
+ fragments = nil
698
+ fragment_opcode = nil
694
699
  loop do
695
700
  frame = connection.read_frame
696
701
  break unless frame
@@ -698,8 +703,26 @@ module Tina4
698
703
  connection.touch # mark activity for the idle reaper
699
704
 
700
705
  case frame[:opcode]
706
+ when 0x0 # Continuation - a stray one (no message started) is dropped
707
+ next unless fragments
708
+ fragments << frame[:data]
709
+ if frame[:fin]
710
+ emit(:message, connection, fragments) if fragment_opcode == 0x1
711
+ fragments = nil
712
+ fragment_opcode = nil
713
+ end
701
714
  when 0x1 # Text
702
- emit(:message, connection, frame[:data])
715
+ if frame[:fin]
716
+ emit(:message, connection, frame[:data])
717
+ else
718
+ fragments = frame[:data].dup
719
+ fragment_opcode = 0x1
720
+ end
721
+ when 0x2 # Binary - not delivered (as before), but its pieces are one message, never text
722
+ unless frame[:fin]
723
+ fragments = frame[:data].dup
724
+ fragment_opcode = 0x2
725
+ end
703
726
  when 0x8 # Close
704
727
  break
705
728
  when 0x9 # Ping
@@ -867,6 +890,7 @@ module Tina4
867
890
  first_byte = @socket.getbyte
868
891
  return nil unless first_byte
869
892
 
893
+ fin = (first_byte & 0x80) != 0
870
894
  opcode = first_byte & 0x0F
871
895
  second_byte = @socket.getbyte
872
896
  return nil unless second_byte
@@ -887,7 +911,7 @@ module Tina4
887
911
  data = data.bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }.pack("C*")
888
912
  end
889
913
 
890
- { opcode: opcode, data: data }
914
+ { fin: fin, opcode: opcode, data: data }
891
915
  rescue IOError, EOFError
892
916
  nil
893
917
  end
@@ -175,25 +175,28 @@ module Tina4
175
175
 
176
176
  def subscribe(channel, &block)
177
177
  @mutex.synchronize do
178
- sid = @nats.subscribe(channel) do |msg|
178
+ # NATS::Client#subscribe returns a NATS::Subscription. The client's own
179
+ # #unsubscribe is private in nats-pure 2.x, so we tear a subscription
180
+ # down through the subscription object, not the client (see #unsubscribe).
181
+ subscription = @nats.subscribe(channel) do |msg|
179
182
  block.call(msg.data) if @running
180
183
  end
181
- @subs[channel] = sid
182
-
183
- # Run NATS event processing in a background thread
184
- @threads[channel] ||= Thread.new do
185
- loop do
186
- break unless @running
187
- sleep 0.01
188
- end
189
- end
184
+ @subs[channel] = subscription
185
+
186
+ # The NATS client dispatches subscription callbacks on its own reader
187
+ # thread, so this keeper thread only has to stay alive until unsubscribe
188
+ # or close kills it. It blocks on an empty queue - nothing is ever
189
+ # pushed - so it waits at zero CPU. Before, it woke a hundred times a
190
+ # second on `sleep 0.01` just to re-check @running, burning energy to do
191
+ # nothing (Carbonah E004).
192
+ @threads[channel] ||= Thread.new { Thread::Queue.new.pop }
190
193
  end
191
194
  end
192
195
 
193
196
  def unsubscribe(channel)
194
197
  @mutex.synchronize do
195
- sid = @subs.delete(channel)
196
- @nats.unsubscribe(sid) if sid
198
+ subscription = @subs.delete(channel)
199
+ subscription&.unsubscribe
197
200
  thread = @threads.delete(channel)
198
201
  thread&.kill
199
202
  end
@@ -202,7 +205,7 @@ module Tina4
202
205
  def close
203
206
  @running = false
204
207
  @mutex.synchronize do
205
- @subs.each_value { |sid| @nats.unsubscribe(sid) rescue nil }
208
+ @subs.each_value { |subscription| subscription.unsubscribe rescue nil }
206
209
  @subs.clear
207
210
  @threads.each_value { |t| t.kill }
208
211
  @threads.clear
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.141
4
+ version: 3.13.142
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-29 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -311,6 +311,7 @@ files:
311
311
  - lib/tina4/queue_backends/lite_backend.rb
312
312
  - lib/tina4/queue_backends/mongo_backend.rb
313
313
  - lib/tina4/queue_backends/rabbitmq_backend.rb
314
+ - lib/tina4/queue_backends/republish_retry_policy.rb
314
315
  - lib/tina4/rack_app.rb
315
316
  - lib/tina4/rate_limiter.rb
316
317
  - lib/tina4/realtime.rb
@@ -340,6 +341,7 @@ files:
340
341
  - lib/tina4/session_handlers/mongo_wire_client.rb
341
342
  - lib/tina4/session_handlers/redis_handler.rb
342
343
  - lib/tina4/session_handlers/resp_client.rb
344
+ - lib/tina4/session_handlers/resp_session_handler.rb
343
345
  - lib/tina4/session_handlers/valkey_handler.rb
344
346
  - lib/tina4/shutdown.rb
345
347
  - lib/tina4/smtp_client.rb