I am trying to use an ActiveRecord::Base.transaction to ensure that a Customer, CustomerAccount and StockNotification all get created at once, or none gets created at all if one of them fails
Here is my transaction
stock_notification.rb:
validates_presence_of :email
def self.make parameters
ActiveRecord::Base.transaction do
shop_id = ProductVariant.find(parameters[:product_variant_id]).shop_id
parameters[:customer_account_id] = CustomerAccount.find_or_make!(parameters[:email], shop_id).id
#stock_notification = StockNotification.create(parameters) # reference A
end
#stock_notification
end
You might need this as well
customer_account.rb:
def self.find_or_make! email, shop_id
customer = Customer.where(email: email).first_or_create!
CustomerAccount.where(shop_id: shop_id, customer_id: customer.id).first_or_create!
end
If I call StockNotification.make with a blank email, the create fails (reference A) and no stock notification is created, but the problem is that a Customer/CustomerAccount is still being created.
So the transaction is not doing it's job at all, or am I missing something?
Yep. Transaction will fail if an exception is raised. You should use not create, but create! that will do that in case of failure. See here.
Related
I have a question regarding the deletion of a User. Note that I'm not using Devise since my app is an API.
What I need to do
So I have a User model, I can delete this User with no issues. The user belongs to many other associations regarding Bank Accounts, Transactions, you name it.
When I delete the User, it's able to be deleted but its associations are not. Note that I'm using soft_deletion which means it gets in an INACTIVE state. And by saying that the "associations aren't being deleted" means that I just need to DISABLE specific associations when the User has been deleted or gets INACTIVE
What I currently have
user.rb model file
class User < ApplicationRecord
has_many :bank_accounts
def soft_deletion!
update!(status: "DELETED",
deleted_at: Time.now)
end
end
delete_user.rb interactor file
module UserRequests
class DeleteUser < BaseInteractor
def call
require_context(:id)
remove_user
context.message = 'user record deleted'
end
def remove_user
user = context.user
context.user.soft_deletion! #<- This is the method I have on my model, which works!
#below I removed all user invites in case there's one
user_invite = UserInvite.joined.where(
"lower(email) = '#{user.email.downcase}'"
)&.first
user_invite&.update(status: "CANCELLED")
return if user.user.present?
user.update!(status: "INACTIVE")
end
end
end
So giving a bit more context of what happened up there. My App is an API, on my frontend I remove the user and it actually works, and so what I need to do next is delete the user AND delete a bank_account that's associated with my user.
What I've been trying to do (This fails so hard, and I need some help )
I honestly don't know how to interact between interactions on Rails, that's the reason of my question here.
delete_user.rb interactor file
module UserRequests
class DeleteUser < BaseInteractor
def call
require_context(:id)
remove_user
soft_delete_transaction_account #method to delete bank account
context.message = 'user record deleted'
end
#since there's an association I believe in adding a method to verify if there's a bank
account.
def soft_delete_bank_account
context.account = context.user.bank_accounts.find_by_id(context.id)
fail_with_error!('account', 'does not exist') unless
context.account.present?
context.account.update!(deleted: true,
deleted_at: Time.now)
end
def remove_user
user = context.user
context.user.soft_deletion! #<- This is the method I have on my model, which works!
context.user.soft_delete_transaction_account #<- Then I add that method here so the bank account can be deleted while the user gets deleted!
#below I removed all user invites in case there's one
user_invite = UserInvite.joined.where(
"lower(email) = '#{user.email.downcase}'"
)&.first
user_invite&.update(status: "CANCELLED")
return if user.user.present?
user.update!(status: "INACTIVE")
end
end
end
ERROR LOG of my code:
NoMethodError - undefined method `soft_delete_bank_account' for #<User:0x00007f8284d660b0>:
app/interactors/admin_requests/delete_user.rb:47:in `remove_user'
app/interactors/admin_requests/delete_user.rb:9:in `call'
app/controllers/admin_controller.rb:18:in `destroy'
I would appreciate your help on this!
I'm using Rails 4 with Oracle 12c and I need to update the status of an User, and then use the new status in a validation for another model I also need to update:
class User
has_many :posts
def custom_update!(new_status)
relevant_posts = user.posts.active_or_something
ActiveRecord::Base.transaction do
update!(status: new_status)
relevant_posts.each { |post| post.update_stuff! }
end
end
end
class Post
belongs_to :user
validate :pesky_validation
def update_stuff!
# I can call this from other places, so I also need a transaction here
ActiveRecord::Base.transaction do
update!(some_stuff: 'Some Value')
end
end
def pesky_validation
if user.status == OLD_STATUS
errors.add(:base, 'Nope')
end
end
end
However, this is failing and I receive the validation error from pesky_validation, because the user inside Post doesn't have the updated status.
The problem is, when I first update the user, the already instantiated users inside the relevant_posts variable are not yet updated, and normally all I'd need to fix this was to call reload, however, maybe because I'm inside a transaction, this is not working, and pesky_validation is failing.
relevant_users.first.user.reload, for example, reloads the user to the same old status it had before the update, and I'm assuming it's because the transaction is not yet committed. How can I solve this and update all references to the new status?
I have a method called invite! in my user model which will generate a unique token for a user and email them an invitation to join / sign up for my app.
The simplified implementation is -
class User < ActiveRecord::Base
# ...
# ...
def invite!
transaction do
generate_invitation_token
mark_as_invited
save!
end
WelcomeMailer.delay.send_invitation(id)
end
end
As you can see, it does some setup steps inside a transaction and then sends a WelcomeMailer invitation.
The key here is that I do NOT want an email delivered if there is any issue in updating the database during the transaction.
How do I best implement some error handling here?
The ruby-level method could throw an error. For example save! might error because of some model validation. In that case I can rescue it and return before it gets to the mailer.
What happens if there's some DB level transaction that errors out? Does it bubble up and throw an error in ruby? Will the same begin..rescue approach work there as well?
Thanks!
Two points about your case:
"RecordInvalid - raised by ActiveRecord::Base#save! and ActiveRecord::Base.create! when the record is invalid." (link)
"Also have in mind that exceptions thrown within a transaction block will be propagated (after triggering the ROLLBACK), so you should be ready to catch those in your application code." (link)
That means, what in a case of an error inside the transaction block in your current simplified code next things will happen:
the transaction will be rolled back
a mail will not be sent
the error will be throwed upper from invite!
If it's necessary to make some actions in a case of an error inside the transaction block, you may change the code like this:
def invite!
transaction do
generate_invitation_token
mark_as_invited
save!
end
rescue RecordInvalid => e
# some actions
raise e
else
# code that runs only if *no* exception was raised
WelcomeMailer.delay.send_invitation(id)
end
After I create a record, I send an email, which I do in an after_commit callback. I want to save the Message-Id header of the email as an attribute on the record to use later. I implemented this as:
after_commit on: :create do
if email = Mailer.email(self).deliver
# `self.message_id = email.message_id` has no effect, so I'm calling update()
self.update message_id: email.message_id
end
end
Surprisingly (to me), this causes an infinite email-sending loop (sorry, Mailgun); it appears that the callback is called on update even though on: :create is specified.
Is there something wrong with this approach that I'm not seeing? How else could I attach this value to this record?
My only thought is to try gating the callback on previous_changes, but in any case I'd like to understand why this doesn't work as-is.
"Is there something wrong with this approach that I'm not seeing?"
I would also expect update in after_commit callback to trigger only on: update callbacks. However I found this in Rails source:
def committed!(should_run_callbacks = true) #:nodoc:
_run_commit_callbacks if should_run_callbacks && destroyed? || persisted?
ensure
force_clear_transaction_record_state
end
Only after running all after_commit callbacks does Rails clear new_record status of the transaction.
"How else could I attach this value to this record?"
Idea 1:
self.update_columns message_id: email.message_id
This won't create transaction and thus won't trigger another after_commit callback. I think it's appropriate to send email and store id in the record outside transaction. After all, if something fails you cannot rollback updating record with message ID and rollback sending email. It's just not atomic by nature.
Idea 2:
In after_commit callback queue an ActiveJob job that sends email and updates record with message id. With real backend like SuckerPunch or DelayedJob the job is queued with record's "Global ID". Job's perform method gets a freshly loaded record with no transaction state stored in its instance variables.
This might work
after_commit :email_send, :if => lambda{ new_record? }
or
after_commit :email_send, :on => :create
or
after_create :email_send
def email_send
if email = Mailer.email(self).deliver
# `self.message_id = email.message_id` has no effect, so I'm calling update()
self.update message_id: email.message_id
end
end
Checking for the prior existence of message_id seems to have solved the problem in my particular case:
after_commit on: :create do
unless self.message_id
if email = Mailer.email(self).deliver
self.update message_id: email.message_id
end
end
end
But I imagine there could be a scenario where I couldn't get away with that, so it'd still be good to understand why an on: :create callback is being called on update.
I have the following code block:
unless User.exist?(...)
begin
user = User.new(...)
# Set more attributes of user
user.save!
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
# Check if that user was created in the meantime
user = User.exists?(...)
raise e if user.nil?
end
end
The reason is, as you can probably guess, that multiple processes might call this method at the same time to create the user (if it doesn't already exist), so while the first one enters the block and starts initializing a new user, setting the attributes and finally calling save!, the user might already be created.
In that case I want to check again if the user exists and only raise the exception if it still doesn't (= if no other process has created it in the meantime).
The problem is, that regularly ActiveRecord::RecordInvalid exceptions are raised from the save! and not rescued from the rescue block.
Any ideas?
EDIT:
Alright, this is weird. I must be missing something. I refactored the code according to Simone's tip to look like this:
unless User.find_by_email(...).present?
# Here we know the user does not exist yet
user = User.new(...)
# Set more attributes of user
unless user.save
# User could not be saved for some reason, maybe created by another request?
raise StandardError, "Could not create user for order #{self.id}." unless User.exists?(:email => ...)
end
end
Now I got the following exception:
ActiveRecord::RecordNotUnique: Mysql::DupEntry: Duplicate entry 'foo#bar.com' for key 'index_users_on_email': INSERT INTO `users` ...
thrown in the line where it says 'unless user.save'.
How can that be? Rails thinks the user can be created because the email is unique but then the Mysql unique index prevents the insert? How likely is that? And how can it be avoided?
In this case, you might want to use a migration to create an unique index on a user table key, so that the database will raise an error.
Also, don't forget to add a validates_uniqueness_of validation in your user model.
The validation doesn't always prevent duplicate data (there's a really minimum chance that two concurrent requests are written at the same millisecond).
If you use the validates_uniqueness_of in combination with an index, you don't need all that code.
unless User.exist?(...)
begin
user = User.new(...)
# Set more attributes of user
user.save!
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
# Check if that user was created in the meantime
user = User.exists?(...)
raise e if user.nil?
end
end
becomes
user = User.new(...)
# Set more attributes of user
if user.save
# saved
else
# user.errors will return
# the list of errors
end
Rails validations aren't able to detect race conditions in the database; the solution we use is to also add database constraints.
Here's our brief page of links about this: Rails ActiveRecord Validations: validates_uniqueness_of races