tina4ruby 3.13.140 → 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 +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/tina4/crud.rb +5 -7
- data/lib/tina4/error_overlay.rb +4 -6
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +9 -2
- data/lib/tina4/job.rb +17 -0
- data/lib/tina4/migration.rb +49 -41
- data/lib/tina4/queue_backends/kafka_backend.rb +12 -31
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +15 -37
- data/lib/tina4/queue_backends/republish_retry_policy.rb +35 -0
- data/lib/tina4/session_handlers/redis_handler.rb +7 -51
- data/lib/tina4/session_handlers/resp_session_handler.rb +75 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -51
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +26 -2
- data/lib/tina4/websocket_backplane.rb +16 -13
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e0e60af31fb00e8d35480634176d6bf978963f0d2e0609ebc9468204d8852d0
|
|
4
|
+
data.tar.gz: c34fbf17ba63d2295c13071cdc82cf1eca291bd7fafa4a1e8d6510083bd95ba1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a290825e4873802ac5311a665c67d19fcb618d77048299601f4bb6d9368a389cb093ce1039eaf7b071f30f1b7f0d7d3d2e297617947198ad9e81912909acb23
|
|
7
|
+
data.tar.gz: 287309debcce829752205d6bafedd17e2849b20726c27e151e69503e31a7656bfb420511946512e6f12b11953ec68e4ed01a1628dbf173d1ad9cdee605e1e49e
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ 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
|
+
|
|
27
|
+
## 3.13.141 — 2026-09-29
|
|
28
|
+
|
|
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.
|
|
30
|
+
|
|
31
|
+
### Security
|
|
32
|
+
- A new `scripts/check-no-symlinks.sh` gate refuses any committed symlink (git mode 120000). A committed symlink breaks Windows and Composer extraction and is a supply-chain risk. The gate runs first in the test workflow, and a real RSpec spec proves it by mutation — it stages a symlink in a throwaway git repo, watches the guard go red, then removes it and watches it go green.
|
|
33
|
+
|
|
9
34
|
## 3.13.140 — 2026-09-28
|
|
10
35
|
|
|
11
36
|
A housekeeping release that tightens the guards and dedups the SQLite plumbing. The CLAUDE.md doc-drift gate now watches this repo, so a doc that names a Tina4 method the code no longer has fails loudly instead of drifting on quietly. The SQLite path resolver learnt some manners: an escaping relative path is refused, and it creates the parent folders itself at 0775 (ADR-0086). 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
|
+
# & < > " ' -> & < > " ') 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
|
|
413
|
-
.gsub("&", "&")
|
|
414
|
-
.gsub("<", "<")
|
|
415
|
-
.gsub(">", ">")
|
|
416
|
-
.gsub('"', """)
|
|
417
|
-
.gsub("'", "'")
|
|
415
|
+
Tina4::Frond.escape_html(text)
|
|
418
416
|
end
|
|
419
417
|
|
|
420
418
|
# Pretty label from a column name: "user_name" => "User Name"
|
data/lib/tina4/error_overlay.rb
CHANGED
|
@@ -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 & < > " ' -> & < > " ')
|
|
174
|
+
# rather than keeping a private copy of the escape table here.
|
|
172
175
|
def esc(text)
|
|
173
|
-
text
|
|
174
|
-
.gsub("&", "&")
|
|
175
|
-
.gsub("<", "<")
|
|
176
|
-
.gsub(">", ">")
|
|
177
|
-
.gsub('"', """)
|
|
178
|
-
.gsub("'", "'")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -492,56 +492,23 @@ module Tina4
|
|
|
492
492
|
|
|
493
493
|
# Block comment /* … */ — stripped.
|
|
494
494
|
if ch == "/" && i + 1 < n && sql[i + 1] == "*"
|
|
495
|
-
|
|
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
|
-
|
|
504
|
-
i = endpos || n
|
|
502
|
+
i = skip_line_comment(sql, i, n)
|
|
505
503
|
next
|
|
506
504
|
end
|
|
507
505
|
|
|
508
|
-
#
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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.
|
|
135
|
-
# topic/payload/id alone reset attempts to 0 on every
|
|
136
|
-
#
|
|
137
|
-
|
|
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
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
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.
|
|
75
|
-
# topic/payload/id alone reset attempts to 0 on every
|
|
76
|
-
#
|
|
77
|
-
|
|
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
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
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
|
|
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
data/lib/tina4/websocket.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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] =
|
|
182
|
-
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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 { |
|
|
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.
|
|
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-
|
|
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
|