We use cookies to enhance your experience on the site
CodeWorlds

Transactions - safe operations on tributes

The legionary Marcus hands a golden chalice to Gaius. In code that is two operations: subtract the chalice from Marcus's inventory, add it to Gaius's. You perform the first one, and at that very moment the database server goes down. The result? The chalice has vanished from the Empire's treasury. Marcus no longer has it, Gaius never received it - it evaporated between two writes.

The treasury cannot afford that. We need a way to tell the database: "these two operations are one order - perform both or neither." That way is a transaction.

Atomicity and the rest of ACID

The property we are after is called atomicity - a transaction is indivisible like an atom: it cannot be performed halfway. It is the first letter of the acronym ACID, which describes a transaction's guarantees:

  • Atomicity - all the operations, or none of them,
  • Consistency - the database moves from one valid state to another, never staying in between,
  • Isolation - transactions running in parallel do not peek at each other's unfinished changes,
  • Durability - once committed, the changes survive even a power failure.

Remember the A above all - the rest follows from it, and it is what our chalice is about.

The transaction lifecycle

Every transaction walks the same road, whatever the database or the language. First BEGIN - we announce the start, and from that moment the database records changes on the side, as impermanent. Then we perform the operations - our two writes. Next we check that everything succeeded. And finally one of two roads: COMMIT confirms the whole thing and only now do the changes become permanent, or ROLLBACK undoes everything as if the transaction had never happened.

This scheme - begin, perform, verify, commit or roll back - is worth holding in your head before we look at code. All the rest of this lesson is two ways of writing it down in TypeORM.

The first way: a managed transaction

The simplest variant is

dataSource.transaction()
. You pass a function, and TypeORM opens the transaction before calling it and closes it afterwards:

1await this.dataSource.transaction(async (manager) => {
2  await manager.update(Tribute, tributeId, { legionariusze: { id: toLegionaryId } });
3  await manager.increment(Legionary, { id: toLegionaryId }, 'tributeCount', 1);
4  await manager.decrement(Legionary, { id: fromLegionaryId }, 'tributeCount', 1);
5});

The function receives one argument:

manager
- the equivalent of a repository, but bound to this particular transaction. That is the most important detail of this code: every operation must go through
manager
. If you reached inside for a plain
this.tributeRepository.save(...)
, that write would run outside the transaction - and would not be undone on error.

So how does the database know whether to commit or roll back? From exceptions. If the function runs to its end peacefully, TypeORM performs a COMMIT. If anything inside throws - it performs a ROLLBACK and rethrows the exception. You write no error-handling line at all - and that is what I recommend as the default choice, @name.

The second way: QueryRunner, or full control

Sometimes you need to steer the transaction by hand - to do something between operations, say, or to react to one specific error. Then you reach for a QueryRunner: an object representing one exclusive database connection, on which you call each stage yourself.

1const queryRunner = this.dataSource.createQueryRunner();
2
3await queryRunner.connect();
4await queryRunner.startTransaction();
5
6try {
7  await queryRunner.manager.update(Tribute, tributeId, {
8    legionariusze: { id: toLegionaryId },
9  });
10  await queryRunner.manager.increment(
11    Legionary, { id: toLegionaryId }, 'tributeCount', 1,
12  );
13
14  await queryRunner.commitTransaction();
15} catch (error) {
16  await queryRunner.rollbackTransaction();
17  throw error;
18} finally {
19  await queryRunner.release();
20}

Let's walk this code step by step, because every line matches one stage of the lifecycle you met above.

createQueryRunner()
creates the object,
connect()
reserves a connection from the pool for it, and
startTransaction()
is our BEGIN. We perform operations through
queryRunner.manager
- again the same condition as before: only what goes through this runner's manager belongs to the transaction. The
try
block ends with
commitTransaction()
, while
catch
calls
rollbackTransaction()
and rethrows, so the layer above knows the transfer failed.

The last part matters most.

release()
must sit in
finally
, because the connection has to return to the pool in every scenario - after success and after failure alike. Skipping it will not break a single transfer; the consequence shows up later, when the pool runs out of free connections and the whole application stalls. This is the most common mistake with manual transactions.

Note also what

release()
does not do: it neither commits nor rolls back anything. If you release a runner without committing, the transaction is discarded - freeing the connection is not saving the changes.

Isolation - transactions side by side

That leaves the I from ACID. When two transactions run at the same time, the database must decide how much one sees of the other's unfinished work. You pass the level of that separation as an argument:

1await queryRunner.startTransaction('SERIALIZABLE');

The default level is usually

'READ COMMITTED'
- you see only changes others have already committed.
'SERIALIZABLE'
is the strictest level: transactions execute as if queued, one after another. It gives the strongest guarantees, but at the cost of throughput - the database more often forces one transaction to back off and retry. That is why we raise the level deliberately and only where it is truly needed, for instance on treasury balance operations.

Summary

Marcus's chalice will never again be lost halfway:

  • a transaction is a group of operations executed atomically - all of them, or none,
  • the acronym ACID describes its guarantees, and the letter A is precisely atomicity,
  • the lifecycle is always the same: BEGIN, operations, verification, COMMIT or ROLLBACK,
  • dataSource.transaction(async manager => {...})
    runs that cycle for you: a clean exit means COMMIT, an exception means ROLLBACK,
  • QueryRunner gives full control:
    createQueryRunner
    ,
    connect
    ,
    startTransaction
    , then
    commitTransaction
    in
    try
    ,
    rollbackTransaction
    in
    catch
    and
    release
    in
    finally
    ,
  • in both variants the operations must go through the transaction's
    manager
    - a plain repository works outside it,
  • the isolation level decides how much transactions see of each other's unfinished work.

In the next lesson you will meet seeders - the quartermasters who equip a fresh database with starting data. For now remember: a transaction is one order made of many moves - the database will carry it out whole, or pretend it never heard it.

Go to CodeWorlds