Skip to content

Commit 0dd8c08

Browse files
authored
Merge pull request rails#44526 from matthewd/restart-transaction
Rollback nested transactions by restarting the parent where possible
2 parents cece807 + 3f19431 commit 0dd8c08

File tree

9 files changed

+162
-11
lines changed

9 files changed

+162
-11
lines changed

activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@ def transaction(requires_new: nil, isolation: nil, joinable: true, &block)
323323

324324
delegate :within_new_transaction, :open_transactions, :current_transaction, :begin_transaction,
325325
:commit_transaction, :rollback_transaction, :materialize_transactions,
326-
:disable_lazy_transactions!, :enable_lazy_transactions!, to: :transaction_manager
326+
:disable_lazy_transactions!, :enable_lazy_transactions!, :dirty_current_transaction,
327+
to: :transaction_manager
327328

328329
def transaction_open?
329330
current_transaction.open?
@@ -369,6 +370,12 @@ def rollback_db_transaction
369370

370371
def exec_rollback_db_transaction() end # :nodoc:
371372

373+
def restart_db_transaction
374+
exec_restart_db_transaction
375+
end
376+
377+
def exec_restart_db_transaction() end # :nodoc:
378+
372379
def rollback_to_savepoint(name = nil)
373380
exec_rollback_to_savepoint(name)
374381
end

activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module QueryCache
88
class << self
99
def included(base) # :nodoc:
1010
dirties_query_cache base, :create, :insert, :update, :delete, :truncate, :truncate_tables,
11-
:rollback_to_savepoint, :rollback_db_transaction, :exec_insert_all
11+
:rollback_to_savepoint, :rollback_db_transaction, :restart_db_transaction, :exec_insert_all
1212

1313
base.set_callback :checkout, :after, :configure_query_cache!
1414
base.set_callback :checkin, :after, :disable_query_cache!

activerecord/lib/active_record/connection_adapters/abstract/transaction.rb

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ def closed?; true; end
8080
def open?; false; end
8181
def joinable?; false; end
8282
def add_record(record, _ = true); end
83+
def restartable?; false; end
84+
def dirty?; false; end
85+
def dirty!; end
8386
end
8487

