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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 209245b1743578b21530e31feb670940f068cc911725fff1b2a59ee6d8335e10
4
- data.tar.gz: '059d0fde8d4fbc57db9e4aa408d0dd31f71b1c8065d5041258a8a502017ce7ab'
3
+ metadata.gz: 2a015bff2d92f110b5a0f8646ae2491dada678055a5f4f3fe8d842c41215e86f
4
+ data.tar.gz: 1ae42fcc29b11dd6f2ad94fb510d3b0946c5b3b396c0c1977786aa195f5e2e14
5
5
  SHA512:
6
- metadata.gz: 30babc583e40444ec46069088e1af2a94f0485546b585530ccda22cca2a8ba29fafca3464bd08845fd1d9f88e92342533f2c58a82b89f38a517fce607096f905
7
- data.tar.gz: 84960f5d44cb50d7ec4d8dd31a6645e10640526aaf6c9f777e81c7376a43290a6027d33d903f45a317f369834eee59518ec9d215d05e8933c820e5d3020ae49e
6
+ metadata.gz: 429e4eeef534f632cff87ef296396c1c86e839dfbe6b66c86bf90944332ee8168ab21fd86e0d07748546a3b8383f01e8a38d5f3a56d2cc14e33d34d8b2780f0e
7
+ data.tar.gz: 912729e68df830558d8df751236513443ba8d63eec8f4a28e95deaa86b137b2334476461bf551e5f7daa2d6c52d95b029881150eb2b5efb8b11aab3b4fe1e782
@@ -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 { success: true, affected_rows: 0 } if data.empty?
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
- results = []
665
+ params_list ||= []
666
+ already_pinned = !Thread.current[@tx_pin_key].nil?
620
667
  drv = current_driver
621
- drv.begin_transaction
668
+ Thread.current[@tx_pin_key] = drv unless already_pinned
669
+
622
670
  begin
623
- params_list.each do |params|
624
- results << drv.execute(sql, params)
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
- drv.commit
627
- rescue => e
628
- drv.rollback
629
- raise e
680
+ ensure
681
+ Thread.current[@tx_pin_key] = nil unless already_pinned
630
682
  end
631
- results
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
@@ -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
- File.write(message_path(msg_id), JSON.pretty_generate(message))
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
- result = @connection.execute("SELECT SCOPE_IDENTITY() AS id")
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: uri.host || "localhost",
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
- if params.empty?
46
- @connection.query(sql)
47
- else
48
- stmt = @connection.prepare(sql)
49
- stmt.execute(*params)
50
- end
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
- @connection.last_id
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
@@ -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
- id INTEGER PRIMARY KEY,
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
- executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
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
- unless @subscribed_topics.include?(topic)
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
- msg = @consumer.poll(1000)
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
- @client = Mongo::Client.new(uri)
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
- delivery_info, _properties, payload = queue.pop
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
- def acknowledge(_message)
44
- @channel.acknowledge(@last_delivery_tag) if @last_delivery_tag
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
- # Ensure TTL index
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
- @redis = Redis.new(
12
- host: options[:host] || "localhost",
13
- port: options[:port] || 6379,
14
- db: options[:db] || 0,
15
- password: options[:password]
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
- data = @redis.get("#{@prefix}#{session_id}")
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
- @redis.setex(key, @ttl, JSON.generate(data))
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
- @redis.del("#{@prefix}#{session_id}")
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
- @redis = Redis.new(
12
- host: options[:host] || ENV["TINA4_SESSION_VALKEY_HOST"] || "localhost",
13
- port: options[:port] || (ENV["TINA4_SESSION_VALKEY_PORT"] ? ENV["TINA4_SESSION_VALKEY_PORT"].to_i : 6379),
14
- db: options[:db] || (ENV["TINA4_SESSION_VALKEY_DB"] ? ENV["TINA4_SESSION_VALKEY_DB"].to_i : 0),
15
- password: options[:password] || ENV["TINA4_SESSION_VALKEY_PASSWORD"]
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
- data = @redis.get("#{@prefix}#{session_id}")
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
- @redis.setex(key, @ttl, JSON.generate(data))
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
- @redis.del("#{@prefix}#{session_id}")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.43"
4
+ VERSION = "3.13.44"
5
5
  end
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.43
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-22 00:00:00.000000000 Z
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