PostgreSQL Transaction and Rails callbacks

Hi all, in theese days I am working on a new little feature on a Rails project. I have to generate a random and unique token for every new record of a model.

Unfortunately I have encountered a strange error ActiveRecord::StatetementInvalid. Let’s see some code and understand why this happens.

I have created a simple project (Git Repo) with a single model, User, that has email and token as columns. I want to generate automatically a unique random token for each user, so in my db migration I have added an index on the token column:

...
add_index :users, :token, unique: true
...

And in my model I have added an after_create callback:

class User < ActiveRecord::Base
  after_create :generate_token

  private

  def generate_token
    update_column :token, SecureRandom.hex(8)
  end
end

This works great and generates a random token, but at the moment there is no control on its uniqueness; Rails simply raises an ActiveRecord::RecordNotUnique error if the token is not unique.

Well, I can rescue this error and retry to generate a new token.

...
  def generate_token
    update_column :token, SecureRandom.hex(8)
  rescue ActiveRecord::RecordNotUnique
    retry
  end
...

Now I have a model that retries generating a new token until it is unique, Great!

Oh yeah, works great; until you switch from the little cute SQLite to the super powered PostgreSQL!

With PostgreSQL this code ends in another error ActiveRecord::StatetementInvalid raised after a PostgreSQL error PG:InFailedSqlTransaction.

With PostgreSQL I can’t run a new SQL query if a previous query, in the same transaction, fails.

So if I want to preserve the behavior of my model I have to use a different callback.

The problem is that I am making an invalid update_column during a transaction, to solve this I can use the after_commit callback. When the record is saved I regenerate the token until it is unique, and this happens out of the previous transaction.

class User < ActiveRecord::Base
  after_commit :generate_token, on: :create

  private

  def generate_token
    update_column :token, SecureRandom.hex(8)
  rescue ActiveRecord::RecordNotUnique
    retry
  end
end

The only one cons of this solution are the tests, to make it pass I need to use the database_cleaner gem with :truncation as strategy. Otherwise in tests Rails does not fire the after_commit trigger.

RSpec.configure do |config|

  config.before(:suite) do
    DatabaseCleaner.strategy = :truncation
    DatabaseCleaner.clean_with(:truncation)
  end

  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end

end

There is another couple of different solutions to archive this result, maybe a trigger at the database level or something else, but for now this one works.

To avoid an almost impossible, but possible, infinite loop I’ve added a retry limit, that you can find in the Repo.

You can find also an another branch sqlite with the SQLite version of the code.

I hope this article has been helpful, bye!

Leave a Reply

wpDiscuz