tina4ruby 3.13.37 → 3.13.39
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/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +110 -2
- data/lib/tina4/database.rb +407 -52
- data/lib/tina4/dev_admin.rb +47 -14
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +35 -8
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +351 -73
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +113 -24
- data/lib/tina4/orm.rb +196 -32
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +458 -21
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +91 -12
- metadata +6 -47
data/lib/tina4/database.rb
CHANGED
|
@@ -199,19 +199,32 @@ module Tina4
|
|
|
199
199
|
# commit/rollback clear it. While pinned, current_driver returns the same
|
|
200
200
|
# driver for every call so the whole transaction runs on one connection.
|
|
201
201
|
@tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
|
|
202
|
-
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
202
|
+
# Per-thread nested-transaction depth counter (DB-contract C, v3.13.37).
|
|
203
|
+
# A second start_transaction on a thread that already holds the pin is a
|
|
204
|
+
# double-begin: most engines silently commit or no-op the inner BEGIN,
|
|
205
|
+
# leaving the connection mid-transaction. We warn + increment depth instead
|
|
206
|
+
# of re-beginning; the inner commit just decrements; the outer commit/any
|
|
207
|
+
# rollback releases the pin.
|
|
208
|
+
@tx_depth_key = :"tina4_tx_depth_#{object_id}"
|
|
209
|
+
|
|
210
|
+
# Query cache. One store, two layers (parity with Python connection.py).
|
|
211
|
+
# BOTH layers are OPT-IN — the DEFAULT is OFF.
|
|
212
|
+
#
|
|
213
|
+
# A request-scoped cache that defaults ON is a footgun: a SELECT MAX(id)
|
|
214
|
+
# (or generator read) right before an INSERT in the SAME request returns a
|
|
215
|
+
# cached pre-write value → duplicate primary keys, and any read-after-write
|
|
216
|
+
# in one request shows stale state. So both layers default OFF:
|
|
217
|
+
# • request-scoped (opt-in, TINA4_AUTO_CACHING=true) — dedupes identical
|
|
218
|
+
# SELECTs to protect the DB from rapid repeat reads on read-heavy
|
|
219
|
+
# endpoints. Cleared at the START of every HTTP request (so it never
|
|
220
|
+
# serves rows across requests) AND on any write, with a short safety
|
|
221
|
+
# TTL (5s) for non-request contexts (scripts/workers).
|
|
209
222
|
# • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache
|
|
210
223
|
# that is NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL.
|
|
211
224
|
@cache_persistent = truthy?(ENV["TINA4_DB_CACHE"])
|
|
212
|
-
# Default
|
|
213
|
-
# (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "
|
|
214
|
-
@cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "
|
|
225
|
+
# Default OFF; honour the same truthy semantics the framework uses
|
|
226
|
+
# (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "false"))).
|
|
227
|
+
@cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "false")
|
|
215
228
|
@cache_enabled = @cache_persistent || @cache_request_scoped
|
|
216
229
|
@cache_ttl = if @cache_persistent
|
|
217
230
|
(ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
@@ -242,6 +255,17 @@ module Tina4
|
|
|
242
255
|
end
|
|
243
256
|
end
|
|
244
257
|
|
|
258
|
+
# Autocommit is ON by default — parity with Python/PHP/Node. A standalone
|
|
259
|
+
# write (execute/insert/update/delete made OUTSIDE an explicit
|
|
260
|
+
# start_transaction()/commit() block) commits on its own connection before
|
|
261
|
+
# returning, so a write actually persists. An UNSET TINA4_AUTOCOMMIT is
|
|
262
|
+
# treated as TRUE; set TINA4_AUTOCOMMIT=false for strict manual mode (every
|
|
263
|
+
# write needs an explicit commit). Inside an explicit transaction the
|
|
264
|
+
# framework-issued commit is suppressed (gated on the thread tx-pin), so
|
|
265
|
+
# explicit transactions stay atomic. Mirrors Python's
|
|
266
|
+
# DatabaseAdapter._autocommit ("true"/"1"/"yes", default "true").
|
|
267
|
+
@autocommit = truthy?(ENV.fetch("TINA4_AUTOCOMMIT", "true"))
|
|
268
|
+
|
|
245
269
|
# Register this connection so Tina4::Database.reset_request_caches can
|
|
246
270
|
# clear its request-scoped entries at the start of every HTTP request.
|
|
247
271
|
Tina4::Database.register_instance(self)
|
|
@@ -269,10 +293,11 @@ module Tina4
|
|
|
269
293
|
@driver.connect(@connection_string, username: @username, password: @password)
|
|
270
294
|
@connected = true
|
|
271
295
|
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
296
|
+
# Push the resolved autocommit setting down to the driver when it exposes a
|
|
297
|
+
# native toggle (default ON — see @autocommit in #initialize). The
|
|
298
|
+
# framework-level commit in #autocommit_standalone_write covers drivers
|
|
299
|
+
# that have no native setter.
|
|
300
|
+
@driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
|
|
276
301
|
|
|
277
302
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
278
303
|
rescue => e
|
|
@@ -374,6 +399,14 @@ module Tina4
|
|
|
374
399
|
|
|
375
400
|
# Fetch rows with pagination, returning a DatabaseResult.
|
|
376
401
|
#
|
|
402
|
+
# FAILS LOUD (v3.13.37, DB-contract A): a SQL error in the main query
|
|
403
|
+
# propagates — a typo'd / bad SELECT RAISES, it never silently returns an
|
|
404
|
+
# empty result. The cause is captured on @last_error / #get_error before the
|
|
405
|
+
# re-raise (parity with #execute and the Python master), so the public API
|
|
406
|
+
# can read why it failed even for engines whose driver doesn't expose its
|
|
407
|
+
# own last_error. Because the raise happens BEFORE cache_set is reached, a
|
|
408
|
+
# buried failure is never written into the query cache.
|
|
409
|
+
#
|
|
377
410
|
# Pass `no_cache: true` to bypass the query cache entirely for this single
|
|
378
411
|
# call — no lookup, no store — and run the query directly against the
|
|
379
412
|
# driver. Works for both the request-scoped auto-cache and the persistent
|
|
@@ -404,22 +437,30 @@ module Tina4
|
|
|
404
437
|
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
405
438
|
return cached
|
|
406
439
|
end
|
|
407
|
-
|
|
408
|
-
|
|
440
|
+
# fetch_direct RAISES on a SQL error (and captures @last_error), so a
|
|
441
|
+
# failed read never reaches cache_set below — we never cache an empty
|
|
442
|
+
# result produced by a buried failure.
|
|
443
|
+
result = fetch_direct(drv, effective_sql, params)
|
|
409
444
|
cache_set(key, result)
|
|
410
445
|
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
411
446
|
return result
|
|
412
447
|
end
|
|
413
448
|
|
|
414
|
-
|
|
415
|
-
Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
|
|
449
|
+
fetch_direct(drv, effective_sql, params)
|
|
416
450
|
end
|
|
417
451
|
|
|
418
452
|
# Fetch a single row (or nil).
|
|
419
453
|
#
|
|
454
|
+
# FAILS LOUD (v3.13.37, DB-contract A): a SQL error RAISES and populates
|
|
455
|
+
# @last_error / #get_error the same way #execute and #fetch do — pre-fix
|
|
456
|
+
# fetch_one ran the query through #fetch but did not separately guarantee the
|
|
457
|
+
# error capture, and a buried failure could be cached as nil. It now routes
|
|
458
|
+
# the uncached path through fetch_one_direct (capture + re-raise) and, on the
|
|
459
|
+
# cached path, only ever stores a value produced by a SUCCESSFUL read.
|
|
460
|
+
#
|
|
420
461
|
# Pass `no_cache: true` to bypass the query cache entirely for this call —
|
|
421
462
|
# no lookup, no store — running the query directly. The `no_cache` flag is
|
|
422
|
-
# propagated to the inner
|
|
463
|
+
# propagated to the inner read so the request-scoped/persistent cache is
|
|
423
464
|
# never populated either. Default `false` preserves cached behaviour.
|
|
424
465
|
def fetch_one(sql, params = [], no_cache: false)
|
|
425
466
|
sql = Tina4::Database.strip_trailing_semicolons(sql)
|
|
@@ -430,15 +471,15 @@ module Tina4
|
|
|
430
471
|
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
431
472
|
return cached
|
|
432
473
|
end
|
|
433
|
-
|
|
434
|
-
|
|
474
|
+
# Raises (and captures @last_error) BEFORE cache_set, so a failed read
|
|
475
|
+
# is never cached as nil.
|
|
476
|
+
value = fetch_one_direct(sql, params)
|
|
435
477
|
cache_set(key, value)
|
|
436
478
|
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
437
479
|
return value
|
|
438
480
|
end
|
|
439
481
|
|
|
440
|
-
|
|
441
|
-
result.first
|
|
482
|
+
fetch_one_direct(sql, params)
|
|
442
483
|
end
|
|
443
484
|
|
|
444
485
|
def insert(table, data)
|
|
@@ -459,7 +500,9 @@ module Tina4
|
|
|
459
500
|
placeholders = drv.placeholders(columns.length)
|
|
460
501
|
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
461
502
|
drv.execute(sql, data.values)
|
|
462
|
-
|
|
503
|
+
last_id = drv.last_insert_id
|
|
504
|
+
autocommit_standalone_write(drv)
|
|
505
|
+
{ success: true, last_id: last_id }
|
|
463
506
|
end
|
|
464
507
|
|
|
465
508
|
def update(table, data, filter = {}, params = nil)
|
|
@@ -472,6 +515,7 @@ module Tina4
|
|
|
472
515
|
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
473
516
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
474
517
|
drv.execute(sql, data.values + Array(params))
|
|
518
|
+
autocommit_standalone_write(drv)
|
|
475
519
|
return { success: true }
|
|
476
520
|
end
|
|
477
521
|
|
|
@@ -481,6 +525,7 @@ module Tina4
|
|
|
481
525
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
482
526
|
values = data.values + filter.values
|
|
483
527
|
drv.execute(sql, values)
|
|
528
|
+
autocommit_standalone_write(drv)
|
|
484
529
|
{ success: true }
|
|
485
530
|
end
|
|
486
531
|
|
|
@@ -499,6 +544,7 @@ module Tina4
|
|
|
499
544
|
sql = "DELETE FROM #{table}"
|
|
500
545
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
501
546
|
drv.execute(sql, Array(params))
|
|
547
|
+
autocommit_standalone_write(drv)
|
|
502
548
|
return { success: true }
|
|
503
549
|
end
|
|
504
550
|
|
|
@@ -507,6 +553,7 @@ module Tina4
|
|
|
507
553
|
sql = "DELETE FROM #{table}"
|
|
508
554
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
509
555
|
drv.execute(sql, filter.values)
|
|
556
|
+
autocommit_standalone_write(drv)
|
|
510
557
|
{ success: true }
|
|
511
558
|
end
|
|
512
559
|
|
|
@@ -534,12 +581,29 @@ module Tina4
|
|
|
534
581
|
@driver_name
|
|
535
582
|
end
|
|
536
583
|
|
|
537
|
-
# Execute a write statement.
|
|
538
|
-
#
|
|
584
|
+
# Execute a write statement. FAILS LOUD — raises on a SQL error.
|
|
585
|
+
#
|
|
586
|
+
# On a SQL error (bad SQL, constraint violation, dead/aborted connection,
|
|
587
|
+
# missing driver) the cause is captured on @last_error / #get_error AND the
|
|
588
|
+
# error is re-raised — execute() never silently returns false on failure.
|
|
589
|
+
# Almost no caller checks a boolean after every write, so the old
|
|
590
|
+
# swallow-and-return-false behaviour turned a failed INSERT/UPDATE/DELETE
|
|
591
|
+
# into a silent partial-write footgun. This mirrors fetch()/fetch_one(),
|
|
592
|
+
# which already raise, and the Python master (database.execute).
|
|
593
|
+
#
|
|
594
|
+
# On SUCCESS the return is unchanged: a DatabaseResult when the SQL contains
|
|
595
|
+
# RETURNING, CALL, EXEC, or SELECT (truthy), otherwise true. Never false.
|
|
596
|
+
#
|
|
597
|
+
# Higher-level callers that promise a boolean (ORM save/create_table) wrap
|
|
598
|
+
# this in begin/rescue and return false themselves; the migration runner and
|
|
599
|
+
# dev-admin/MCP DB tools catch the raise and surface it as a failed migration
|
|
600
|
+
# or a clean { error: } payload respectively.
|
|
539
601
|
def execute(sql, params = [])
|
|
540
602
|
cache_invalidate if @cache_enabled
|
|
541
|
-
|
|
603
|
+
drv = current_driver
|
|
604
|
+
result = drv.execute(sql, params)
|
|
542
605
|
@last_error = nil
|
|
606
|
+
autocommit_standalone_write(drv)
|
|
543
607
|
sql_upper = sql.strip.upcase
|
|
544
608
|
if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
|
|
545
609
|
sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
|
|
@@ -548,7 +612,7 @@ module Tina4
|
|
|
548
612
|
true
|
|
549
613
|
rescue => e
|
|
550
614
|
@last_error = e.message
|
|
551
|
-
|
|
615
|
+
raise
|
|
552
616
|
end
|
|
553
617
|
|
|
554
618
|
def execute_many(sql, params_list = [])
|
|
@@ -570,6 +634,7 @@ module Tina4
|
|
|
570
634
|
def transaction
|
|
571
635
|
drv = current_driver
|
|
572
636
|
Thread.current[@tx_pin_key] = drv
|
|
637
|
+
Thread.current[@tx_depth_key] = 1
|
|
573
638
|
drv.begin_transaction
|
|
574
639
|
yield self
|
|
575
640
|
drv.commit
|
|
@@ -578,29 +643,83 @@ module Tina4
|
|
|
578
643
|
raise e
|
|
579
644
|
ensure
|
|
580
645
|
Thread.current[@tx_pin_key] = nil
|
|
646
|
+
Thread.current[@tx_depth_key] = nil
|
|
581
647
|
end
|
|
582
648
|
|
|
583
649
|
# Begin a transaction without a block — matches PHP/Python/Node API.
|
|
584
650
|
# Pins the driver to this thread for the whole transaction so executes
|
|
585
651
|
# and the final commit/rollback all run on the same connection.
|
|
652
|
+
#
|
|
653
|
+
# Nested-begin guard (v3.13.37, DB-contract C): a second start_transaction
|
|
654
|
+
# on a thread that already holds the pin is a double-begin — the inner BEGIN
|
|
655
|
+
# silently commits or no-ops on most engines, leaving the connection
|
|
656
|
+
# mid-transaction with the caller none the wiser. We keep a per-thread depth
|
|
657
|
+
# counter and log a clear warning instead of silently re-beginning. The pin
|
|
658
|
+
# stays on the original driver so commit/rollback still land on the right
|
|
659
|
+
# connection.
|
|
586
660
|
def start_transaction
|
|
661
|
+
pinned = Thread.current[@tx_pin_key]
|
|
662
|
+
if pinned
|
|
663
|
+
depth = (Thread.current[@tx_depth_key] || 1)
|
|
664
|
+
Tina4::Log.warning(
|
|
665
|
+
"start_transaction called while a transaction is already open on this " \
|
|
666
|
+
"thread (depth would become #{depth + 1}). Nested transactions are not " \
|
|
667
|
+
"supported — the existing transaction stays open on its pinned " \
|
|
668
|
+
"connection and this nested begin is ignored. Commit or rollback the " \
|
|
669
|
+
"outer transaction first."
|
|
670
|
+
)
|
|
671
|
+
Thread.current[@tx_depth_key] = depth + 1
|
|
672
|
+
return
|
|
673
|
+
end
|
|
587
674
|
drv = current_driver
|
|
588
675
|
Thread.current[@tx_pin_key] = drv
|
|
676
|
+
Thread.current[@tx_depth_key] = 1
|
|
589
677
|
drv.begin_transaction
|
|
590
678
|
end
|
|
591
679
|
|
|
592
680
|
# Commit the current transaction and release the driver pin.
|
|
681
|
+
#
|
|
682
|
+
# FAILS LOUD (v3.13.37, DB-contract C): if the underlying commit raises,
|
|
683
|
+
# capture @last_error and RE-RAISE — never swallow. On failure the
|
|
684
|
+
# transaction pin is RETAINED so the caller's follow-up #rollback lands on
|
|
685
|
+
# the SAME connection (clearing it would leak a dirty connection back into
|
|
686
|
+
# the pool and route the rollback to a different one). The pin is cleared
|
|
687
|
+
# ONLY on a successful commit. An inner commit of an ignored nested begin
|
|
688
|
+
# (depth > 1) just decrements the depth and returns — the outer commit is
|
|
689
|
+
# the real one.
|
|
593
690
|
def commit
|
|
691
|
+
depth = (Thread.current[@tx_depth_key] || 0)
|
|
692
|
+
if depth > 1
|
|
693
|
+
Thread.current[@tx_depth_key] = depth - 1
|
|
694
|
+
return
|
|
695
|
+
end
|
|
594
696
|
current_driver.commit
|
|
595
|
-
|
|
697
|
+
@last_error = nil
|
|
698
|
+
# Success — release the pin.
|
|
596
699
|
Thread.current[@tx_pin_key] = nil
|
|
700
|
+
Thread.current[@tx_depth_key] = nil
|
|
701
|
+
rescue => e
|
|
702
|
+
# Keep the pin so rollback reaches this same connection.
|
|
703
|
+
@last_error = e.message
|
|
704
|
+
raise
|
|
597
705
|
end
|
|
598
706
|
|
|
599
707
|
# Roll back the current transaction and release the driver pin.
|
|
708
|
+
#
|
|
709
|
+
# Rollback is the terminal cleanup of a transaction, so it ALWAYS clears the
|
|
710
|
+
# pin (and the depth counter) — even after a failed commit it routes to the
|
|
711
|
+
# retained pinned connection and cleans it up. If the underlying rollback
|
|
712
|
+
# itself raises, @last_error is captured and the error re-raised, but the pin
|
|
713
|
+
# is still released so a poisoned connection doesn't stay pinned forever.
|
|
600
714
|
def rollback
|
|
601
715
|
current_driver.rollback
|
|
716
|
+
@last_error = nil
|
|
717
|
+
rescue => e
|
|
718
|
+
@last_error = e.message
|
|
719
|
+
raise
|
|
602
720
|
ensure
|
|
603
721
|
Thread.current[@tx_pin_key] = nil
|
|
722
|
+
Thread.current[@tx_depth_key] = nil
|
|
604
723
|
end
|
|
605
724
|
|
|
606
725
|
def tables
|
|
@@ -733,6 +852,46 @@ module Tina4
|
|
|
733
852
|
|
|
734
853
|
private
|
|
735
854
|
|
|
855
|
+
# Run a fetch straight against the driver — no cache lookup or store.
|
|
856
|
+
#
|
|
857
|
+
# DB-contract A (v3.13.37): shared by the cached and no_cache paths so error
|
|
858
|
+
# capture is identical regardless of caching. FAILS LOUD: a SQL error in the
|
|
859
|
+
# main query propagates (same contract as #execute). The cause is captured on
|
|
860
|
+
# @last_error for #get_error before the re-raise — preferring the driver's own
|
|
861
|
+
# last_error (when it exposes one, e.g. postgres) over the exception message.
|
|
862
|
+
def fetch_direct(drv, effective_sql, params)
|
|
863
|
+
result = drv.execute_query(effective_sql, params)
|
|
864
|
+
@last_error = nil
|
|
865
|
+
Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
|
|
866
|
+
rescue => e
|
|
867
|
+
@last_error = driver_error_message(drv, e)
|
|
868
|
+
raise
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Run a fetch_one straight against the driver — no cache lookup or store.
|
|
872
|
+
#
|
|
873
|
+
# DB-contract A (v3.13.37): goes through #fetch (so the trailing-semicolon
|
|
874
|
+
# strip + LIMIT-append + driver path stay identical), but wraps it so the
|
|
875
|
+
# error is captured on @last_error and re-raised. Returns the first row (a
|
|
876
|
+
# Hash) or nil on a SUCCESSFUL "no row" read.
|
|
877
|
+
def fetch_one_direct(sql, params)
|
|
878
|
+
result = fetch(sql, params, limit: 1, no_cache: true)
|
|
879
|
+
@last_error = nil
|
|
880
|
+
result.first
|
|
881
|
+
rescue => e
|
|
882
|
+
@last_error = driver_error_message(current_driver, e)
|
|
883
|
+
raise
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
# Prefer the driver's own last_error (postgres sets one) over str(e), so the
|
|
887
|
+
# captured message matches what each engine surfaces; never blank.
|
|
888
|
+
def driver_error_message(drv, error)
|
|
889
|
+
drv_err = drv.respond_to?(:last_error) ? drv.last_error : nil
|
|
890
|
+
msg = drv_err || error.message
|
|
891
|
+
msg = error.message if msg.nil? || msg.to_s.empty?
|
|
892
|
+
msg || @last_error
|
|
893
|
+
end
|
|
894
|
+
|
|
736
895
|
# Ensure the tina4_sequences table exists for race-safe ID generation.
|
|
737
896
|
def ensure_sequence_table
|
|
738
897
|
return if table_exists?("tina4_sequences")
|
|
@@ -747,41 +906,212 @@ module Tina4
|
|
|
747
906
|
end
|
|
748
907
|
|
|
749
908
|
# Atomically increment and return the next value for a named sequence.
|
|
750
|
-
#
|
|
909
|
+
#
|
|
910
|
+
# DB-contract B (v3.13.37): the old read-increment-read path had a RACE —
|
|
911
|
+
# two concurrent callers could read the same current_value and return the
|
|
912
|
+
# same id (duplicate primary keys). Each engine now uses a single atomic
|
|
913
|
+
# increment-and-return, pinned to ONE driver so the two statements (where two
|
|
914
|
+
# are needed) land on the same connection:
|
|
915
|
+
#
|
|
916
|
+
# * SQLite (lib >= 3.35): UPDATE ... SET current_value = current_value + 1
|
|
917
|
+
# WHERE seq_name = ? RETURNING current_value — one atomic statement, run
|
|
918
|
+
# under the process-wide SqliteDriver.write_lock. Older SQLite falls back
|
|
919
|
+
# to UPDATE +1 then SELECT, still serialised by the held write_lock.
|
|
920
|
+
# * MySQL: UPDATE ... SET current_value = LAST_INSERT_ID(current_value + 1)
|
|
921
|
+
# then SELECT LAST_INSERT_ID() on the SAME connection (per-connection →
|
|
922
|
+
# race-safe).
|
|
923
|
+
# * MSSQL: UPDATE ... SET current_value += 1 OUTPUT inserted.current_value
|
|
924
|
+
# WHERE seq_name = ? — one atomic statement.
|
|
925
|
+
#
|
|
926
|
+
# Seeding is race-safe: an atomic insert-if-absent (INSERT OR IGNORE /
|
|
927
|
+
# INSERT IGNORE / INSERT ... WHERE NOT EXISTS) seeded from MAX(pk) runs BEFORE
|
|
928
|
+
# the atomic increment, so there is never a read-then-insert gap. On error we
|
|
929
|
+
# RAISE (never silently fall back to 1).
|
|
751
930
|
def sequence_next(seq_name, table: nil, pk_column: "id")
|
|
752
|
-
|
|
931
|
+
# Pin a single driver for the whole sequence op so seed + increment + read
|
|
932
|
+
# all hit the SAME connection. Inside an active transaction the driver is
|
|
933
|
+
# already pinned; otherwise pin here and release in the ensure so the pool
|
|
934
|
+
# can rotate afterwards.
|
|
935
|
+
already_pinned = !Thread.current[@tx_pin_key].nil?
|
|
753
936
|
drv = current_driver
|
|
937
|
+
Thread.current[@tx_pin_key] = drv unless already_pinned
|
|
754
938
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
939
|
+
begin
|
|
940
|
+
case @driver_name
|
|
941
|
+
when "sqlite"
|
|
942
|
+
# SQLite does ensure-table + seed + increment all under the driver
|
|
943
|
+
# write lock (a single shared connection — concurrent reads/writes on
|
|
944
|
+
# it otherwise corrupt or race).
|
|
945
|
+
sequence_next_sqlite(drv, seq_name, table, pk_column)
|
|
946
|
+
when "mysql"
|
|
947
|
+
ensure_sequence_table
|
|
948
|
+
sequence_next_mysql(drv, seq_name, table, pk_column)
|
|
949
|
+
when "mssql"
|
|
950
|
+
ensure_sequence_table
|
|
951
|
+
sequence_next_mssql(drv, seq_name, table, pk_column)
|
|
952
|
+
else
|
|
953
|
+
# Any other engine routed here (defensive) — generic atomic-ish path.
|
|
954
|
+
ensure_sequence_table
|
|
955
|
+
sequence_next_generic(drv, seq_name, table, pk_column)
|
|
956
|
+
end
|
|
957
|
+
ensure
|
|
958
|
+
Thread.current[@tx_pin_key] = nil unless already_pinned
|
|
959
|
+
end
|
|
960
|
+
end
|
|
758
961
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
962
|
+
# Best-effort MAX(pk) seed for a new sequence row. 0 if table missing/empty.
|
|
963
|
+
def sequence_seed_value(drv, table, pk_column)
|
|
964
|
+
return 0 unless table
|
|
965
|
+
|
|
966
|
+
max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
|
|
967
|
+
max_row = max_rows.is_a?(Array) ? max_rows.first : nil
|
|
968
|
+
val = row_value(max_row, :max_id)
|
|
969
|
+
val ? val.to_i : 0
|
|
970
|
+
rescue StandardError
|
|
971
|
+
0 # Table doesn't exist yet — start at 0
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# SQLite atomic increment. Holds the process-wide write lock for the ENTIRE
|
|
975
|
+
# op (ensure-table + seed + increment). The single UPDATE ... RETURNING (lib
|
|
976
|
+
# >= 3.35) is itself atomic; the held lock serialises every connection touch
|
|
977
|
+
# so no duplicate ids under concurrency. ensure-table is done inline under the
|
|
978
|
+
# lock (NOT via ensure_sequence_table, which would re-enter current_driver/
|
|
979
|
+
# table_exists? and risk a nested touch).
|
|
980
|
+
def sequence_next_sqlite(drv, seq_name, table, pk_column)
|
|
981
|
+
conn = drv.respond_to?(:connection) ? drv.connection : nil
|
|
982
|
+
raise "get_next_id: SQLite driver has no live connection" if conn.nil?
|
|
983
|
+
|
|
984
|
+
Tina4::Drivers::SqliteDriver.write_lock.synchronize do
|
|
985
|
+
# Ensure the sequence table exists (idempotent) on this connection.
|
|
986
|
+
conn.execute(
|
|
987
|
+
"CREATE TABLE IF NOT EXISTS tina4_sequences (" \
|
|
988
|
+
"seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " \
|
|
989
|
+
"current_value INTEGER NOT NULL DEFAULT 0)"
|
|
990
|
+
)
|
|
991
|
+
seed = sequence_seed_value(drv, table, pk_column)
|
|
992
|
+
conn.execute(
|
|
993
|
+
"INSERT OR IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
|
|
994
|
+
[seq_name, seed]
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
if sqlite_supports_returning?
|
|
998
|
+
# One atomic increment-and-return.
|
|
999
|
+
rows = conn.execute(
|
|
1000
|
+
"UPDATE tina4_sequences SET current_value = current_value + 1 " \
|
|
1001
|
+
"WHERE seq_name = ? RETURNING current_value",
|
|
1002
|
+
[seq_name]
|
|
1003
|
+
)
|
|
1004
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
1005
|
+
else
|
|
1006
|
+
# Older SQLite (< 3.35, no RETURNING): increment then read. Still
|
|
1007
|
+
# race-safe because we hold write_lock across both statements.
|
|
1008
|
+
conn.execute(
|
|
1009
|
+
"UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?",
|
|
1010
|
+
[seq_name]
|
|
1011
|
+
)
|
|
1012
|
+
rows = conn.execute(
|
|
1013
|
+
"SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
|
|
1014
|
+
[seq_name]
|
|
1015
|
+
)
|
|
1016
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
771
1017
|
end
|
|
772
|
-
|
|
773
|
-
|
|
1018
|
+
|
|
1019
|
+
val = sqlite_row_value(row, "current_value")
|
|
1020
|
+
raise "get_next_id: sequence row '#{seq_name}' vanished mid-increment" if val.nil?
|
|
1021
|
+
|
|
1022
|
+
val.to_i
|
|
774
1023
|
end
|
|
1024
|
+
end
|
|
775
1025
|
|
|
776
|
-
|
|
777
|
-
|
|
1026
|
+
# MySQL atomic increment. LAST_INSERT_ID(expr) stashes expr in this
|
|
1027
|
+
# CONNECTION's session var and returns it — atomic per-connection, no
|
|
1028
|
+
# read-back race. Calls the driver directly (not self.commit) so it doesn't
|
|
1029
|
+
# trip Database#commit's pin management.
|
|
1030
|
+
def sequence_next_mysql(drv, seq_name, table, pk_column)
|
|
1031
|
+
seed = sequence_seed_value(drv, table, pk_column)
|
|
1032
|
+
# Race-safe seed: INSERT IGNORE is a no-op if the row exists.
|
|
1033
|
+
drv.execute("INSERT IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed])
|
|
1034
|
+
drv.commit rescue nil
|
|
1035
|
+
drv.execute(
|
|
1036
|
+
"UPDATE tina4_sequences SET current_value = LAST_INSERT_ID(current_value + 1) WHERE seq_name = ?",
|
|
1037
|
+
[seq_name]
|
|
1038
|
+
)
|
|
778
1039
|
drv.commit rescue nil
|
|
1040
|
+
rows = drv.execute_query("SELECT LAST_INSERT_ID() AS next_id")
|
|
1041
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
1042
|
+
val = row_value(row, :next_id)
|
|
1043
|
+
raise "get_next_id: LAST_INSERT_ID() returned nothing for '#{seq_name}'" if val.nil?
|
|
779
1044
|
|
|
780
|
-
|
|
1045
|
+
val.to_i
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
# MSSQL atomic increment via a single UPDATE ... OUTPUT statement.
|
|
1049
|
+
def sequence_next_mssql(drv, seq_name, table, pk_column)
|
|
1050
|
+
seed = sequence_seed_value(drv, table, pk_column)
|
|
1051
|
+
# Race-safe seed: INSERT only when absent (single statement).
|
|
1052
|
+
drv.execute(
|
|
1053
|
+
"INSERT INTO tina4_sequences (seq_name, current_value) " \
|
|
1054
|
+
"SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM tina4_sequences WHERE seq_name = ?)",
|
|
1055
|
+
[seq_name, seed, seq_name]
|
|
1056
|
+
)
|
|
1057
|
+
drv.commit rescue nil
|
|
1058
|
+
# Single atomic statement: increment + return the new value via OUTPUT.
|
|
1059
|
+
rows = drv.execute_query(
|
|
1060
|
+
"UPDATE tina4_sequences SET current_value = current_value + 1 " \
|
|
1061
|
+
"OUTPUT inserted.current_value AS next_id WHERE seq_name = ?",
|
|
1062
|
+
[seq_name]
|
|
1063
|
+
)
|
|
1064
|
+
drv.commit rescue nil
|
|
1065
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
1066
|
+
val = row_value(row, :next_id)
|
|
1067
|
+
raise "get_next_id: OUTPUT produced no row for sequence '#{seq_name}'" if val.nil?
|
|
1068
|
+
|
|
1069
|
+
val.to_i
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# Defensive fallback for any engine not otherwise special-cased: seed if
|
|
1073
|
+
# absent (rollback on conflict), increment, then read on the pinned driver.
|
|
1074
|
+
def sequence_next_generic(drv, seq_name, table, pk_column)
|
|
1075
|
+
seed = sequence_seed_value(drv, table, pk_column)
|
|
1076
|
+
begin
|
|
1077
|
+
drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed])
|
|
1078
|
+
drv.commit rescue nil
|
|
1079
|
+
rescue StandardError
|
|
1080
|
+
# Row likely already exists (PK conflict) — fine, keep going.
|
|
1081
|
+
drv.rollback rescue nil
|
|
1082
|
+
end
|
|
1083
|
+
drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
|
|
1084
|
+
drv.commit rescue nil
|
|
781
1085
|
rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
|
|
782
1086
|
row = rows.is_a?(Array) ? rows.first : nil
|
|
783
1087
|
val = row_value(row, :current_value)
|
|
784
|
-
|
|
1088
|
+
raise "get_next_id: sequence row '#{seq_name}' missing" if val.nil?
|
|
1089
|
+
|
|
1090
|
+
val.to_i
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# Whether the loaded SQLite library supports the RETURNING clause (>= 3.35).
|
|
1094
|
+
def sqlite_supports_returning?
|
|
1095
|
+
return @sqlite_returning unless @sqlite_returning.nil?
|
|
1096
|
+
|
|
1097
|
+
ver = (defined?(SQLite3::SQLITE_VERSION) && SQLite3::SQLITE_VERSION) || "0.0.0"
|
|
1098
|
+
parts = ver.split(".").map(&:to_i)
|
|
1099
|
+
major, minor = parts[0].to_i, parts[1].to_i
|
|
1100
|
+
@sqlite_returning = (major > 3) || (major == 3 && minor >= 35)
|
|
1101
|
+
rescue StandardError
|
|
1102
|
+
@sqlite_returning = false
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
# Read a value from a raw sqlite3 row (results_as_hash → string keys; a bare
|
|
1106
|
+
# RETURNING row may come back as a positional Array on some gem versions).
|
|
1107
|
+
def sqlite_row_value(row, key)
|
|
1108
|
+
return nil if row.nil?
|
|
1109
|
+
|
|
1110
|
+
if row.is_a?(Hash)
|
|
1111
|
+
row[key] || row[key.to_sym] || row.values.first
|
|
1112
|
+
elsif row.is_a?(Array)
|
|
1113
|
+
row.first
|
|
1114
|
+
end
|
|
785
1115
|
end
|
|
786
1116
|
|
|
787
1117
|
# Safely extract a value from a driver result row, trying both symbol and string keys.
|
|
@@ -794,6 +1124,31 @@ module Tina4
|
|
|
794
1124
|
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
795
1125
|
end
|
|
796
1126
|
|
|
1127
|
+
# Durability: commit a standalone write so it actually persists.
|
|
1128
|
+
#
|
|
1129
|
+
# Called after a write (execute/insert/update/delete) issued OUTSIDE an
|
|
1130
|
+
# explicit transaction. The commit is suppressed when autocommit is off
|
|
1131
|
+
# (TINA4_AUTOCOMMIT=false, strict manual mode) OR when a transaction is open
|
|
1132
|
+
# on this thread (the thread tx-pin is set) — so an explicit
|
|
1133
|
+
# start_transaction()/commit() block stays atomic and is never broken up by
|
|
1134
|
+
# a per-statement commit. A commit with no transaction in progress is a
|
|
1135
|
+
# harmless no-op on every engine (SQLite swallows the specific
|
|
1136
|
+
# "no transaction is active" error in its driver; PostgreSQL/MySQL/MSSQL emit
|
|
1137
|
+
# at most a benign warning), so this never raises in the common case. Mirrors
|
|
1138
|
+
# the `not self._in_transaction and self.autocommit` gate in the Python
|
|
1139
|
+
# master and PHP's `autoCommit && transaction === null`.
|
|
1140
|
+
def autocommit_standalone_write(drv)
|
|
1141
|
+
return unless @autocommit
|
|
1142
|
+
return unless Thread.current[@tx_pin_key].nil?
|
|
1143
|
+
|
|
1144
|
+
drv.commit
|
|
1145
|
+
rescue StandardError => e
|
|
1146
|
+
# A standalone write already succeeded; a follow-up commit failure here
|
|
1147
|
+
# must not mask that. Capture for #get_error and log, but don't raise.
|
|
1148
|
+
@last_error = e.message
|
|
1149
|
+
Tina4::Log.warning("autocommit commit after standalone write failed: #{e.message}")
|
|
1150
|
+
end
|
|
1151
|
+
|
|
797
1152
|
# "persistent" / "request" / "off" — mirrors Python connection.py.
|
|
798
1153
|
def cache_mode
|
|
799
1154
|
if @cache_persistent
|