User Tools

Site Tools


tutorial:transfer-api_transactions

The Transaction system

This article is part of a series on the Fabric Transfer API. Link to the home page of the series.

Motivation

When interacting with inventories, we often want to perform multiple related operations. For example, we might want to either perform all of the following actions, or none of them:

consumeWater();
consumeLava();
produceObsidian();

In the absence of transactions, such a process is usually implemented with the following “simulation” pattern:

if (canConsumeWater() && canConsumeLava() && canProduceObsidian()) {
    // "Simulation" succeeded, let's actually do it
    doConsumeWater();
    doConsumeLava();
    doProduceObsidian();
}

While this seems correct, it is possible that doConsumeWater succeeds but doConsumeLava fails. For example, if we need to use some power to consume a fluid. Consider the following sequence:

  • Our tank has water, lava, and 1 unit of power.
  • canConsumeWater: we have water and 1 unit of power → true.
  • canConsumeLava: we have lava and 1 unit of power → true.
  • doConsumeWater: we have water and 1 unit of power → OK. This time we consume the power.
  • doConsumeLava: we have lava but we do not have power anymore.

We are now in a broken state where water was consumed but lava cannot be consumed. Either we abort the process and accept that water was deleted, or we continue the process and we still produce the obsidian. Either we delete resources that we shouldn’t, or we create resources that we shouldn’t.

There exist workarounds for this problem in some cases, but in an ideal world we could do simply store a copy of the internal state of the game, and revert to it if we are unhappy with what happened:

// Store a full copy of the game state
 
if (consumeWater() && consumeLava() && produceObsidian()) {
    // All good, all three steps worked!
} else {
    // Some step failed - restore the state back to our old copy.
}

This is exactly what the transaction system will allow us to do:

// Start a transaction - equivalent to a copy of the relevant game state.
try (Transaction tx = Transaction.openOuter()) {
 
    if (consumeWater(tx) && consumeLava(tx) && produceObsidian(tx)) {
        // All good, all three steps worked - confirm the transaction.
        tx.commit();
    } else {
        // Some step failed - restore the state back to the transaction start.
        // As we will see below, abort() is the default and can be omitted.
        tx.abort();
    }
 
}

The transaction concept

To better illustrate how transactions operate, let’s assume that we have a transactional integer with the following methods:

public class TransactionalInteger {
    // Create a new instance
    public TransactionalInteger(int startingValue) { ... }
    // Get current integer value
    public int get() { ... }
    // Transaction-aware increment function.
    public void increment(TransactionContext transaction) { ... }
}

We will see later how these methods can be implemented, for now let’s focus on how the state can be manipulated with transactions.

Let’s start with a simple example, showing a single transaction that gets aborted:

TransactionalInteger o = new TransactionalInteger(0);
o.get(); // returns 0
 
try (Transaction t1 = Transaction.openOuter()) {
    o.get(); // still 0
 
    o.increment(t1);
    o.get(); // returns 1
 
    // At the end of try, the transaction is aborted if it was not committed.
}
 
o.get(); // returns 0 again

Here is how transactions can be visualized:

  • Opening a new transaction creates a new copy of the state. From now on, that copy is modified.
  • Aborting a transaction discards that copy. Back to the original state.
  • Committing a transaction replaces the original state by the modified copy. From now on, this is the new state.

We can represent this in a graph with branches:

  • Any modification operates on the top branch.
  • Opening a new transaction creates a new branch.
  • Aborting a transaction discards the top branch.
  • Committing a transaction merges the top branch into the branch below it.

Here is the branching graph for that first example:

Nested transactions

We can also have nested transactions, i.e. sub-transactions. They work in the same way as the so called “outer” transactions.

TransactionalInteger o = new TransactionalInteger(0);
o.get(); // returns 0
 
try (Transaction t1 = Transaction.openOuter()) {
    o.get(); // still 0
    o.increment(t1);
    o.get(); // returns 1
 
    try (Transaction t2 = t1.openNested()) {
        o.increment(t2);
        o.increment(t2);
        o.get(); // returns 3
 
        try (Transaction t3 = t2.openNested()) {
            o.increment(t3);
            o.get(); // returns 4
 
            // At the end of try, the transaction is aborted if it was not committed.
        }
 
        o.get(); // returns 3 again because t3 transaction was aborted 
 
        t2.commit();
    }
 
    o.get(); // returns 3
 
    t1.commit();
}
 
