tina4ruby 3.13.43 → 3.13.44
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/lib/tina4/database.rb +75 -11
- data/lib/tina4/dev_mailbox.rb +8 -4
- data/lib/tina4/drivers/mssql_driver.rb +29 -4
- data/lib/tina4/drivers/mysql_driver.rb +29 -8
- data/lib/tina4/drivers/postgres_driver.rb +90 -0
- data/lib/tina4/drivers/sqlite_driver.rb +20 -2
- data/lib/tina4/migration.rb +22 -2
- data/lib/tina4/queue_backends/kafka_backend.rb +19 -2
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -1
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +19 -3
- data/lib/tina4/session_handlers/mongo_handler.rb +16 -5
- data/lib/tina4/session_handlers/redis_handler.rb +34 -12
- data/lib/tina4/session_handlers/resp_client.rb +126 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +33 -12
- data/lib/tina4/version.rb +1 -1
- metadata +31 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a015bff2d92f110b5a0f8646ae2491dada678055a5f4f3fe8d842c41215e86f
|
|
4
|
+
data.tar.gz: 1ae42fcc29b11dd6f2ad94fb510d3b0946c5b3b396c0c1977786aa195f5e2e14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 429e4eeef534f632cff87ef296396c1c86e839dfbe6b66c86bf90944332ee8168ab21fd86e0d07748546a3b8383f01e8a38d5f3a56d2cc14e33d34d8b2780f0e
|
|
7
|
+
data.tar.gz: 912729e68df830558d8df751236513443ba8d63eec8f4a28e95deaa86b137b2334476461bf551e5f7daa2d6c52d95b029881150eb2b5efb8b11aab3b4fe1e782
|
data/lib/tina4/database.rb
CHANGED
|
@@ -486,9 +486,20 @@ module Tina4
|
|
|
486
486
|
cache_invalidate if @cache_enabled
|
|
487
487
|
drv = current_driver
|
|
488
488
|
|
|
489
|
-
# List of hashes — batch insert
|
|
489
|
+
# List of hashes — batch insert.
|
|
490
|
+
#
|
|
491
|
+
# Cross-framework parity (mirrors the Python master's DatabaseAdapter.insert
|
|
492
|
+
# → execute_many): build ONE parameterised INSERT and run it once per row
|
|
493
|
+
# inside a SINGLE transaction on a SINGLE connection (see #execute_many),
|
|
494
|
+
# then report a DatabaseResult whose affected_rows == the number of rows
|
|
495
|
+
# (deterministic — the batch is all-or-raise) and a sensible last_id read
|
|
496
|
+
# from that same connection. The per-driver #insert overrides (e.g.
|
|
497
|
+
# PostgreSQL's INSERT ... RETURNING *) call data.keys, so they only ever
|
|
498
|
+
# see a single Hash — the Array is intercepted here and never reaches them,
|
|
499
|
+
# which is exactly the crash Python hit when a list fell through to a
|
|
500
|
+
# keys-only override.
|
|
490
501
|
if data.is_a?(Array)
|
|
491
|
-
return
|
|
502
|
+
return Tina4::DatabaseResult.new([], affected_rows: 0, last_id: nil) if data.empty?
|
|
492
503
|
keys = data.first.keys.map(&:to_s)
|
|
493
504
|
placeholders = drv.placeholders(keys.length)
|
|
494
505
|
sql = "INSERT INTO #{table} (#{keys.join(', ')}) VALUES (#{placeholders})"
|
|
@@ -496,6 +507,18 @@ module Tina4
|
|
|
496
507
|
return execute_many(sql, params_list)
|
|
497
508
|
end
|
|
498
509
|
|
|
510
|
+
# Issue #256: a driver that can surface the ACTUAL generated primary key
|
|
511
|
+
# (PostgreSQL, via INSERT ... RETURNING *) owns its own insert so a UUID
|
|
512
|
+
# PK comes back as the real 36-char string and a SERIAL PK as the integer
|
|
513
|
+
# — instead of probing a session sequence (lastval()) that returns nil or
|
|
514
|
+
# a stale wrong id for a UUID table. Other engines (SQLite/MySQL/MSSQL/
|
|
515
|
+
# Firebird) keep the generic build-then-last_insert_id path below.
|
|
516
|
+
if drv.respond_to?(:insert)
|
|
517
|
+
result = drv.insert(table, data)
|
|
518
|
+
autocommit_standalone_write(drv)
|
|
519
|
+
return result
|
|
520
|
+
end
|
|
521
|
+
|
|
499
522
|
columns = data.keys.map(&:to_s)
|
|
500
523
|
placeholders = drv.placeholders(columns.length)
|
|
501
524
|
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
@@ -615,20 +638,61 @@ module Tina4
|
|
|
615
638
|
raise
|
|
616
639
|
end
|
|
617
640
|
|
|
641
|
+
# Run one statement once per row in a SINGLE transaction on a SINGLE
|
|
642
|
+
# connection, returning a DatabaseResult.
|
|
643
|
+
#
|
|
644
|
+
# DB-contract / batch-insert parity (mirrors the Python master's
|
|
645
|
+
# execute_many): the WHOLE batch runs on ONE driver — pinned for the
|
|
646
|
+
# duration — so begin/execute*/commit can never scatter across pooled
|
|
647
|
+
# connections (which made affected_rows / last_id non-deterministic). It is
|
|
648
|
+
# all-or-raise (any row raising rolls the whole batch back), so:
|
|
649
|
+
#
|
|
650
|
+
# * affected_rows is the ROW COUNT, computed deterministically from the
|
|
651
|
+
# number of supplied rows — NOT read from a driver rowcount. PostgreSQL's
|
|
652
|
+
# no-RETURNING INSERT reports cmd_tuples correctly, but other engines'
|
|
653
|
+
# rowcounts after a batch are unreliable, and a follow-up probe (lastval/
|
|
654
|
+
# SAVEPOINT) can clobber the rowcount, so the count is the supplied length.
|
|
655
|
+
# * last_id is read from last_insert_id() on the SAME connection AFTER the
|
|
656
|
+
# batch, so a SERIAL/AUTOINCREMENT table surfaces the last generated id
|
|
657
|
+
# (nil for engines/tables with no sequence — Firebird, a no-PK table).
|
|
658
|
+
#
|
|
659
|
+
# The pin is set here only when no transaction is already open on the thread
|
|
660
|
+
# (an outer start_transaction already pinned the driver — leave it, and let
|
|
661
|
+
# the outer commit/rollback own the lifecycle). When we pin, we own the
|
|
662
|
+
# begin/commit/rollback; when an outer tx owns the pin, we just run the rows
|
|
663
|
+
# and let the outer transaction commit them.
|
|
618
664
|
def execute_many(sql, params_list = [])
|
|
619
|
-
|
|
665
|
+
params_list ||= []
|
|
666
|
+
already_pinned = !Thread.current[@tx_pin_key].nil?
|
|
620
667
|
drv = current_driver
|
|
621
|
-
drv
|
|
668
|
+
Thread.current[@tx_pin_key] = drv unless already_pinned
|
|
669
|
+
|
|
622
670
|
begin
|
|
623
|
-
|
|
624
|
-
|
|
671
|
+
drv.begin_transaction unless already_pinned
|
|
672
|
+
begin
|
|
673
|
+
params_list.each { |params| drv.execute(sql, params) }
|
|
674
|
+
drv.commit unless already_pinned
|
|
675
|
+
rescue => e
|
|
676
|
+
drv.rollback unless already_pinned
|
|
677
|
+
@last_error = e.message
|
|
678
|
+
raise e
|
|
625
679
|
end
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
drv.rollback
|
|
629
|
-
raise e
|
|
680
|
+
ensure
|
|
681
|
+
Thread.current[@tx_pin_key] = nil unless already_pinned
|
|
630
682
|
end
|
|
631
|
-
|
|
683
|
+
|
|
684
|
+
last_id = begin
|
|
685
|
+
drv.last_insert_id
|
|
686
|
+
rescue StandardError
|
|
687
|
+
nil
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
Tina4::DatabaseResult.new(
|
|
691
|
+
[],
|
|
692
|
+
affected_rows: params_list.length,
|
|
693
|
+
last_id: last_id,
|
|
694
|
+
db: self
|
|
695
|
+
)
|
|
632
696
|
end
|
|
633
697
|
|
|
634
698
|
def transaction
|
data/lib/tina4/dev_mailbox.rb
CHANGED
|
@@ -56,11 +56,11 @@ module Tina4
|
|
|
56
56
|
path = message_path(msg_id)
|
|
57
57
|
return nil unless File.exist?(path)
|
|
58
58
|
|
|
59
|
-
message = JSON.parse(File.read(path), symbolize_names: true)
|
|
59
|
+
message = JSON.parse(File.read(path, encoding: "UTF-8"), symbolize_names: true)
|
|
60
60
|
unless message[:read]
|
|
61
61
|
message[:read] = true
|
|
62
62
|
message[:updated_at] = Time.now.iso8601
|
|
63
|
-
File.write(path, JSON.pretty_generate(message))
|
|
63
|
+
File.write(path, JSON.pretty_generate(message), encoding: "UTF-8")
|
|
64
64
|
end
|
|
65
65
|
message
|
|
66
66
|
end
|
|
@@ -140,13 +140,17 @@ module Tina4
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def write_message(msg_id, message)
|
|
143
|
-
|
|
143
|
+
# JSON is UTF-8 by spec, and seed/captured bodies may carry non-ASCII
|
|
144
|
+
# (accented fake names, smart quotes). Pin the I/O encoding to UTF-8 so a
|
|
145
|
+
# locale-less environment (Encoding.default_external == US-ASCII) does not
|
|
146
|
+
# mangle the write or raise on the read-back.
|
|
147
|
+
File.write(message_path(msg_id), JSON.pretty_generate(message), encoding: "UTF-8")
|
|
144
148
|
end
|
|
145
149
|
|
|
146
150
|
def load_all_messages
|
|
147
151
|
pattern = File.join(@mailbox_dir, "messages", "*.json")
|
|
148
152
|
Dir.glob(pattern).filter_map do |path|
|
|
149
|
-
JSON.parse(File.read(path), symbolize_names: true)
|
|
153
|
+
JSON.parse(File.read(path, encoding: "UTF-8"), symbolize_names: true)
|
|
150
154
|
rescue JSON::ParserError => e
|
|
151
155
|
Tina4::Log.error("DevMailbox: corrupt message file #{path}: #{e.message}")
|
|
152
156
|
nil
|
|
@@ -41,15 +41,30 @@ module Tina4
|
|
|
41
41
|
|
|
42
42
|
def execute(sql, params = [])
|
|
43
43
|
effective_sql = interpolate_params(sql, params)
|
|
44
|
+
|
|
45
|
+
# Capture the generated IDENTITY AT WRITE TIME — mirror of the Python
|
|
46
|
+
# master (mssql.py execute(): SELECT SCOPE_IDENTITY() runs straight after
|
|
47
|
+
# the INSERT on the SAME cursor). tiny_tds runs each #execute as its OWN
|
|
48
|
+
# T-SQL batch, and SCOPE_IDENTITY() is batch-scoped: read in a separate
|
|
49
|
+
# later batch it is always NULL — which is why both insert(...).last_id
|
|
50
|
+
# and db.get_last_id came back nil (issue #262). So for an INSERT we run
|
|
51
|
+
# the INSERT and SELECT SCOPE_IDENTITY() in ONE batch (a single
|
|
52
|
+
# @connection.execute), read the id from the SAME batch, and cache it.
|
|
53
|
+
if sql.to_s.lstrip[0, 6].casecmp?("INSERT")
|
|
54
|
+
result = @connection.execute("#{effective_sql}; SELECT SCOPE_IDENTITY() AS id")
|
|
55
|
+
rows = result.each(symbolize_keys: true).to_a
|
|
56
|
+
result.cancel if result.respond_to?(:cancel)
|
|
57
|
+
row = rows.last
|
|
58
|
+
@last_insert_id = row && row[:id] ? row[:id].to_i : nil
|
|
59
|
+
return true
|
|
60
|
+
end
|
|
61
|
+
|
|
44
62
|
result = @connection.execute(effective_sql)
|
|
45
63
|
result.do
|
|
46
64
|
end
|
|
47
65
|
|
|
48
66
|
def last_insert_id
|
|
49
|
-
|
|
50
|
-
row = result.first
|
|
51
|
-
result.cancel if result.respond_to?(:cancel)
|
|
52
|
-
row[:id]&.to_i
|
|
67
|
+
@last_insert_id
|
|
53
68
|
end
|
|
54
69
|
|
|
55
70
|
def placeholder
|
|
@@ -129,6 +144,16 @@ module Tina4
|
|
|
129
144
|
escaped =
|
|
130
145
|
if param.nil?
|
|
131
146
|
"NULL"
|
|
147
|
+
elsif param == true
|
|
148
|
+
# SQL Server has no boolean literal — BIT stores 0/1. A raw `true`
|
|
149
|
+
# would interpolate as the bareword `true` ("Invalid column name
|
|
150
|
+
# 'true'"). Coerce at the bind boundary, parity with the SQLite
|
|
151
|
+
# driver's coerce_params and the Python/PHP/Node bind contract.
|
|
152
|
+
"1"
|
|
153
|
+
elsif param == false
|
|
154
|
+
"0"
|
|
155
|
+
elsif param.is_a?(Time) || param.is_a?(DateTime)
|
|
156
|
+
"'#{(param.respond_to?(:iso8601) ? param.iso8601 : param.to_s).gsub("'", "''")}'"
|
|
132
157
|
elsif param.is_a?(String)
|
|
133
158
|
"'#{param.gsub("'", "''")}'"
|
|
134
159
|
else
|
|
@@ -18,8 +18,18 @@ module Tina4
|
|
|
18
18
|
" gem install mysql2 # bare driver"
|
|
19
19
|
end
|
|
20
20
|
uri = URI.parse(connection_string)
|
|
21
|
+
# libmysqlclient connects over a UNIX socket whenever host is "localhost"
|
|
22
|
+
# (its historical convention) and silently ignores the port. A URL that
|
|
23
|
+
# names a port clearly intends TCP, so rewrite "localhost" to "127.0.0.1"
|
|
24
|
+
# in that case to force the TCP path — without it a Docker/TCP-only MySQL
|
|
25
|
+
# fails with "Can't connect ... through socket '/tmp/mysql.sock'". A
|
|
26
|
+
# port-less "localhost" keeps the socket path so socket deployments still
|
|
27
|
+
# work. Parity with PHP's MySQLAdapter::rewriteHostForTcp (mysqli has the
|
|
28
|
+
# identical socket trap).
|
|
29
|
+
host = uri.host || "127.0.0.1"
|
|
30
|
+
host = "127.0.0.1" if host == "localhost" && uri.port
|
|
21
31
|
@connection = Mysql2::Client.new(
|
|
22
|
-
host:
|
|
32
|
+
host: host,
|
|
23
33
|
port: uri.port || 3306,
|
|
24
34
|
username: username || uri.user,
|
|
25
35
|
password: password || uri.password,
|
|
@@ -42,16 +52,27 @@ module Tina4
|
|
|
42
52
|
end
|
|
43
53
|
|
|
44
54
|
def execute(sql, params = [])
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
result =
|
|
56
|
+
if params.empty?
|
|
57
|
+
@connection.query(sql)
|
|
58
|
+
else
|
|
59
|
+
stmt = @connection.prepare(sql)
|
|
60
|
+
stmt.execute(*params)
|
|
61
|
+
end
|
|
62
|
+
# Capture the generated id AT WRITE TIME — mirrors the Python master
|
|
63
|
+
# (mysql.py execute(): `last_id = cursor.lastrowid` is read straight after
|
|
64
|
+
# the statement, never re-read later). mysql2's @connection.last_id reflects
|
|
65
|
+
# the LAST statement on this connection, so a follow-up autocommit COMMIT
|
|
66
|
+
# (a separate query) clobbers it to 0 — that is exactly why db.get_last_id
|
|
67
|
+
# returned 0 after an insert (issue #262). Snapshot it for every INSERT so
|
|
68
|
+
# last_insert_id keeps the id of the last insert regardless of any
|
|
69
|
+
# subsequent COMMIT / SELECT on the connection.
|
|
70
|
+
@last_insert_id = @connection.last_id if sql.to_s.lstrip[0, 6].casecmp?("INSERT")
|
|
71
|
+
result
|
|
51
72
|
end
|
|
52
73
|
|
|
53
74
|
def last_insert_id
|
|
54
|
-
@
|
|
75
|
+
@last_insert_id
|
|
55
76
|
end
|
|
56
77
|
|
|
57
78
|
def placeholder
|
|
@@ -44,6 +44,13 @@ module Tina4
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def execute(sql, params = [])
|
|
47
|
+
# Issue #256: a bare INSERT run through execute() (not #insert, so no
|
|
48
|
+
# RETURNING captured) must NOT let a previously-captured RETURNING id
|
|
49
|
+
# leak into a later last_insert_id() — that would surface a stale id
|
|
50
|
+
# (e.g. a UUID string from an earlier db.insert) for this new write.
|
|
51
|
+
# Clear the cache so last_insert_id falls back to the lastval() probe,
|
|
52
|
+
# which is the correct source for a sequence-backed bare INSERT.
|
|
53
|
+
@last_returning_id = nil if sql.lstrip[0, 6].upcase == "INSERT"
|
|
47
54
|
converted_sql = convert_placeholders(sql)
|
|
48
55
|
if params.empty?
|
|
49
56
|
@connection.exec(converted_sql)
|
|
@@ -52,7 +59,51 @@ module Tina4
|
|
|
52
59
|
end
|
|
53
60
|
end
|
|
54
61
|
|
|
62
|
+
# Issue #256: surface the ACTUAL primary key value an INSERT wrote —
|
|
63
|
+
# including a server-generated UUID — instead of guessing it from a
|
|
64
|
+
# session sequence after the fact.
|
|
65
|
+
#
|
|
66
|
+
# Before this, Database#insert ran a bare INSERT and then probed
|
|
67
|
+
# ``last_insert_id`` (``SELECT lastval()``). For a UUID PK
|
|
68
|
+
# (``id uuid PRIMARY KEY DEFAULT gen_random_uuid()``) there is no
|
|
69
|
+
# session sequence, so the probe returned nil — or, worse, a STALE
|
|
70
|
+
# integer left over from an unrelated SERIAL table's nextval() earlier
|
|
71
|
+
# in the same session (a silently WRONG id). The SERIAL integer path was
|
|
72
|
+
# correct only by luck of lastval() pointing at the right sequence.
|
|
73
|
+
#
|
|
74
|
+
# Fix (mirrors the Python master's ``INSERT ... RETURNING *`` and the
|
|
75
|
+
# Node adapter): append ``RETURNING *`` and read the generated ``id``
|
|
76
|
+
# back from the returned row. The value is normalised so the SERIAL path
|
|
77
|
+
# keeps returning an Integer while a UUID PK surfaces its real 36-char
|
|
78
|
+
# string. No lastval() probe, so the issue-#38 transaction-abort can't
|
|
79
|
+
# happen on this path at all.
|
|
80
|
+
#
|
|
81
|
+
# Returns { success: true, last_id: <id-or-nil> }. last_id is nil only
|
|
82
|
+
# when the table truly has no ``id`` column.
|
|
83
|
+
def insert(table, data)
|
|
84
|
+
columns = data.keys.map(&:to_s)
|
|
85
|
+
placeholders = placeholders(columns.length)
|
|
86
|
+
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders}) RETURNING *"
|
|
87
|
+
result = execute_query(sql, data.values)
|
|
88
|
+
row = result.is_a?(Array) ? result.first : nil
|
|
89
|
+
id = normalize_returned_id(row)
|
|
90
|
+
# Remember the real id so a follow-up #last_insert_id / db.get_last_id
|
|
91
|
+
# surfaces THIS value (incl. a UUID string) instead of re-probing
|
|
92
|
+
# lastval(), which has no sequence for a UUID PK and would return a
|
|
93
|
+
# stale wrong integer from an unrelated table.
|
|
94
|
+
@last_returning_id = id
|
|
95
|
+
{ success: true, last_id: id }
|
|
96
|
+
end
|
|
97
|
+
|
|
55
98
|
def last_insert_id
|
|
99
|
+
# Issue #256: if the most recent write surfaced its real primary key
|
|
100
|
+
# through ``RETURNING *`` (the #insert path), return that — it is the
|
|
101
|
+
# actual id written (a UUID string stays a string, a SERIAL stays an
|
|
102
|
+
# integer), not a guess. Only fall back to the lastval() probe below
|
|
103
|
+
# when nothing has been captured yet (e.g. a bare
|
|
104
|
+
# ``execute("INSERT ...")`` with no RETURNING).
|
|
105
|
+
return @last_returning_id unless @last_returning_id.nil?
|
|
106
|
+
|
|
56
107
|
# Issue #38: ``SELECT lastval()`` raises on tables with no sequence
|
|
57
108
|
# (UUID, ULID, hash PKs etc.). The exception itself isn't fatal,
|
|
58
109
|
# but the pg gem marks the whole transaction as aborted, so every
|
|
@@ -153,6 +204,45 @@ module Tina4
|
|
|
153
204
|
|
|
154
205
|
private
|
|
155
206
|
|
|
207
|
+
# Issue #256: normalise the ``id`` of an ``INSERT ... RETURNING *`` row.
|
|
208
|
+
#
|
|
209
|
+
# The result-type map already decodes a SERIAL/int8 ``id`` to an Integer
|
|
210
|
+
# and a ``uuid`` ``id`` to a String, so the value usually arrives in the
|
|
211
|
+
# right shape. We still coerce defensively (mirrors the Node adapter's
|
|
212
|
+
# ``normalizeId``): a numeric String becomes an Integer so the SERIAL path
|
|
213
|
+
# always returns the integer id, while a non-numeric String — the UUID PK
|
|
214
|
+
# case — is preserved verbatim. A row with no ``id`` column (or a
|
|
215
|
+
# blank/nil id) yields nil.
|
|
216
|
+
def normalize_returned_id(row)
|
|
217
|
+
return nil unless row
|
|
218
|
+
|
|
219
|
+
value = row_id_value(row)
|
|
220
|
+
case value
|
|
221
|
+
when Integer
|
|
222
|
+
value
|
|
223
|
+
when Numeric
|
|
224
|
+
value
|
|
225
|
+
when String
|
|
226
|
+
stripped = value.strip
|
|
227
|
+
return nil if stripped.empty?
|
|
228
|
+
# A purely numeric string is a SERIAL id surfaced as text — coerce
|
|
229
|
+
# to Integer. Anything else (a UUID, a ULID, a composite key) stays
|
|
230
|
+
# the string it is.
|
|
231
|
+
stripped.match?(/\A-?\d+\z/) ? stripped.to_i : value
|
|
232
|
+
else
|
|
233
|
+
value.nil? ? nil : value
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Read the ``id`` column from a RETURNING row regardless of key style
|
|
238
|
+
# (symbol/string, any case) — execute_query symbolizes keys, but stay
|
|
239
|
+
# tolerant.
|
|
240
|
+
def row_id_value(row)
|
|
241
|
+
return nil unless row.is_a?(Hash)
|
|
242
|
+
|
|
243
|
+
row[:id] || row["id"] || row[:ID] || row["ID"]
|
|
244
|
+
end
|
|
245
|
+
|
|
156
246
|
# Decode result columns to native Ruby types (parity with SQLite, and
|
|
157
247
|
# with Python psycopg2 / Node node-postgres which both return native
|
|
158
248
|
# types). Without this the pg gem hands back EVERY column as a String —
|
|
@@ -71,12 +71,30 @@ module Tina4
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def execute_query(sql, params = [])
|
|
74
|
-
results = @connection.execute(sql, params)
|
|
74
|
+
results = @connection.execute(sql, coerce_params(params))
|
|
75
75
|
results.map { |row| symbolize_keys(row) }
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
def execute(sql, params = [])
|
|
79
|
-
@connection.execute(sql, params)
|
|
79
|
+
@connection.execute(sql, coerce_params(params))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Coerce Ruby values to types the sqlite3 gem can bind. The gem RAISES
|
|
83
|
+
# ("can't prepare TrueClass") on a raw boolean, so map true/false to 1/0 —
|
|
84
|
+
# SQLite stores booleans as INTEGER 0/1. Time/DateTime serialise to ISO-8601
|
|
85
|
+
# so a datetime field round-trips. Parity with the Python/PHP/Node adapters,
|
|
86
|
+
# which coerce booleans at the same bind boundary.
|
|
87
|
+
def coerce_params(params)
|
|
88
|
+
return params unless params.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
params.map do |value|
|
|
91
|
+
case value
|
|
92
|
+
when true then 1
|
|
93
|
+
when false then 0
|
|
94
|
+
when Time, DateTime then value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
95
|
+
else value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
80
98
|
end
|
|
81
99
|
|
|
82
100
|
def last_insert_id
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -192,15 +192,35 @@ module Tina4
|
|
|
192
192
|
)
|
|
193
193
|
SQL
|
|
194
194
|
else
|
|
195
|
+
# Engine-aware bookkeeping DDL. Each engine spells an auto-increment
|
|
196
|
+
# integer PK differently (SQLite AUTOINCREMENT, PostgreSQL SERIAL,
|
|
197
|
+
# MySQL AUTO_INCREMENT, MSSQL IDENTITY); MSSQL also reserves TIMESTAMP
|
|
198
|
+
# for rowversion, so a real timestamp column must be DATETIME. The
|
|
199
|
+
# migration_name UNIQUE column is VARCHAR (not TEXT) — a TEXT column
|
|
200
|
+
# cannot carry a UNIQUE index on MySQL, and SQLite gives VARCHAR TEXT
|
|
201
|
+
# affinity so SQLite behaviour stays identical. Mirrors the
|
|
202
|
+
# engine-aware DDL in ORM.create_table; without it `migrate()` died
|
|
203
|
+
# with "syntax error at AUTOINCREMENT" on PostgreSQL/MySQL/MSSQL.
|
|
204
|
+
#
|
|
195
205
|
# migration_name is UNIQUE: a migration is "applied" iff a success row
|
|
196
206
|
# exists, so a re-applied name must never duplicate a tracking row.
|
|
207
|
+
engine = (@db.respond_to?(:get_database_type) ? @db.get_database_type : "").to_s.downcase
|
|
208
|
+
id_column = case engine
|
|
209
|
+
when "postgres", "postgresql" then "id SERIAL PRIMARY KEY"
|
|
210
|
+
when "mysql" then "id INTEGER PRIMARY KEY AUTO_INCREMENT"
|
|
211
|
+
when "mssql", "sqlserver" then "id INTEGER IDENTITY(1,1) PRIMARY KEY"
|
|
212
|
+
else "id INTEGER PRIMARY KEY AUTOINCREMENT" # sqlite (default)
|
|
213
|
+
end
|
|
214
|
+
executed_at_column = %w[mssql sqlserver].include?(engine) ?
|
|
215
|
+
"executed_at DATETIME DEFAULT CURRENT_TIMESTAMP" :
|
|
216
|
+
"executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
|
197
217
|
@db.execute(<<~SQL)
|
|
198
218
|
CREATE TABLE #{TRACKING_TABLE} (
|
|
199
|
-
|
|
219
|
+
#{id_column},
|
|
200
220
|
migration_name VARCHAR(255) NOT NULL UNIQUE,
|
|
201
221
|
description VARCHAR(255) DEFAULT '',
|
|
202
222
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
203
|
-
|
|
223
|
+
#{executed_at_column},
|
|
204
224
|
passed INTEGER NOT NULL DEFAULT 1
|
|
205
225
|
)
|
|
206
226
|
SQL
|
|
@@ -71,12 +71,29 @@ module Tina4
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def dequeue(topic)
|
|
74
|
-
|
|
74
|
+
first = !@subscribed_topics.include?(topic)
|
|
75
|
+
if first
|
|
75
76
|
@consumer.subscribe(topic)
|
|
76
77
|
@subscribed_topics << topic
|
|
77
78
|
end
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
# The first poll after subscribing must drive the consumer-group join +
|
|
81
|
+
# partition assignment, which takes several seconds on a cold broker.
|
|
82
|
+
# Until partitions are assigned, poll returns nil even when the topic
|
|
83
|
+
# already has messages -- so a single poll made dequeue return nil right
|
|
84
|
+
# after enqueue. Poll in a bounded loop on first subscribe (deadline
|
|
85
|
+
# TINA4_KAFKA_ASSIGN_TIMEOUT, default 15s); steady state stays one ~1s poll.
|
|
86
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
87
|
+
(first ? (ENV["TINA4_KAFKA_ASSIGN_TIMEOUT"] || "15").to_f : 1.0)
|
|
88
|
+
msg = nil
|
|
89
|
+
loop do
|
|
90
|
+
candidate = @consumer.poll(500)
|
|
91
|
+
if candidate
|
|
92
|
+
msg = candidate
|
|
93
|
+
break
|
|
94
|
+
end
|
|
95
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
96
|
+
end
|
|
80
97
|
return nil unless msg
|
|
81
98
|
|
|
82
99
|
data = JSON.parse(msg.payload)
|
|
@@ -30,7 +30,12 @@ module Tina4
|
|
|
30
30
|
@visibility_timeout = resolve_visibility_timeout(options[:visibility_timeout])
|
|
31
31
|
|
|
32
32
|
if uri
|
|
33
|
-
|
|
33
|
+
# Honour the explicit db: / TINA4_MONGO_DB even when a uri is given.
|
|
34
|
+
# Mongo::Client.new(uri) with no database path defaults to "admin", so
|
|
35
|
+
# without passing :database the requested db_name was silently dropped
|
|
36
|
+
# and every job/dead-letter landed in admin (a data-isolation footgun).
|
|
37
|
+
# The explicit db_name always wins over the URI's (often absent) default.
|
|
38
|
+
@client = Mongo::Client.new(uri, database: db_name)
|
|
34
39
|
else
|
|
35
40
|
conn_options = { database: db_name }
|
|
36
41
|
conn_options[:user] = username if username
|
|
@@ -27,7 +27,14 @@ module Tina4
|
|
|
27
27
|
|
|
28
28
|
def dequeue(topic)
|
|
29
29
|
queue = get_queue(topic)
|
|
30
|
-
|
|
30
|
+
# Manual ack: do NOT let bunny's default auto-ack remove the message on
|
|
31
|
+
# pop. The message stays in-flight (unacked) until complete() acks it, so
|
|
32
|
+
# a consumer crash before complete() makes the broker redeliver it
|
|
33
|
+
# (at-least-once delivery) — parity with the Python/PHP masters, whose
|
|
34
|
+
# basic_get uses auto_ack=false / no-ack=false. With the old auto-ack pop
|
|
35
|
+
# the stored delivery_tag had already been acked, so a later
|
|
36
|
+
# channel.acknowledge raised PRECONDITION_FAILED and closed the channel.
|
|
37
|
+
delivery_info, _properties, payload = queue.pop(manual_ack: true)
|
|
31
38
|
return nil unless payload
|
|
32
39
|
|
|
33
40
|
data = JSON.parse(payload)
|
|
@@ -40,8 +47,17 @@ module Tina4
|
|
|
40
47
|
msg
|
|
41
48
|
end
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
# Acknowledge the in-flight message as done (terminal). Named complete() to
|
|
51
|
+
# match the lite/mongo backends AND the Job#complete lifecycle, which calls
|
|
52
|
+
# backend.complete (not acknowledge) — so `job.complete` now actually acks
|
|
53
|
+
# the broker message instead of being a silent no-op. multiple:false acks
|
|
54
|
+
# only this delivery. The stored tag is cleared so a double-complete is a
|
|
55
|
+
# safe no-op rather than a second ack on an unknown tag.
|
|
56
|
+
def complete(_message)
|
|
57
|
+
return unless @last_delivery_tag
|
|
58
|
+
|
|
59
|
+
@channel.acknowledge(@last_delivery_tag, false)
|
|
60
|
+
@last_delivery_tag = nil
|
|
45
61
|
end
|
|
46
62
|
|
|
47
63
|
def requeue(message)
|
|
@@ -12,11 +12,7 @@ module Tina4
|
|
|
12
12
|
database: options[:database] || "tina4_sessions"
|
|
13
13
|
)
|
|
14
14
|
@collection = client[options[:collection] || "sessions"]
|
|
15
|
-
|
|
16
|
-
@collection.indexes.create_one(
|
|
17
|
-
{ updated_at: 1 },
|
|
18
|
-
expire_after_seconds: @ttl
|
|
19
|
-
)
|
|
15
|
+
ensure_ttl_index
|
|
20
16
|
rescue LoadError
|
|
21
17
|
raise "MongoDB session handler requires the 'mongo' gem. Install with: gem install mongo"
|
|
22
18
|
rescue Mongo::Error => e
|
|
@@ -44,6 +40,21 @@ module Tina4
|
|
|
44
40
|
def cleanup
|
|
45
41
|
# MongoDB TTL index handles cleanup
|
|
46
42
|
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Create the updated_at TTL index. An existing updated_at index with a
|
|
47
|
+
# DIFFERENT expireAfterSeconds raises IndexOptionsConflict (code 85) — a
|
|
48
|
+
# TTL index cannot be modified in place — so drop and recreate it with the
|
|
49
|
+
# requested TTL. This makes re-init idempotent (no per-run error log) and
|
|
50
|
+
# lets a changed session TTL take effect.
|
|
51
|
+
def ensure_ttl_index
|
|
52
|
+
@collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
|
|
53
|
+
rescue Mongo::Error::OperationFailure => e
|
|
54
|
+
raise unless e.code == 85 || e.message.include?("IndexOptionsConflict")
|
|
55
|
+
@collection.indexes.drop_one("updated_at_1")
|
|
56
|
+
@collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
|
|
57
|
+
end
|
|
47
58
|
end
|
|
48
59
|
end
|
|
49
60
|
end
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "json"
|
|
3
|
+
require_relative "resp_client"
|
|
3
4
|
|
|
4
5
|
module Tina4
|
|
5
6
|
module SessionHandlers
|
|
7
|
+
# Redis-backed session handler. Prefers the `redis` gem when it is installed
|
|
8
|
+
# (parity with the Python redis-py and Node redis-npm handlers); otherwise
|
|
9
|
+
# speaks raw RESP over a TCP socket via RespClient — zero dependencies, so a
|
|
10
|
+
# Tina4 app stores sessions in Redis with no extra gem.
|
|
6
11
|
class RedisHandler
|
|
7
12
|
def initialize(options = {})
|
|
8
|
-
require "redis"
|
|
9
13
|
@prefix = options[:prefix] || "tina4:session:"
|
|
10
14
|
@ttl = options[:ttl] || 86400
|
|
11
|
-
@
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
rescue LoadError
|
|
18
|
-
raise "Redis session handler requires the 'redis' gem. Install with: gem install redis"
|
|
15
|
+
@host = options[:host] || "localhost"
|
|
16
|
+
@port = options[:port] || 6379
|
|
17
|
+
@db = options[:db] || 0
|
|
18
|
+
@password = options[:password]
|
|
19
|
+
@redis = build_gem_client
|
|
20
|
+
@resp = @redis ? nil : RespClient.new(host: @host, port: @port, password: @password, db: @db)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def read(session_id)
|
|
22
|
-
|
|
24
|
+
key = "#{@prefix}#{session_id}"
|
|
25
|
+
data = @redis ? @redis.get(key) : @resp.get(key)
|
|
23
26
|
return nil unless data
|
|
24
27
|
JSON.parse(data)
|
|
25
28
|
rescue JSON::ParserError
|
|
@@ -28,16 +31,35 @@ module Tina4
|
|
|
28
31
|
|
|
29
32
|
def write(session_id, data)
|
|
30
33
|
key = "#{@prefix}#{session_id}"
|
|
31
|
-
|
|
34
|
+
payload = JSON.generate(data)
|
|
35
|
+
if @redis
|
|
36
|
+
@redis.setex(key, @ttl, payload)
|
|
37
|
+
else
|
|
38
|
+
@resp.setex(key, @ttl, payload)
|
|
39
|
+
end
|
|
32
40
|
end
|
|
33
41
|
|
|
34
42
|
def destroy(session_id)
|
|
35
|
-
|
|
43
|
+
key = "#{@prefix}#{session_id}"
|
|
44
|
+
@redis ? @redis.del(key) : @resp.del(key)
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
def cleanup
|
|
39
48
|
# Redis handles TTL automatically
|
|
40
49
|
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Use the official `redis` gem only when the REAL gem is loadable (guard
|
|
54
|
+
# against an in-test fake `Redis` constant that has no VERSION). Returns nil
|
|
55
|
+
# when the gem is absent so the caller falls back to the raw RESP client.
|
|
56
|
+
def build_gem_client
|
|
57
|
+
require "redis"
|
|
58
|
+
return nil unless defined?(::Redis::VERSION)
|
|
59
|
+
Redis.new(host: @host, port: @port, db: @db, password: @password)
|
|
60
|
+
rescue LoadError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
41
63
|
end
|
|
42
64
|
end
|
|
43
65
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module SessionHandlers
|
|
7
|
+
# A RESP error reply (`-ERR ...`) surfaced as an exception so the handler can
|
|
8
|
+
# tell a protocol error apart from a genuine nil/miss.
|
|
9
|
+
class RespError < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Zero-dependency synchronous RESP client over a TCP socket. Used by the Redis
|
|
12
|
+
# and Valkey session handlers as the fallback when the optional `redis` gem is
|
|
13
|
+
# not installed, so a Tina4 app talks to Redis/Valkey for sessions with NO
|
|
14
|
+
# third-party gem. Parity with the Python (redis-py + raw RESP) and Node
|
|
15
|
+
# (redis npm + raw respClient) session handlers, and with the framework's own
|
|
16
|
+
# cache backends, which already speak raw RESP this way.
|
|
17
|
+
#
|
|
18
|
+
# One short-lived connection per command (open -> AUTH? -> SELECT? -> command
|
|
19
|
+
# -> close), matching the cache backends. Ruby's blocking sockets make the
|
|
20
|
+
# reply reader straightforward: read the header line up to CRLF, then read the
|
|
21
|
+
# bulk body by its exact byte count (so a large session value spanning several
|
|
22
|
+
# TCP segments is read in full, not truncated to one recv()).
|
|
23
|
+
class RespClient
|
|
24
|
+
def initialize(host:, port:, password: nil, db: 0, timeout: 5)
|
|
25
|
+
@host = host
|
|
26
|
+
@port = port
|
|
27
|
+
@password = password
|
|
28
|
+
@db = (db || 0).to_i
|
|
29
|
+
@timeout = timeout
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get(key)
|
|
33
|
+
command("GET", key)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def set(key, value)
|
|
37
|
+
command("SET", key, value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def setex(key, ttl, value)
|
|
41
|
+
command("SETEX", key, ttl.to_s, value)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def del(key)
|
|
45
|
+
command("DEL", key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Send one RESP command and return its reply: a String for a simple/bulk
|
|
49
|
+
# string or integer, an Array for a multi-bulk reply, or nil for a nil bulk
|
|
50
|
+
# ($-1) / miss. A transport failure (server unreachable, rejected AUTH or
|
|
51
|
+
# SELECT, timeout, connection closed mid-reply) RAISES so the Session
|
|
52
|
+
# boundary can log-loud + degrade (a real op never silently no-ops).
|
|
53
|
+
def command(*args)
|
|
54
|
+
sock = Socket.tcp(@host, @port, connect_timeout: @timeout)
|
|
55
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [@timeout, 0].pack("l_2"))
|
|
56
|
+
begin
|
|
57
|
+
if @password && !@password.empty?
|
|
58
|
+
write_command(sock, "AUTH", @password)
|
|
59
|
+
reply = read_reply(sock)
|
|
60
|
+
raise RespError, "AUTH rejected: #{reply.message}" if reply.is_a?(RespError)
|
|
61
|
+
end
|
|
62
|
+
if @db != 0
|
|
63
|
+
write_command(sock, "SELECT", @db.to_s)
|
|
64
|
+
reply = read_reply(sock)
|
|
65
|
+
raise RespError, "SELECT rejected: #{reply.message}" if reply.is_a?(RespError)
|
|
66
|
+
end
|
|
67
|
+
write_command(sock, *args)
|
|
68
|
+
reply = read_reply(sock)
|
|
69
|
+
raise reply if reply.is_a?(RespError)
|
|
70
|
+
reply
|
|
71
|
+
ensure
|
|
72
|
+
begin
|
|
73
|
+
sock.close
|
|
74
|
+
rescue StandardError
|
|
75
|
+
# already closed / never opened — nothing to do
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def write_command(sock, *args)
|
|
83
|
+
cmd = +"*#{args.size}\r\n"
|
|
84
|
+
args.each do |arg|
|
|
85
|
+
s = arg.to_s
|
|
86
|
+
cmd << "$#{s.bytesize}\r\n#{s}\r\n"
|
|
87
|
+
end
|
|
88
|
+
sock.write(cmd)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_reply(sock)
|
|
92
|
+
line = sock.gets("\r\n")
|
|
93
|
+
raise RespError, "connection closed before reply" if line.nil?
|
|
94
|
+
line = line.chomp("\r\n")
|
|
95
|
+
type = line[0]
|
|
96
|
+
rest = line[1..] || ""
|
|
97
|
+
case type
|
|
98
|
+
when "+", ":" then rest # simple string / integer
|
|
99
|
+
when "-" then RespError.new(rest) # error reply
|
|
100
|
+
when "$" # bulk string
|
|
101
|
+
len = rest.to_i
|
|
102
|
+
return nil if len.negative? # $-1 = nil
|
|
103
|
+
data = read_exact(sock, len)
|
|
104
|
+
read_exact(sock, 2) # trailing CRLF
|
|
105
|
+
data
|
|
106
|
+
when "*" # array (multi-bulk)
|
|
107
|
+
len = rest.to_i
|
|
108
|
+
return nil if len.negative?
|
|
109
|
+
Array.new(len) { read_reply(sock) }
|
|
110
|
+
else
|
|
111
|
+
line
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_exact(sock, count)
|
|
116
|
+
buf = +""
|
|
117
|
+
while buf.bytesize < count
|
|
118
|
+
chunk = sock.read(count - buf.bytesize)
|
|
119
|
+
raise RespError, "connection closed mid-reply" if chunk.nil? || chunk.empty?
|
|
120
|
+
buf << chunk
|
|
121
|
+
end
|
|
122
|
+
buf
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "json"
|
|
3
|
+
require_relative "resp_client"
|
|
3
4
|
|
|
4
5
|
module Tina4
|
|
5
6
|
module SessionHandlers
|
|
7
|
+
# Valkey-backed session handler. Valkey speaks the RESP protocol, so it works
|
|
8
|
+
# through the `redis` gem when installed (parity with Python/Node); otherwise
|
|
9
|
+
# it speaks raw RESP over a TCP socket via RespClient — zero dependencies.
|
|
10
|
+
# Same as RedisHandler but reads VALKEY-prefixed configuration variables.
|
|
6
11
|
class ValkeyHandler
|
|
7
12
|
def initialize(options = {})
|
|
8
|
-
require "redis"
|
|
9
13
|
@prefix = options[:prefix] || ENV["TINA4_SESSION_VALKEY_PREFIX"] || "tina4:session:"
|
|
10
14
|
@ttl = options[:ttl] || (ENV["TINA4_SESSION_VALKEY_TTL"] ? ENV["TINA4_SESSION_VALKEY_TTL"].to_i : 86400)
|
|
11
|
-
@
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
rescue LoadError
|
|
18
|
-
raise "Valkey session handler requires the 'redis' gem (Valkey uses the RESP protocol). Install with: gem install redis"
|
|
15
|
+
@host = options[:host] || ENV["TINA4_SESSION_VALKEY_HOST"] || "localhost"
|
|
16
|
+
@port = options[:port] || (ENV["TINA4_SESSION_VALKEY_PORT"] ? ENV["TINA4_SESSION_VALKEY_PORT"].to_i : 6379)
|
|
17
|
+
@db = options[:db] || (ENV["TINA4_SESSION_VALKEY_DB"] ? ENV["TINA4_SESSION_VALKEY_DB"].to_i : 0)
|
|
18
|
+
@password = options[:password] || ENV["TINA4_SESSION_VALKEY_PASSWORD"]
|
|
19
|
+
@redis = build_gem_client
|
|
20
|
+
@resp = @redis ? nil : RespClient.new(host: @host, port: @port, password: @password, db: @db)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def read(session_id)
|
|
22
|
-
|
|
24
|
+
key = "#{@prefix}#{session_id}"
|
|
25
|
+
data = @redis ? @redis.get(key) : @resp.get(key)
|
|
23
26
|
return nil unless data
|
|
24
27
|
JSON.parse(data)
|
|
25
28
|
rescue JSON::ParserError
|
|
@@ -28,16 +31,34 @@ module Tina4
|
|
|
28
31
|
|
|
29
32
|
def write(session_id, data)
|
|
30
33
|
key = "#{@prefix}#{session_id}"
|
|
31
|
-
|
|
34
|
+
payload = JSON.generate(data)
|
|
35
|
+
if @redis
|
|
36
|
+
@redis.setex(key, @ttl, payload)
|
|
37
|
+
else
|
|
38
|
+
@resp.setex(key, @ttl, payload)
|
|
39
|
+
end
|
|
32
40
|
end
|
|
33
41
|
|
|
34
42
|
def destroy(session_id)
|
|
35
|
-
|
|
43
|
+
key = "#{@prefix}#{session_id}"
|
|
44
|
+
@redis ? @redis.del(key) : @resp.del(key)
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
def cleanup
|
|
39
48
|
# Valkey handles TTL automatically (same as Redis)
|
|
40
49
|
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Use the official `redis` gem (RESP-compatible with Valkey) only when the
|
|
54
|
+
# REAL gem is loadable; otherwise nil so the caller falls back to raw RESP.
|
|
55
|
+
def build_gem_client
|
|
56
|
+
require "redis"
|
|
57
|
+
return nil unless defined?(::Redis::VERSION)
|
|
58
|
+
Redis.new(host: @host, port: @port, db: @db, password: @password)
|
|
59
|
+
rescue LoadError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
41
62
|
end
|
|
42
63
|
end
|
|
43
64
|
end
|
data/lib/tina4/version.rb
CHANGED
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.44
|
|
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-06-
|
|
11
|
+
date: 2026-06-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -192,6 +192,34 @@ dependencies:
|
|
|
192
192
|
- - "~>"
|
|
193
193
|
- !ruby/object:Gem::Version
|
|
194
194
|
version: '1.5'
|
|
195
|
+
- !ruby/object:Gem::Dependency
|
|
196
|
+
name: rdkafka
|
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
|
198
|
+
requirements:
|
|
199
|
+
- - "~>"
|
|
200
|
+
- !ruby/object:Gem::Version
|
|
201
|
+
version: '0.20'
|
|
202
|
+
type: :development
|
|
203
|
+
prerelease: false
|
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
205
|
+
requirements:
|
|
206
|
+
- - "~>"
|
|
207
|
+
- !ruby/object:Gem::Version
|
|
208
|
+
version: '0.20'
|
|
209
|
+
- !ruby/object:Gem::Dependency
|
|
210
|
+
name: bunny
|
|
211
|
+
requirement: !ruby/object:Gem::Requirement
|
|
212
|
+
requirements:
|
|
213
|
+
- - "~>"
|
|
214
|
+
- !ruby/object:Gem::Version
|
|
215
|
+
version: '2.22'
|
|
216
|
+
type: :development
|
|
217
|
+
prerelease: false
|
|
218
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
219
|
+
requirements:
|
|
220
|
+
- - "~>"
|
|
221
|
+
- !ruby/object:Gem::Version
|
|
222
|
+
version: '2.22'
|
|
195
223
|
- !ruby/object:Gem::Dependency
|
|
196
224
|
name: rake
|
|
197
225
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -373,6 +401,7 @@ files:
|
|
|
373
401
|
- lib/tina4/session_handlers/file_handler.rb
|
|
374
402
|
- lib/tina4/session_handlers/mongo_handler.rb
|
|
375
403
|
- lib/tina4/session_handlers/redis_handler.rb
|
|
404
|
+
- lib/tina4/session_handlers/resp_client.rb
|
|
376
405
|
- lib/tina4/session_handlers/valkey_handler.rb
|
|
377
406
|
- lib/tina4/shutdown.rb
|
|
378
407
|
- lib/tina4/sql_translation.rb
|