8588
class Transaction # :nodoc:
@@ -94,6 +97,15 @@ def initialize(connection, isolation: nil, joinable: true, run_commit_callbacks:
9497
@joinable = joinable
9598
@run_commit_callbacks = run_commit_callbacks
9699
@lazy_enrollment_records = nil
100+
@dirty = false
101+
end
102+
103+
def dirty!
104+
@dirty = true
105+
end
106+
107+
def dirty?
108+
@dirty
97109
end
98110

99111
def add_record(record, ensure_finalize = true)
@@ -114,6 +126,12 @@ def records
114126
@records
115127
end
116128

129+
# Can this transaction's current state be recreated by
130+
# rollback+begin ?
131+
def restartable?
132+
joinable? && !dirty?
133+
end
134+
117135
def materialize!
118136
@materialized = true
119137
end
@@ -167,6 +185,33 @@ def closed?; false; end
167185
def open?; !closed?; end
168186
end
169187

188+
class RestartParentTransaction < Transaction
189+
def initialize(connection, parent_transaction, **options)
190+
super(connection, **options)
191+
192+
@parent = parent_transaction
193+
194+
if isolation_level
195+
raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction"
196+
end
197+
198+
@parent.state.add_child(@state)
199+
end
200+
201+
delegate :materialize!, :materialized?, :restart, to: :@parent
202+
203+
def rollback
204+
@state.rollback!
205+
@parent.restart
206+
end
207+
208+
def commit
209+
@state.commit!
210+
end
211+
212+
def full_rollback?; false; end
213+
end
214+
170215
class SavepointTransaction < Transaction
171216
def initialize(connection, savepoint_name, parent_transaction, **options)
172217
super(connection, **options)
@@ -185,6 +230,10 @@ def materialize!
185230
super
186231
end
187232

233+
def restart
234+
connection.rollback_to_savepoint(savepoint_name) if materialized?
235+
end
236+
188237
def rollback
189238
unless @state.invalidated?
190239
connection.rollback_to_savepoint(savepoint_name) if materialized?
@@ -211,6 +260,17 @@ def materialize!
211260
super
212261
end
213262

263+
def restart
264+
return unless materialized?
265+
266+
if connection.supports_restart_db_transaction?
267+
connection.restart_db_transaction
268+
else
269+
connection.rollback_db_transaction
270+
materialize!
271+
end
272+
end
273+
214274
def rollback
215275
connection.rollback_db_transaction if materialized?
216276
@state.full_rollback!
@@ -242,11 +302,19 @@ def begin_transaction(isolation: nil, joinable: true, _lazy: true)
242302
joinable: joinable,
243303
run_commit_callbacks: run_commit_callbacks
244304
)
305+
elsif current_transaction.restartable?
306+
RestartParentTransaction.new(
307+
@connection,
308+
current_transaction,
309+
isolation: isolation,
310+
joinable: joinable,
311+
run_commit_callbacks: run_commit_callbacks
312+
)
245313
else
246314
SavepointTransaction.new(
247315
@connection,
248316
"active_record_#{@stack.size}",
249-
@stack.last,
317+
current_transaction,
250318
isolation: isolation,
251319
joinable: joinable,
252320
run_commit_callbacks: run_commit_callbacks
@@ -276,8 +344,20 @@ def lazy_transactions_enabled?
276344
@lazy_transactions_enabled
277345
end
278346

347+
def dirty_current_transaction
348+
current_transaction.dirty!
349+
end
350+
279351
def materialize_transactions
280352
return if @materializing_transactions
353+
354+
# As a logical simplification for now, we assume anything that requests
355+
# materialization is about to dirty the transaction. Note this is just
356+
# an assumption about the caller, not a direct property of this method.
357+
# It can go away later when callers are able to handle dirtiness for
358+
# themselves.
359+
dirty_current_transaction
360+
281361
return unless @has_unmaterialized_transactions
282362

283363
@connection.lock.synchronize do
@@ -301,6 +381,8 @@ def commit_transaction
301381
@stack.pop
302382
end
303383

384+
dirty_current_transaction if transaction.dirty?
385+
304386
transaction.commit
305387
transaction.commit_records
306388
end

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,10 @@ def savepoint_errors_invalidate_transactions?
335335
false
336336
end
337337

338+
def supports_restart_db_transaction?
339+
false
340+
end
341+
338342
# Does this adapter support application-enforced advisory locking?
339343
def supports_advisory_locks?
340344
false

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ def supports_transaction_isolation?
8181
true
8282
end
8383

84+
def supports_restart_db_transaction?
85+
true
86+
end
87+
8488
def supports_explain?
8589
true
8690
end
@@ -224,6 +228,10 @@ def exec_rollback_db_transaction # :nodoc:
224228
execute("ROLLBACK", "TRANSACTION")
225229
end
226230

231+
def exec_restart_db_transaction # :nodoc:
232+
execute("ROLLBACK AND CHAIN", "TRANSACTION")
233+
end
234+
227235
def empty_insert_statement_value(primary_key = nil) # :nodoc:
228236
"VALUES ()"
229237
end

activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@ def begin_db_transaction # :nodoc:
109109
end
110110

111111
def begin_isolated_db_transaction(isolation) # :nodoc:
112-
begin_db_transaction
113-
execute "SET TRANSACTION ISOLATION LEVEL #{transaction_isolation_levels.fetch(isolation)}"
112+
execute("BEGIN ISOLATION LEVEL #{transaction_isolation_levels.fetch(isolation)}", "TRANSACTION")
114113
end
115114

116115
# Commits a transaction.
@@ -125,6 +124,12 @@ def exec_rollback_db_transaction # :nodoc:
125124
execute("ROLLBACK", "TRANSACTION")
126125
end
127126

127+
def exec_restart_db_transaction # :nodoc:
128+
@raw_connection.cancel unless @raw_connection.transaction_status == PG::PQTRANS_IDLE
129+
@raw_connection.block
130+
execute("ROLLBACK AND CHAIN", "TRANSACTION")
131+
end
132+
128133
# From https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT
129134
HIGH_PRECISION_CURRENT_TIMESTAMP = Arel.sql("CURRENT_TIMESTAMP").freeze # :nodoc:
130135
private_constant :HIGH_PRECISION_CURRENT_TIMESTAMP

activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ def reset_pk_sequence!(table, pk = nil, sequence = nil) # :nodoc:
288288
quoted_sequence = quote_table_name(sequence)
289289
max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA")
290290
if max_pk.nil?
291-
if database_version >= 100000
291+
if database_version >= 10_00_00
292292
minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA")
293293
else
294294
minvalue = query_value("SELECT min_value FROM #{quoted_sequence}", "SCHEMA")

activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def supports_index_sort_order?
183183
end
184184

185185
def supports_partitioned_indexes?
186-
database_version >= 110_000 # >= 11.0
186+
database_version >= 11_00_00 # >= 11.0
187187
end
188188

189189
def supports_partial_index?
@@ -234,19 +234,23 @@ def supports_savepoints?
234234
true
235235
end
236236

237+
def supports_restart_db_transaction?
238+
database_version >= 12_00_00 # >= 12.0
239+
end
240+
237241
def supports_insert_returning?
238242
true
239243
end
240244

241245
def supports_insert_on_conflict?
242-
database_version >= 90500 # >= 9.5
246+
database_version >= 9_05_00 # >= 9.5
243247
end
244248
alias supports_insert_on_duplicate_skip? supports_insert_on_conflict?
245249
alias supports_insert_on_duplicate_update? supports_insert_on_conflict?
246250
alias supports_insert_conflict_target? supports_insert_on_conflict?
247251

248252
def supports_virtual_columns?
249-
database_version >= 120_000 # >= 12.0
253+
database_version >= 12_00_00 # >= 12.0
250254
end
251255

252256
def index_algorithms
@@ -398,7 +402,7 @@ def supports_foreign_tables?
398402
end
399403

400404
def supports_pgcrypto_uuid?
401-
database_version >= 90400 # >= 9.4
405+
database_version >= 9_04_00 # >= 9.4
402406
end
403407

404408
def supports_optimizer_hints?
@@ -531,7 +535,7 @@ def build_insert_sql(insert) # :nodoc:
531535
end
532536

533537
def check_version # :nodoc:
534-
if database_version < 90300 # < 9.3
538+
if database_version < 9_03_00 # < 9.3
535539
raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3."
536540
end
537541
end

activerecord/test/cases/transactions_test.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,10 +697,14 @@ def test_releasing_named_savepoints
697697

698698
def test_savepoints_name
699699
Topic.transaction do
700+
Topic.delete_all # Dirty the transaction to force a savepoint below
701+
700702
assert_nil Topic.connection.current_savepoint_name
701703
assert_nil Topic.connection.current_transaction.savepoint_name
702704

703705
Topic.transaction(requires_new: true) do
706+
Topic.delete_all # Dirty the transaction to force a savepoint below
707+
704708
assert_equal "active_record_1", Topic.connection.current_savepoint_name
705709
assert_equal "active_record_1", Topic.connection.current_transaction.savepoint_name
706710

@@ -1102,6 +1106,43 @@ def test_unprepared_statement_materializes_transaction
11021106
end
11031107
end
11041108

1109+
def test_nested_transactions_skip_excess_savepoints
1110+
capture_sql do
1111+
# RealTransaction (begin..commit)
1112+
Topic.transaction(requires_new: true) do
1113+
# ResetParentTransaction (no queries)
1114+
Topic.transaction(requires_new: true) do
1115+
Topic.delete_all
1116+
# SavepointTransaction (savepoint..release)
1117+
Topic.transaction(requires_new: true) do
1118+
# ResetParentTransaction (no queries)
1119+
Topic.transaction(requires_new: true) do
1120+
Topic.delete_all
1121+
end
1122+
end
1123+
end
1124+
Topic.delete_all
1125+
end
1126+
end
1127+
1128+
actual_queries = ActiveRecord::SQLCounter.log_all
1129+
1130+
expected_queries = [
1131+
/BEGIN/i,
1132+
/DELETE/i,
1133+
/^SAVEPOINT/i,
1134+
/DELETE/i,
1135+
/^RELEASE/i,
1136+
/DELETE/i,
1137+
/COMMIT/i,
1138+
]
1139+
1140+
assert_equal expected_queries.size, actual_queries.size
1141+
expected_queries.zip(actual_queries) do |expected, actual|
1142+
assert_match expected, actual
1143+
end
1144+
end
1145+
11051146
if ActiveRecord::Base.connection.prepared_statements
11061147
def test_prepared_statement_materializes_transaction
11071148
Topic.first

0 commit comments

Comments
 (0)