o.get(); // returns 3

Here is the corresponding graph:

Transaction vs TransactionContext

You might have noticed that sometimes we use Transaction and sometimes we use TransactionContext. The former has some additional functions that are only relevant to the code that opened the transaction. The rule of thumb is:

  • Use Transaction in code that opens and closes transactions.
  • Use TransactionContext in code that implements transaction-aware operations.

Implementing support for transactions

In this section we explain how TransactionalInteger can be written. For that, we will use SnapshotParticipant that will do the heavy lifting for us. ALWAYS use SnapshotParticipant, NEVER use the raw primitives directly (TransactionContext#addCallback and TransactionContext#addOuterCallback).

A SnapshotParticipant saves copies of its internal state and restores them when required. These copies are referred to as “snapshots”, hence the name. Using a SnapshotParticipant is generally quite simple:

  1. Choose a data type to represent copies of internal state. Usually this will be a record. Here we will use Integer.
  2. Extend SnapshotParticipant<internal state>. Here we will add extends SnapshotParticipant<Integer> to our class.
  3. Implement functions to create copies of the internal state, and restore copies thereof - respectively createSnapshot and readSnapshot.
  4. Call updateSnapshots(transaction) before any change to the internal state.

Let’s start with the following template:

public class TransactionalInteger {
    private int value;
 
    // Create a new instance
    public TransactionalInteger(int startingValue) {
        this.value = startingValue;
    }
 
    // Get current integer value
    public int get() {
        return value;
    }

First, we implement the state saving and restoring logic:

public class TransactionalInteger extends SnapshotParticipant<Integer> {
    // Previous methods omitted
 
    @Override
    protected Integer createSnapshot() {
        // Return a copy or "snapshot" of our internal state
        return value;
    }
 
    @Override
    protected void readSnapshot(Integer snapshot) {
        // Restore our internal state to a previous snapshot
        this.value = snapshot;
    }
}

Once this is done, we can implement increment as follows. Always remember to call updateSnapshots before the internal state is modified.

    public void increment(TransactionContext transaction) {
        // First, save a snapshot of the state if needed
        updateSnapshots(transaction);
        // Then, modify the value
        value++;
    }

That’s it!

Correctly saving changes

If we need to perform an operation after a change, we can override onFinalCommit. It will only be called when the SnapshotParticipant is involved in a transaction that is committed. In other words, it will only be called if some modification made its way back to the bottom of the branching graph.

For example, if we modified the internal state of a block entity, we should make sure to call markDirty at the end:

    @Override
    protected void onFinalCommit() {
        // Make sure to call markDirty 
        blockEntity.markDirty();
    }

Technical details

This section explains the details of how the SnapshotParticipant system works. Feel free to skip it if this is not interesting to you.

The goal of SnapshotParticipant is to bridge the gap between the transaction programming model (opening a transaction copies the game state, etc…) and an efficient implementation.

The SnapshotParticipant stores up to one snapshot per transaction - tracking the state to which it should revert if said transaction were aborted. To minimize data copies, the snapshots are only created lazily.

For performance reasons, aborting or committing a transaction just runs a list of actions, but does not worry about more complex things such as nesting or state copies. See Transaction#addCloseCallback. All of the state management logic is thus part of the SnapshotParticipant itself.

Now that we have been through this background knowledge, here is how the SnapshotParticipant operates:

  • When updateSnapshots is called:
    • If we already have a snapshot for this transaction, do nothing.
    • Otherwise call createSnapshot to save a snapshot, and add a close callback.
  • When a transaction is aborted:
    • We are guaranteed to have a snapshot for that transaction due to how state is managed.
    • Call readSnapshot to revert the state changes.
  • When a transaction is committed:
    • If this is an outer (= not nested) transaction, the change is confirmed.
      • We know that something probably changed, otherwise we would not have a registered close callback.
      • Call addOuterCloseCallback. The callback will call onFinalCommit.
    • If this is a nested transaction, we need to deal with the snapshot:
      • If the parent transaction already has an older snapshot, discard the more recent snapshot.
      • Otherwise the snapshot is moved to the parent transaction.

Hopefully that gives an overview of what is happening under the hood. You should now be ready to read the source code of SnapshotParticipant.

tutorial/transfer-api_transactions.txt · Last modified: 2023/07/19 14:00 by technici4n