Transactions are not very hip anymore - so unhip, in fact, that people started building databases without them. Alas, as people who decided to try those databases found out, transactions remain a fundamental aspect of applications that don’t break horrifically.
Except, it turns out that even if you have transactions, it’s surprisingly easy to shoot yourself in the foot.
0. Overview
This post is about using transactions in asynchronous APIs, specifically when using Promises in javascript. However, for illustratitive purposes, it’ll first go through anti-patterns and safe patterns of use in blocking code.
Best of all, I’ll do this using a test harness that verifies a given pattern is safe. I want to use the word “proves”, but I don’t feel comfortable enough in my knowledge of academia to use that word.
The full source code backing this post can be found on Github.
1. The Problem
Imagine we have a use case like this:
begin transaction
if domain logic is true:
commit transaction
else:
rollback transaction
The implementation must commit or roll back exactly once - but never do both - and if domain logic is true, it must commit. The problem is that any of the actions may fail, the pattern we choose needs to handle those failures without violating the rules.
To test this, we wrote a tiny test harness that can take a pattern, and tell you if it will violate any of the rules.
We do this first by definining all the available actions, and their possible outcomes. For the blocking case, one action is begin()
, and it has two possible outcomes - either it succeeds or it throws an exception:
let begin = action('begin', FAIL, SUCCEED);
function FAIL() {
throw new Error("Induced failure.");
}
function SUCCEED() {
}
Then we define a set of valid outcomes, or valid sequences of actions. If the code pattern we’re testing invokes actions in a sequence we haven’t explicitly said is valid, the pattern fails the test.
For the blocking use case, one of the rules is that if domain logic fails, it must roll back. We define this by writing it up as a valid sequence of actions:
let valid_sequences = [
[[begin, SUCCEED], [business_logic, FAIL], [rollback]],
];
Then we give the available actions, the list of valid sequences and the pattern to test to the harness, and it will tell us if any combination of action outcomes causes the pattern to perform an invalid sequence.
test_correctness(actions, valid_sequences, pattern)
If a scenario is found where the pattern fails validation, we get a description like this:
Pattern failed validation
Scenario: begin -> SUCCEED
business logic -> FAIL
commit -> FAIL
rollback -> FAIL
Sequence: begin, business logic
You can find the source code for the tester here.
2. Blocking patterns
Remembering our use case:
begin transaction
if domain logic is true:
commit transaction
else:
rollback transaction
A naive approach
Lets try just implementing the use case verbatim.
begin();
if( business_logic() ) {
commit();
} else {
rollback();
}
Unfortunately, this doesn’t work, says our test. Four different cases cause bad outcomes, all variants of the same case:
Pattern failed validation
Scenario: begin -> SUCCEED
business logic -> FAIL
commit -> FAIL
rollback -> FAIL
Sequence: begin, business logic
Note how the “Sequence” section above is just begin, business logic
, no commit or rollback. In the Scenario
section, we can see that the business logic
action is set to fail.
Looking at the code, if the business logic throws an exception, the code never calls commit or rollback, leading to a leaked transaction.
Handling exceptions
Fine, so we add a try/catch then.
begin();
try {
if( business_logic() ) {
commit();
} else {
rollback();
}
} catch( e ) {
rollback();
}
Again, no dice, four cases had bad outcomes. Each is one of two variants:
Pattern failed validation
Scenario: begin -> SUCCEED
business logic -> RETURN_TRUE
commit -> FAIL
rollback -> FAIL
Sequence: begin, business logic, commit, rollback
Pattern failed validation
Scenario: begin -> SUCCEED
business logic -> RETURN_FALSE
commit -> FAIL
rollback -> FAIL
Sequence: begin, business logic, rollback, rollback
That is - either this pattern calls rollback twice, or it calls rollback after it’s called commit. This is better, but with most databases the second call to rollback would’ve caused an error, meaning this pattern does not elegantly handle failure and could make it hard to find the root cause.
A correct pattern
One common and correct approach is this:
success = false;
begin();
try {
if( business_logic() ) {
success = true;
}
} finally {
if(success) {
commit();
} else {
rollback();
}
}
This passes all permutations without breaking the rules, meaning it won’t leak transactions and it won’t obfuscate errors by causing secondary errors. However, it is a bit verbose.
A correct, and less verbose, pattern
This is why Neo’s legacy Java API, and now all our blocking Driver APIs, don’t expose commit
and rollback
on the transaction interface. Instead, the pattern we just looked at is enshrined in the API.
let tx = begin();
try {
if( business_logic() ) {
tx.success();
}
} finally {
tx.finish();
}
Or, if you are using a language with context handlers:
with begin() as tx:
if business_logic():
tx.success()
try(Transaction tx = begin()) {
if(business_logic()) {
tx.success();
}
}
3. Asynchronous patterns
The asynchronous case more complex. Each action may now fail in two ways: it can either immediately throw an exception, or the promise it returns can be rejected.
We first tried the pattern another vendor recommends in their documentation:
begin()
.then(business_logic)
.then((outcome) => {
if(outcome) {
commit();
} else {
throw Error("Exception to trigger rollback.")
}
})
.catch((error) => rollback());
Unfortunately, this suffers from the “secondary failure” problem; if commit()
fails, this pattern will then trigger rollback()
, which will obscure the first failure:
Pattern failed validation
Scenario: begin -> SUCCEED
commit -> THROW
rollback -> SUCCEED
business logic -> RETURN_TRUE
Sequence: begin, business logic, commit, rollback
A correct pattern
The Promise API seems unclear about whether it supports finally
. Some implementations do, others don’t. In any case, one way to solve our whoes is to leverage a Promise API that supports finally
. Then we can run an adapted version of our blocking pattern:
let success = false;
begin()
.then(business_logic)
.then((outcome) => {
if(outcome) {
success = true;
}
})
.finally(() => {
if(success) {
commit();
} else {
rollback();
}
});
If you don’t have finally
, you can achieve the same thing by chaining catch
and then
:
let success = false, error;
begin()
.then(business_logic)
.then((outcome) => {
if(outcome) {
success = true;
}
})
.catch((e) => error = e)
.then(() => {
if(success) {
commit();
} else {
rollback();
}
if(error) { throw error; }
});
Phew. That’s getting mighty long.
A correct, and less verbose, pattern
Part of designing APIs like this is to make the right thing be the obvious thing to do. While the patterns above are correct, they are not obvious - in fact, they are horrendously obscure and brittle.
Javascripts excellent support for closures can be used almost the same way the try-with-resources
or with
examples I showed from Java and Python further up. Scratching our heads a bit, it seems there’s at least one pattern where that can be safely used with Promises:
transactionally((tx) => {
return business_logic().then((outcome) => {
if(outcome) {
tx.success();
}
});
});
If you’re interested in the transactionally
function, it’s defined here.
This has one important caveat: the user must have that return in there, or the transactionally
sugar can’t know when to wrap the transaction up. However, we can guard against this by mandating that something is returned.
It’s not quite as terse as the blocking code examples, but it’s reasonably simple, and rather hard to use incorrectly.
In order to support more advanced and specialized use cases, we’ll keep the raw commit
and rollback
functions available in the Javascript driver today, and instead propose to augment the driver with this pattern, and to make this the default in our documentation.
Finally
All the code for this is available here. If you think you’ve got a pattern that’s easier, faster, smarter - clone the repo and see if it passes the test :)