Postgres transaction pitfalls for rails developers

1 minute read

Rails abstract lot of database stuff away using Active record which is very convenient. But the convenience can bite back if we are not careful enough.

Here I am going to list some common mistakes rails developers make and how to avoid them.

  1. Having network calls on rails after_create/before_create callbacks

Active record provides callback methods to perform some operations based on the changes in data. it includes before_create, after_create, after_commit etc. Rails wrap before_create and after_create inside the same transaction so that it can roll back when the exception is raised from any of these callbacks. after_commit is executed after the transaction is committed to the database. So if you have calls like

class MyModel <ApplicationRecord
  before_create :fetch_data_from_server

  def fetch_data_from_server
    self.attribute = SlowApi.new(self).call
  end
end

The transaction will remain open for long time and that will cause locks and potentially deadlocks on tables.

Solutions:

1. Make network calls before transaction begins, ie before the save method is called

2. Make the API call in after commit callback
  1. Scheduling Background jobs from after_create/before_create callbacks

This is very similar to the previous point, as the backgrounds jobs typically use another data store like Redis and the communication will be over the network. This may not be significant on a small scale if the Redis is in the same infra and network latency is not significant. But any performance degradation on redis infra will cause a sudden spike in long transactions and can potentially cause cascading failures on the infrastructure.

Solutions:

1. Schedule background jobs from after_commit callback
2. Move job scheduling out of rails callbacks
  1. Nested transactions

When you have nested services you might end up with nested transactions as well.

class OrderCreator
  def call
    Order.transaction do
      adjust_currency_conversion
      ...
      InventoryUpdateService.new.call
    end
  end
end

class InventoryUpdateService
  def call
   Inventory.transaction(require_new: true) do
     deduct_inventory_from_primary_store
     run_inventory_sync
   end
  end
end

This can cause performance bottlenecks due to the SAVEPOINT behaviour, Skipping it here as Gilab wrote a great blog on this here

  1. Last but the not least is about adding transaction block where it is not necessary, for instance if there won’t any data corruption if the operations ran individually, or the impact is very minimal which gets fixed on background worker retry, then avoid using database transactions

Also checkout these two great gems which allows us to reduce transaction burdern on database.

  1. after_commit_everywhere
  2. isolator

Updated:

Comments