loyalty.dev

Returning from transactions in Rails 6, 7, and 8

ActiveRecord provides a simple, block-based API for working with database transactions. Any exception from within the block will roll back the transaction. This works great for simple cases, but what happens if you return from within the block?

Transactions 101

The #transaction method in ActiveRecord provides a tool for describing a database transaction in Ruby, which in turn gives us some "all-or-nothing" guarantees around the queries in the block as long as we're using "bang" methods inside the transaction.

For example, in the following case, we would either end up with a debit and a credit transaction, or neither, guaranteeing that accounts always balance:

def transact
  ActiveRecord::Base.transaction do
    Debit.create!(...)
    Credit.create!(...)
  end
end

So far everything looks great. The API is simple and intuitive in the above example.

The problem

Unfortunately nothing is stopping us from using any other Ruby control-flow keywords inside the transaction block, and unlike raise, with a well-defined behaviour, it is not immediately clear what it should mean to do so.

Consider the following example:

def transact
  ActiveRecord::Base.transaction do
    Debit.create!(...)

    return if condition?

    Credit.create!(...)
  end
end

If condition? is true, and we enter the return branch of the code, what should happen? Should the transaction roll back? Should it commit partially? Or should an exception be raised? These are all plausible options, but neither seems necessarily more intuitive than any other. (You could also theoretically leave the transactions "dangling", but this is probably not a good option.)

You might think that this is a contrived example, but it is surprisingly common. It tends to happen when making changes to the code at a later point, when the author might not be thinking carefully about what transactions are and how they work. At first glance it seems like a reasonable way to get the #transact method to return a particular value.

Although far less common, Ruby also provides next, break, and throw, all of which also alter control flow and have poorly defined interactions with transactions.

Breaking changes in Rails

In Rails 6, using return inside a transaction will partially commit the preceding statements to the database. This might seem like it undermines the whole idea of database transactions, and that is true to some degree, but since the API lends itself well to fuzzy thinking, many applications have come to depend on this behaviour regardless. Later versions of Rails 6 will give you a deprecation warning if you do break out of transactions early.

In Rails 7, using return inside a transaction will roll the transaction back. This is arguably a slightly more intuitive behaviour, but it's a breaking change between the major versions. It seems this change was potentially imparted by mistake, and (from our understanding) the author might have intended for an exception to be raised.

In Rails 8, using return looks like it's going to raise an exception. This is potentially the "best" approach to this problem, as it will force the developer to stop and think rigorously about what it is they are trying to accomplish. Although exceptions happen only at runtime, so users with lacking test coverage might still be in for a painful surprise after the upgrade.

Do notation does work

It's worth mentioning that "do notation" as implemented in for example dry-monads (and some of our internal gems here in Ascenda) are compatible with database transactions, as they rely on exceptions for control flow. This seems to be a deliberate decision on behalf of the authors for exactly this reason.

Could the API be redesigned?

This whole debacle has made me think about whether there are ways to design the transaction API in a way that is more explicit. Perhaps the #transaction method could yield an object which is used to commit or roll back the transaction. However, that still does not preclude the use of control flow keywords within the block, and so wouldn't solve the root problem.

Maybe you have an idea how to design a better database transaction API?

If you do, and you think you want to come and work with us on this (and other) technical challenges, have a look at our open roles at our careers page.