I have two Rails models / tables that I want insert as part of transaction.
For example, here are roughly my tables:
Posts: (id)
Comments: (id, post_id, comment_text, user_id)
Commenters: (id, post_id, user_id), unique constraint on (post_id,
user_id)
Right now I'm trying approximately equivalent to:
ActiveRecord::Base.transaction do
Comment.create!(post: post, user: user, comment_text: '...')
begin
Commenters.find_or_create_by!(post: post, user: user)
rescue PG::UniqueViolation
end
end
This works 99.9% of time, but sometimes two concurrent comments will trigger a PG::UniqueViolation.
Even though I'm catching and suppressing the PG::UniqueViolation, the entire transaction fails due to:
ERROR: current transaction is aborted, commands ignored until end of transaction block
I realize I could already achieve this by joining Post and Comment table, but this is a simplified example.
Is there a simpler way to ensure both inserts happen as part of a transaction while still ignoring the unique violation since we can assume that the record already exists?
An exception raised inside the transaction does two things:
Rolls back the transaction so none of the changes made inside the transaction will persist in the database.
The exception propagates outside the transaction block.
So you can move your exception handler outside the transaction call:
begin
ActiveRecord::Base.transaction do
Comment.create!(post: post, user: user, comment_text: '...')
Commenters.find_or_create_by!(post: post, user: user)
end
rescue PG::UniqueViolation
retry
end
You could include a counter to only retry a few times if you wanted more safety.
You should have the associations properly set inside your models, so rails does the validation for you. Then you can simply rescue the possible ActiveRecord::RecordInvalid (with message Validation failed: Attribute has already been taken).
If you want to read some more about uniqueness validtion this should come in handy: http://guides.rubyonrails.org/active_record_validations.html#uniqueness
To me it actually looks like your Commenter model is not really necessary. It just consists of derived information which can also be drawn directly from the Comments model (there you already store post_id and user_id) so you could drop the Commenter class entirely. Then take care to validate Comment on creation by for example setting
class Comment
belongs_to :post
belongs_to :user
validates :user_id, uniqueness: {scope: :post_id}
end
But this way you would allow a user to comment only once.
I propose you drop the uniqueness constraint and construct the information stored in Commenter by making distinct selects on the Comment model.
PS: Models in rails are written in Uppercase and singular while tables are referred to (by symbols) in lowercase and plural.
The error itself happens because of multi-threading behaviour of your app.
You need to rescue ActiveRecord::RecordNotUnique instead of PG-specific error.
Also perhaps put transaction inside begin rescue end block.
And retry to continue with another transaction within rescue block. Something like another answer suggested.
Related
Is there a way to rewrite the process below, which currently uses find_or_initialize_by, using the joins method?
For context - I have users (employees) who record their attendances in the system (a user has many attendances, and an attendance record belongs to a user).
Attendance.find_or_initialize_by(
user: User.find_by(name: 'Bob'),
date: Time.zone.today
)
.update(...) # Update some columns after this
I'm trying to rewrite it using .joins like this:
Attendance.joins(:user)
.where(users: {name: 'Bob'}, date: Time.zone.today)
.first_or_initialize
.update(...) # Update some columns after this
My tests come back with mixed results:
Test cases where the record only needs to be updated pass
Test cases where the attendance record doesn't exist yet (i.e. cases when I have to initialize) fail
The error message is
ActiveRecord::RecordInvalid: Validation failed: User must exist.
But I'm confused because the user actually exists - if I debug using byebug, the user is there.
Rather than starting from the Attendance model, I would tend to start from the User, like this:
User.find_by(name: 'Bob').attendances.find_or_initialize_by(date: Time.zone.today).update(...)
That keeps things easy to read. You could add an association extension method to make things more convenient:
class User < ActiveRecord::Base
has_many :attendances do
def for_date(date)
find_or_initialize_by(date: Time.zone.today)
end
end
end
# Then call with:
User.attendances.for_date(Time.zone.today)
Depending on what you're doing with that attendance record, you could also have your for_date method take extra arguments.
first_or_initialize has been removed according to: https://api.rubyonrails.org/classes/ActiveRecord/Relation.html. Thanks to #engineersmnky for the correction. The method is undocumented, but that looks likes a mistake.
Are you setting Bob's ID in the update call? Basically when you run this, and the where clause returns empty, an empty new record is instantiated. At this moment, all reference to Bob is gone, so you need to set it again in the update:
Attendance.joins(:user)
.where(users: {name: 'Bob'}, date: Time.zone.today)
.first_or_initialize
.update(user: User.find_by(name: 'Bob'), #other stuff ) # Update some columns after this
I'm writing a web app using Rails, part of which includes giving users the ability to leave reviews for things. I wanted to put a validation in the review model to ensure that one user can't leave multiple reviews of the same item, so I wrote this:
class NoDuplicateReviewValidator < ActiveModel::Validator
def validate(record)
dup_reviews = Review.where({user_id: record.user,
work_id: record.work})
unless dup_reviews.length < 1
record.errors[:duplicate] << "No duplicate reviews!"
end
end
end
This validator has the desired behavior, i.e. it guarantees that a user can't review a work twice. However, it has the undesired side-effect that a user can't update an already existing review that he/she left. I'm using a really simple
def update
#review.update(review_params)
respond_with(#work)
end
in the reviews controller. How can I change either the validator or the update method so that duplicate reviews are prevented but updates are allowed?
I'm very new to Rails and web development, so I'm sure I've done something goofy here. I didn't use one of the built-in unique validators because what is unique is the user/work pair; there can more than one review by the same user of different works, and there can be more than one review of the same work by different users.
You can use validates_uniqueness_of on multiple attributes, like this:
validates_uniqueness_of :user_id, :scope => :work_id
Then a user would not be allowed to review a already reviewed work.
#Sharvy Ahmed's answer is definitely the best, as long as the case is simple enough – the OP's case seems like one of them.
However, if the conditions are more complex, you may need/want to write your custom validation. For that purpose, here's an example (checked with Rails 6.0).
class NoDuplicateReviewValidator < ActiveModel::Validator
def validate(record)
dup_reviews = Review.where(user_id: record.user,
work_id: record.work)
dup_reviews = dup_reviews.where.not(id: record.id) unless record.new_record?
if dup_reviews.count > 0
record.errors[:duplicate] << "No duplicate reviews!"
end
end
end
The idea is,
In create, all the relevant DB records retrieved with where can and should be used to judge the uniqueness. In the example new_record? is used to check it out, but it is actually redundant (because nil id matches no records).
In update, the DB row of the record to update must be excluded from the unique comparison. Otherwise, the update would always fail in the validation.
The count method is slightly more efficient in terms of DB transaction.
How do you write validations for a number of associations that is externally defined? I've so far written something like this:
class Document
validate :publication_count
private
def publication_count
if publications.count > template.component_count
errors.add(:articles, 'too many')
elsif publications.count < template.component_count
errors.add(:articles, 'not enough')
end
end
Both publications and template are associations. I just get a rollback error with this code, even though the record should be valid.
Your code appears correct, so it seems likely that the associations aren't being set or saved correctly.
Did you check that:
publications and template are both assigned to the Document instance before you save?
the rollback error isn't for a different reason, like uniqueness failure?
this is the actual validation that's failing rather than another one?
I have a UserReport model that connects a User model and a Report model. (has many through association).
I have another model called Comment which belongs to UserReport. (has many association)
When a report is created I need to create a UserReport for all users with one default comment.
My question is how to do that in a way that will rollback the report creation if any one of the child records fail to save.
My goal is to ensure that the DB will not stay in in-consisted state.
Any suggestions?
You want something called a transaction. The code would look something like
begin
Report.transaction do
# create report like Report.create! or something
# create comments like Comment.create! or something
end
rescue
# there was an error
end
Inside the transaction, if an error is thrown the database is reverted to what it was before the entire transaction was begun. In the rescue, you can handle any errors that were thrown.
When you save a model, the entire process is wrapped in a transaction that will be rolled back if the save fails (due to validations, callbacks, etc). So if you build your whole object tree in memory first, then attempt to save the report, none of your objects will be saved if there are any failures.
Here's an example of how you might do this:
# in report.rb
class Report < ActiveRecord::Base
validates_associated :user_reports
end
# in user_report.rb
class UserReport < ActiveRecord::Base
validates_associated :comments
end
# in your controller or wherever you're doing this
report = Report.new
User.pluck(:id).each{ |user_id| report.user_reports.build(user_id: user_id) }
report.user_reports.each{ |user_report| user_report.comments.build }
report.save # will always save either everything or nothing, no inconsistencies
Note the use of #new and #build to avoid committing anything until the final line. The validates_associated lines in the models cause any validation errors on the child objects to propagate to the parent object, preventing it from saving even if the parent object itself passes validation.
I'm trying to use ActiveRecord's find_or_create_by_*column*, but I'm getting errors from Postgres letting me know that it occasionally fails to find the model, and tries to insert one anyways. It's really important that I keep this table unique, so I added a :unique => true attribute to its migration, so that Postgres would know that I was serious about it.
And, fail:
ActiveRecord::StatementInvalid: PGError: ERROR: duplicate key value violates unique constraint "index_marketo_leads_on_person_id" DETAIL: Key (person_id)=(9968932) already exists. : INSERT INTO "marketo_leads" ("mkt_person_id", "synced_at", "person_updated_at", "person_id") VALUES(NULL, NULL, '2011-05-06 12:57:02.447018', 9968932) RETURNING "id"
I have models like so:
class User < AR::Base
has_one :marketo_lead
before_save :update_marketo_lead
def update_marketo_lead
if marketo_lead
if (User.marketo_columns & self.changes.keys).any?
marketo_lead.touch(:person_updated_at)
end
elsif self.id
marketo_lead = MarketoLead.find_or_create_by_person_id(:person_updated_at => Time.now, :person_id => self.id)
end
end
end
class MarketoLead
belongs_to :user, :foreign_key => 'person_id'
end
The second model is used for linking our users accounts to the Marketo email server, and keeping a record of the last time certain fields of the user was modified, so that we can push changed records in batched background tasks.
I can't think of any reason for this callback, update_marketo_lead to fail, other than some kind of race condition that I can't quite imagine.
(please ignore the horribleness of 'user' sharing a primary key with 'person')
(using Rails 2.3.11, Postgres 9.0.3)
Its quite possible that when find_or_create was executed, matching person_id was not found, so create logic was used, however its possible that between find_or_create and actual user.save, another request managed to complete save transaction and at that point your Database constraint caused this exception.
What I would recommend is to catch StatementInvalid exception and to retry saving(up to a finite number of times...
begin
user.save!
rescue ActiveRecord::StatementInvalid => error
#save_retry_count = (#save_retry_count || 5)
retry if( (#save_retry_count -= 1) > 0 )
raise error
end
Note this should be executed wherever you try to save the user. All callbacks and validations are happening within save! transaction
P.S. Im assuming your version of rails supports transactions :) In Rails 3 its unnecessary to wrap save! in transaction because it already uses one internally
I'm hitting this inside a sidekick job that retries and gets the error repeatedly and eventually clears itself. I'm not convinced its a race condition from another request or it would be really rare and only happen once or twice but not 11 consecutive times like I'm seeing. The best explanation I've found is on a blog post here. The gist is that postgres keeps an internally stored value for incrementing the primary key that gets messed up somehow. This rings true for me because I'm setting the primary key and not just using an incremented value so maybe that's how this cropped up. The solution from the comments in the link above appears to be to call ActiveRecord::Base.connection.reset_pk_sequence!(table_name)
I can't verify this yet because I couldn't repro the issue, but my attempted fix, modified from Vladimir's fix above is:
begin
user.save!
rescue ActiveRecord::StatementInvalid => error
#save_retry_count = (#save_retry_count || 1)
ActiveRecord::Base.connection.reset_pk_sequence!(:user)
retry if( (#save_retry_count -= 1) >= 0 )
raise error
end
So if this doesn't fix it on the first try I'll see an error raised