Lately I acquired a new hobby. I went around and asked experience Rails developers, whom I respect and value a lot, how many users the following script would create:
The result should be the same on pretty much any database and any Rails version. For the sake of argument you can assume Rails 5.1 and Postgres 9.6 (what I tested it with).
So, how many users does it create? No one from more than a hand full of people I asked got the answer right (including myself).
The answer is 2.
Yup you read that right. It creates 2 users, the rollback is effectively useless here. Ideally this should create one user (Kotori), but as some people know nested transactions isn’t really a thing that databases support (save for MS-SQL apparently). People, whom I asked and knew this, then guessed 0 because well if I can’t rollback a part of it, better safe than sorry and roll all of it back, right?
Well, sadly the inner transaction rescues the rollback and then the outer transaction happily commits all of it. 😦
Before you get all worried – if an exception is raised and not caught the outer transaction can’t commit and hence 0 users are created as expected.
So, what can we do? When opening a transaction, we can pass
requires_new: true to the transaction which will emulate a “real” nested transaction using savepoints:
|User.transaction(requires_new: true) do|
As you’d expect this creates just one user.
Nah, doesn’t concern me I’d never write code like this!
Sure, you probably straight up won’t write code like this in a file. However, split across multiple files – I think so. You have one unit of business logic that you want to run in a transaction and then you start reusing it in another method that’s also wrapped in another transaction. Happens more often than you think.
Plus it can happen even more often than that as every save operation is wrapped in its own transaction (for good reasons). That means, as soon as you save anything inside a transaction or you save/update records as part of a callback you might run into this problem.
Here’s a small example highlighting the problem:
|class User < ApplicationRecord|
|raise ActiveRecord::Rollback if rollback|
|User.create(name: "someone", rollback: true)|
As you probably expect by now this creates 2 users. And yes, I checked – if you run create with
rollback: true outside of the transaction no user is created. Of course, you shouldn’t raise rollbacks in callbacks but I’m sure that someone somewhere does it.
In case you want to play with this, all of these examples (+ more) are up at my rails playground.
The saddest part of this surprise…
Unless you stumbled across this before, chances are this is at least somewhat surprising to you. If you knew this before, kudos to you. The saddest part is that this shouldn’t be a surprise to anyone though. A lot of what is written here is part of the official documentation, including the exact example I used. It introduces the example with the following wonderful sentence:
For example, the following behavior may be surprising:
Why do I even blog about this when it’s in the official documentation all along? I think this deserves more attention and more people should know about it to avoid truly bad surprises. The fact that nobody I asked knew the answer encouraged me to write this. We should all take care to read the documentation of software we use more, we might find something interesting you know.
What do we learn from this?
READ THE DOCUMENTATION!!!!