trx_ext 1.0.0

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.
data/README.md ADDED
@@ -0,0 +1,622 @@
1
+ # TrxExt
2
+
3
+ Extends functionality of ActiveRecord's transaction to auto-retry failed SQL transaction in case of deadlock, serialization error or unique constraint error. It also allows you to define `on_complete` callback that is being executed after SQL transaction is finished(either COMMIT-ed or ROLLBACK-ed).
4
+
5
+ Currently, the implementation only works for ActiveRecord PostgreSQL adapter. Feel free to improve it.
6
+
7
+ **WARNING!**
8
+
9
+ Because the implementation of this gem is a patch for `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter` - carefully test its integration into your project. For example, if your project patches ActiveRecord or if some of your gems patches ActiveRecord - there might be conflicts in the implementation which could potentially lead to the data loss.
10
+
11
+ Currently, the implementation is tested for `6.0.4.1` and `6.1.4.1` versions of ActiveRecord(see [TrxExt::SUPPORTED_AR_VERSIONS](lib/trx_ext/version.rb))
12
+
13
+ ## Requirements
14
+
15
+ - ActiveRecord 6+
16
+ - Ruby 3
17
+ - PostgreSQL 9.1+
18
+
19
+ ## Installation
20
+
21
+ Add this line to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem 'trx_ext'
25
+ ```
26
+
27
+ And then execute:
28
+
29
+ $ bundle install
30
+
31
+ Or install it yourself as:
32
+
33
+ $ gem install trx_ext
34
+
35
+ ## Usage
36
+
37
+ ```ruby
38
+ require 'trx_ext'
39
+ require 'active_record'
40
+
41
+ # Object #trx is a shorthand of ActiveRecord::Base.transaction
42
+ trx do
43
+ DummyRecord.first || DummyRecord.create
44
+ end
45
+
46
+ trx do
47
+ DummyRecord.first || DummyRecord.create
48
+ trx do |c|
49
+ c.on_complete { puts "This message will be printed after COMMIT statement." }
50
+ end
51
+ end
52
+
53
+ trx do
54
+ DummyRecord.first || DummyRecord.create
55
+ trx do |c|
56
+ c.on_complete { puts "This message will be printed after ROLLBACK statement." }
57
+ end
58
+ raise ActiveRecord::Rollback
59
+ end
60
+
61
+ class DummyRecord
62
+ # Wrap method in transaction
63
+ wrap_in_trx def some_method_with_quieries
64
+ DummyRecord.first || DummyRecord.create
65
+ end
66
+ end
67
+ ```
68
+
69
+ ## Configuration
70
+
71
+ ```ruby
72
+ TrxExt.configure do |c|
73
+ # Number of retries before failing when unique constraint error raises. Default is 5
74
+ c.unique_retries = 5
75
+ end
76
+ ```
77
+
78
+ ## How it works?
79
+
80
+ Your either every single AR SQL query or whole AR transaction is retried whenever it throws deadlock error, serialization error or unique constraint error. In case of AR transaction - the block of code that the AR transaction belongs to is re-executed, thus the transaction is retried.
81
+
82
+ ## Rules you have to stick when using this gem
83
+
84
+ > "Don't put more into a single transaction than needed for integrity purposes."
85
+ >
86
+ > — [PostgreSQL documentation]
87
+
88
+ Since `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter` is now patched with `TrxExt::Retry.with_retry_until_serialized`, there's no need to wrap every AR query in a `trx` block to ensure integrity. Wrap code in an explicit `trx` block if and only if it can or does produce two or more SQL queries *and* it is important to run those queries together atomically.
89
+
90
+ There is "On complete" feature that allows you to define callbacks(blocks of code) that will be executed after transaction is complete. See `On complete callbacks` section bellow for the docs. See `On complete callbacks integrity` section bellow to be aware about different situations with them.
91
+
92
+ * Don't explicitly wrap queries.
93
+
94
+ #### Bad
95
+
96
+ ```ruby
97
+ trx { User.find_by(username: 'someusername') }
98
+ ```
99
+
100
+ #### Good
101
+
102
+ ```ruby
103
+ User.find_by(username: 'someusername')
104
+ ```
105
+
106
+ * Don't wrap multiple `SELECT` queries in a single transaction unless it is of vital importance (see epigraph).
107
+
108
+ #### Bad
109
+
110
+ ```ruby
111
+ trx do
112
+ @author = User.first
113
+ @posts = current_user.posts.load
114
+ end
115
+ ```
116
+
117
+ ```sql
118
+ BEGIN
119
+ SELECT "users".* FROM "users" ...
120
+ SELECT "posts".* FROM "posts" ...
121
+ COMMIT
122
+ ```
123
+
124
+ #### Good
125
+
126
+ ```ruby
127
+ @author = User.first
128
+ @posts = current_user.posts.load
129
+ ```
130
+
131
+ ```sql
132
+ -- TrxExt::Retry.with_retry_until_serialized {
133
+ SELECT "users".* FROM "users" ...
134
+ -- }
135
+ -- TrxExt::Retry.with_retry_until_serialized {
136
+ SELECT "posts".* FROM "posts" ...
137
+ -- }
138
+ ```
139
+
140
+ * Beware of `AR::Relation` lazy loading if it is necessary to have multiple `SELECT`s in a single transaction.
141
+
142
+ #### Bad
143
+
144
+ ```ruby
145
+ trx do
146
+ @posts = Post.all
147
+ @users = User.all
148
+ end
149
+ ```
150
+
151
+ will result in no query.
152
+
153
+ #### Good
154
+
155
+ ```ruby
156
+ trx do
157
+ @posts = Post.all
158
+ @users = User.all
159
+ end
160
+ ```
161
+
162
+ ```sql
163
+ BEGIN
164
+ SELECT "posts".* FROM "posts" ...
165
+ SELECT "users".* FROM "users" ...
166
+ COMMIT
167
+ ```
168
+
169
+ * When performing `UPDATE`/`INSERT` queries that depend on record's state - reload that record in the beginning of `trx` block.
170
+
171
+ #### Bad
172
+ ```ruby
173
+ def initialize(user)
174
+ @user = user
175
+ end
176
+
177
+ def update_posts
178
+ trx do
179
+ @user.posts.update_all(banned: true) if @user.user_permission.admin?
180
+ end
181
+ end
182
+ ```
183
+
184
+ ```sql
185
+ BEGIN
186
+ UPDATE posts SET banned = TRUE WHERE posts.user_id IN (...)
187
+ COMMIT
188
+ ```
189
+
190
+ #### Explanation
191
+ It might not be obvious that this code depends on `@user` - `UserPermission#admin?` is used to detect whether `Post#banned` must be updated. However, it is accessed through `@user` and there is no guarantee that, when calling `@user.user_permission`, it was not already cached by either previous calls, upper by stack trace, or inside `trx` block on transaction retry. This is why it is mandatory to call `@user.reload` - to reset user's cache and the cache of user's relations.
192
+
193
+ #### Good
194
+ ```ruby
195
+ def initialize(user)
196
+ @user = user
197
+ end
198
+
199
+ def update_posts
200
+ trx do
201
+ @user.reload
202
+ @user.posts.update_all(banned: true) if @user.user_permission.admin?
203
+ end
204
+ end
205
+ ```
206
+
207
+ ```sql
208
+ BEGIN
209
+ SELECT * FROM users WHERE users.id = ...
210
+ SELECT * FROM user_permissions WHERE user_permissions.user_id = ...
211
+ UPDATE posts SET banned = TRUE WHERE posts.id IN (...)
212
+ COMMIT
213
+ ```
214
+
215
+ * It may happen that you need to invoke mailer's method inside `trx` block and pass there values that are calculated within the transaction block. Normally, you need to extract those values into after-transaction code and invoke mailer after transaction's end. Use `on_complete` callback to simplify your code:
216
+
217
+ #### Bad
218
+ ```ruby
219
+ trx do
220
+ user = User.find_or_initialize_by(email: email)
221
+ if user.save
222
+ # May be invoked more than one time if transaction is retried
223
+ Mailer.registration_confirmation(user.id).deliver_later
224
+ end
225
+ end
226
+ ```
227
+
228
+ #### Good (before refactoring)
229
+ ```ruby
230
+ user = nil
231
+ result =
232
+ trx do
233
+ user = User.find_or_initialize_by(email: email)
234
+ user.save
235
+ end
236
+ Mailer.registration_confirmation(user.id).deliver_later if result
237
+ ```
238
+
239
+ #### Good (after refactoring)
240
+ ```ruby
241
+ trx do |c|
242
+ user = User.find_or_initialize_by(email: email)
243
+ if user.save
244
+ c.on_complete { Mailer.registration_confirmation(user.id).deliver_later }
245
+ end
246
+ end
247
+ ```
248
+
249
+ * Always keep in mind, that retrying of transactions is just re-execution of ruby's block of code on transaction retry. If you have any variables, that are changing inside the block - ensure that their values are reset in the beginning of block. Don't use methods that will raise error if called more than twice.
250
+
251
+ #### Bad
252
+ ```ruby
253
+ resurrected_users_count = 0
254
+ trx do
255
+ User.deleted.find_each do |user|
256
+ if user.created_at > 2.days.ago
257
+ user.active!
258
+ resurrected_users_count += 1
259
+ end
260
+ end
261
+ end
262
+ puts resurrected_users_count
263
+ ```
264
+
265
+ #### Good
266
+ ```ruby
267
+ resurrected_users_count = nil
268
+ trx do
269
+ resurrected_users_count = 0
270
+ User.deleted.find_each do |user|
271
+ if user.created_at > 2.days.ago
272
+ user.active!
273
+ resurrected_users_count += 1
274
+ end
275
+ end
276
+ end
277
+ puts resurrected_users_count
278
+ ```
279
+
280
+ #### Bad
281
+ ```ruby
282
+ class UsersController
283
+ def update
284
+ # This may raise AbstractController::DoubleRenderError if either redirect or render invoked twice
285
+ trx do
286
+ if @user.update(user_params)
287
+ redirect_to @user
288
+ else
289
+ render :edit
290
+ end
291
+ end
292
+ end
293
+ end
294
+ ```
295
+
296
+ #### Bad
297
+ ```ruby
298
+ class UsersController
299
+ # This may raise AbstractController::DoubleRenderError if either redirect or render invoked twice
300
+ wrap_in_trx def update
301
+ if @user.update(user_params)
302
+ redirect_to @user
303
+ else
304
+ render :edit
305
+ end
306
+ end
307
+ end
308
+ ```
309
+
310
+ #### Good
311
+ ```ruby
312
+ class UsersController
313
+ def update
314
+ if @user.update(user_params)
315
+ redirect_to @user
316
+ else
317
+ render :edit
318
+ end
319
+ end
320
+ end
321
+ ```
322
+
323
+ #### Good
324
+ ```ruby
325
+ class UsersController
326
+ def update
327
+ trx do |c|
328
+ if @user.update(user_params)
329
+ c.on_complete { redirect_to @user }
330
+ else
331
+ c.on_complete { render :edit }
332
+ end
333
+ end
334
+ end
335
+ end
336
+ ```
337
+
338
+ * Carefully implement the code that is related to the non-relational databases like Redis or MongoDB
339
+
340
+ #### Bad
341
+ ```ruby
342
+ trx do
343
+ @post.reload
344
+ if @post.tags_arr.include?('special')
345
+ @post.update_columns(special: true)
346
+ @post.mongo_post.update(special: true)
347
+ end
348
+ end
349
+ ```
350
+
351
+ #### Explanation
352
+
353
+ Example: `@post.tags_arr.include?('special') == true` and, as a result, `@post.mongo_post.update(special: true)` is executed but transaction is failed to be serialized. On second try - `@post.tags_arr.include?('special')` becomes false but the value of `MongoPost#special` was already changed
354
+
355
+ #### Good
356
+ ```ruby
357
+ trx do
358
+ @post.reload
359
+ if @post.tags_arr.include?('special')
360
+ @post.update_columns(special: true)
361
+ end
362
+ @post.mongo_post.update(special: @post.tags_arr.include?('special'))
363
+ end
364
+ ```
365
+
366
+ * Don't explicitly use `return` in the transaction's block of code. It may affect on how the transaction is going to be finished. Currently, it finishes with `COMPLETE` statement, but in the future versions it may change - according to the [warning message](https://github.com/rails/rails/blob/v6.1.3.2/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L330-L337), the behaviour may change soon.
367
+
368
+ #### Bad
369
+ ```ruby
370
+ def some_method
371
+ trx do
372
+ return if User.where(email: email).exists?
373
+
374
+ User.create(email: email)
375
+ end
376
+ end
377
+ ```
378
+
379
+ #### Bad
380
+ ```ruby
381
+ def some_method
382
+ trx do |c|
383
+ user = User.find_by(email: email)
384
+ return user if user
385
+
386
+ user = User.create(email: email)
387
+ c.on_complete { Mailer.registration_confirmation(user.id).deliver_later }
388
+ end
389
+ end
390
+ ```
391
+
392
+ #### Explanation
393
+ Using `return` in the `Proc`(a block of code is a `Proc`) will return from the stack call instead the return from the block of code. Example:
394
+
395
+ ```
396
+ def some_method
397
+ puts "Start"
398
+ yield
399
+ puts "End"
400
+ end
401
+
402
+ def another_method
403
+ some_method do
404
+ puts "Hi"
405
+ return
406
+ end
407
+ end
408
+ ```
409
+ Calling `#another_method` will output `Start` and `Hi` string, `End` string will never get output. Refer to [official docs](https://ruby-doc.org/core-3.0.1/Proc.html#class-Proc-label-Lambda+and+non-lambda+semantics) for more info.
410
+
411
+ #### Good
412
+ ```ruby
413
+ def some_method
414
+ trx do
415
+ unless User.where(email: email).exists?
416
+ User.create(email: email)
417
+ end
418
+ end
419
+ end
420
+ ```
421
+
422
+ #### Good
423
+ ```ruby
424
+ wrap_in_trx def some_method
425
+ return if User.where(email: email).exists?
426
+
427
+ User.create(email: email)
428
+ end
429
+ ```
430
+
431
+ #### Good
432
+ ```ruby
433
+ wrap_in_trx def some_method
434
+ user = User.find_by(email: email)
435
+ return user if user
436
+
437
+ user = User.create(email: email)
438
+ trx { |c| c.on_complete { Mailer.registration_confirmation(user.id).deliver_later } }
439
+ end
440
+ ```
441
+
442
+ ## On complete callbacks
443
+
444
+ On-complete callbacks are defined with `TrxExt::CallbackPool#on_complete` method. An instance of `TrxExt::CallbackPool` is passed in each transaction block. You may add as much on-complete callbacks as you want by calling `TrxExt::CallbackPool#on_complete` several times - they will be executed in the order you define
445
+ them(FIFO principle). The on-complete callbacks from nested transactions will be executed from the most deep to the most top transaction. Another words, if top transaction defines `<#TrxExt::CallbackPool 0x1>` instance and nested transaction defines `<#TrxExt::CallbackPool 0x2>` instance then, when executing on-complete callbacks - the callbacks of `<#TrxExt::CallbackPool 0x2>` instance will be executed first(FILO principle).
446
+
447
+ Example:
448
+
449
+ ```ruby
450
+ ActiveRecord::Base.transaction do |c1|
451
+ User.first
452
+ c1.on_complete { puts "This is 3rd message" }
453
+ ActiveRecord::Base.transaction do |c2|
454
+ User.last
455
+ c2.on_complete { puts "This is 2nd message" }
456
+ ActiveRecord::Base.transaction do |c3|
457
+ c3.on_complete { puts "This is 1st message" }
458
+ User.first(2)
459
+ end
460
+ end
461
+ c1.on_complete { puts "This is 4th message" }
462
+ end
463
+ ```
464
+
465
+ If you don't need to define on-complete callbacks - you may skip explicit definition of block's argument.
466
+
467
+ Example:
468
+
469
+ ```ruby
470
+ ActiveRecord::Base.transaction { User.first }
471
+ ```
472
+
473
+ Keep in mind, that all on-complete callbacks are not a part of the transaction. If you want to make it transactional - you need to wrap it in another transaction.
474
+
475
+ Example:
476
+
477
+ ```ruby
478
+ ActiveRecord::Base.transaction do |c1|
479
+ User.first
480
+ c1.on_complete do
481
+ ActiveRecord::Base.transaction do
482
+ User.find_or_create_by(email: email)
483
+ end
484
+ end
485
+ end
486
+ ```
487
+
488
+ You may define on-complete callbacks inside another on-complete callbacks. You may define another transactions in
489
+ on-complete callbacks. Just don't get confused in the order they are going to be executed.
490
+
491
+ Example:
492
+
493
+ ```ruby
494
+ ActiveRecord::Base.transaction do |c1|
495
+ User.first
496
+ c1.on_complete do
497
+ puts "This line will be executed first"
498
+ ActiveRecord::Base.transaction do |c2|
499
+ User.last
500
+ c2.on_complete do
501
+ puts "This line will be executed second"
502
+ end
503
+ end
504
+ puts "This line will be executed third"
505
+ end
506
+ end
507
+ ```
508
+
509
+ Also, please avoid usage of the callbacks that belong to one transaction in another transaction explicitly. This complicates the readability of the code.
510
+
511
+ Example:
512
+
513
+ ```ruby
514
+ ActiveRecord::Base.transaction do |c1|
515
+ User.first
516
+ c1.on_complete do
517
+ ActiveRecord::Base.transaction do
518
+ User.last
519
+ c1.on_complete do
520
+ puts "This will be executed at the time when parent transaction's on-complete callbacks are executed!"
521
+ end
522
+ end
523
+ end
524
+ end
525
+ ```
526
+
527
+ ## On complete callbacks integrity
528
+
529
+ * Don't define callbacks blocks as lambdas unless you are 100% sure what you are doing. Lambda has a bit different behaviour comparing to Proc. Refer to [ruby documentation](https://ruby-doc.org/core-2.6.5/Proc.html#class-Proc-label-Lambda+and+non-lambda+semantics).
530
+
531
+ * When defining a callback - make sure that it does not depend on transaction's integrity. Another words - define it in a way like it is a normal code implementation outside the transaction:
532
+
533
+ #### Bad
534
+ ```ruby
535
+ trx do |c|
536
+ user = User.find(id)
537
+ user.referrals.create(referral_attrs)
538
+ c.on_complete do
539
+ Mailer.new_referral(
540
+ user_id: user.id, total_referrals: user.referrals.count
541
+ ).deliver_later
542
+ end
543
+ end
544
+ ```
545
+
546
+ #### Explanation
547
+ The example above introduces two issues:
548
+ - `on_complete` callback does not depend on the result of `user.referrals.create(referral_attrs)`. And it should - we only need to send the email only if referral is created. Solution - add the condition for the `on_complete` callback
549
+ - the number of user's referrals `user.referrals.count` is calculated inside `on_complete`, but it should be calculated within the transaction. Solution - calculate referrals count in transaction, extract its value into local variable and use that variable in the `on_complete` callback
550
+
551
+ #### Good
552
+ ```ruby
553
+ trx do |c|
554
+ user = User.find(id)
555
+ referral = user.referrals.create(referral_attrs)
556
+ if referral.persisted?
557
+ total_referrals = user.referrals.count
558
+ c.on_complete do
559
+ Mailer.new_referral(user_id: user.id, total_referrals: total_referrals).deliver_later
560
+ end
561
+ end
562
+ end
563
+ ```
564
+
565
+ ## Make methods atomic.
566
+
567
+ You can make any method atomic by wrapping it into transaction using `#wrap_in_trx`. Example:
568
+
569
+ ```ruby
570
+ class ApplicationRecord < ActiveRecord::Base
571
+ class << self
572
+ wrap_in_trx :find_or_create_by
573
+ wrap_in_trx :find_or_create_by!
574
+ end
575
+
576
+ wrap_in_trx def some_method
577
+ SomeRecord.first || SomeRecord.create
578
+ end
579
+ end
580
+ ```
581
+
582
+ ## Development
583
+
584
+ ### Setup
585
+
586
+ - After checking out the repo, run `bin/setup` to install dependencies.
587
+ - Setup postgresql server with `serializable` transaction isolation level - you have to set `default_transaction_isolation` config option to `serializable` in your `postgresql.conf` file
588
+ - Create pg user and a database. This database will be used to run tests. When running console, this database will be used as a default database to connect to. Example:
589
+ ```shell
590
+ sudo -u postgres createuser postgres --superuser
591
+ sudo -u postgres psql --command="CREATE DATABASE trx_ext_db OWNER postgres"
592
+ ```
593
+ - Setup db connection settings. Copy config sample and edit it to match your created pg user and database:
594
+ ```shell
595
+ cp spec/support/config/database.yml.sample spec/support/config/database.yml
596
+ ```
597
+
598
+ Now you can run `bin/console` for an interactive prompt that will allow you to experiment.
599
+
600
+ ### Tests
601
+
602
+ You can run tests for currently installed AR using `rspec` command. There is `bin/test_all_ar_versions` executable that allows you to run tests within all supported AR versions(see [TrxExt::SUPPORTED_AR_VERSIONS](lib/trx_ext/version.rb)) as well.
603
+
604
+ ### Other
605
+
606
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
607
+
608
+ ## Contributing
609
+
610
+ Bug reports and pull requests are welcome on GitHub at https://github.com/intale/trx_ext. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/intale/trx_ext/blob/master/CODE_OF_CONDUCT.md).
611
+
612
+ ## License
613
+
614
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
615
+
616
+ ## Code of Conduct
617
+
618
+ Everyone interacting in the TrxExt project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/intale/trx_ext/blob/master/CODE_OF_CONDUCT.md).
619
+
620
+ ## TODO
621
+
622
+ - integrate GitHub Actions
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/bin/console ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "trx_ext"
7
+
8
+ # You can add fixtures and/or initialization code here to make experimenting
9
+ # with your gem easier. You can also use a different console, if you like.
10
+
11
+ # (If you use this, don't forget to add pry to your Gemfile!)
12
+ # require "pry"
13
+ # Pry.start
14
+
15
+ require "irb"
16
+ require "logger"
17
+ require 'active_record'
18
+ require_relative '../spec/support/config'
19
+ require_relative '../spec/support/dummy_record'
20
+
21
+ ActiveRecord::Base.logger = ActiveSupport::Logger.new(STDOUT)
22
+ TrxExt.logger = ActiveSupport::Logger.new(STDOUT)
23
+
24
+ if Config.db_config
25
+ ActiveRecord::Base.establish_connection(Config.db_config)
26
+ DummyRecord.setup
27
+ end
28
+
29
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../lib/trx_ext/version'
6
+
7
+ TrxExt::SUPPORTED_AR_VERSIONS.each do |ar_version|
8
+ `rm Gemfile.lock`
9
+ Process.waitpid(Kernel.spawn({ 'AR_VERSION' => ar_version }, "bundle install --quiet", close_others: true))
10
+ Process.waitpid(Kernel.spawn({ 'AR_VERSION' => ar_version }, "rspec", close_others: true))
11
+ end