Transaction

A restio Transaction coordinates the context of persistent operations to a remote REST API.

Transactions benefit from the relationship between Data Access Objects and Models to decide how data should be persisted on the remote server by tracking how changes were made to models.

A DAO EmployeeDAO can be mapped to a Model Employee in a Transaction by running Transaction.register_dao:

See also

You can find the implementation of Employee and EmployeeDAO in Data Access Object:

from restio.transaction import Transaction

from myproject.models import Employee
from myproject.dao import EmployeeDAO

transaction = Transaction()

# informs the transaction about the relationship between EmployeeDAO and Employee
transaction.register_dao(EmployeeDAO(Employee))

A transaction instance is supposed to resemble a database transaction, but used within the context of REST APIs. With this module, we try to solve common problems faced when using a REST API, such as caching, persistency, state management and performance.

For example, we can use the transaction to retrieve an Employee from the remote server:

employee = await transaction.get(Employee, 1)  # Employee with primary key 1

print(employee)  # Employee(key=1, name="John Doe", age=30, address="The Netherlands")
print(id(employee))  # 123456

Trying to retrieve the same employee will not result in a new call to the server, and instead will bring the object from the Transaction cache:

employee = await transaction.get(Employee, 1)

print(employee)  # Employee(key=1, name="John Doe", age=30, address="The Netherlands")
print(id(employee))  # 123456

employee_again = await transaction.get(Employee, 1)  # same employee

print(employee_again)  # Employee(key=1, name="John Doe", age=30, address="The Netherlands")
print(id(employee_again))  # 123456

Note

Please visit Strategies for more information about caching.

Differently from a normal relational database, a generic REST API should be stateless and doesn’t implement transactions. Therefore, it is never guaranteed that the transaction is atomic when interacting with the remote server. However, replicating the remote models as local abstractions within the Transaction module allows the framework to anticipate common issues, such as model relationship inconsistencies or bad data.

Going back to the example shown in Data Access Object, let’s now use the Transaction to add a new Employee Jay to the remote API, and at the same time to update the address of John:

...

transaction = Transaction()
transaction.register_dao(EmployeeDAO(Employee))

# retrieves John Doe's model
john = await transaction.get(Employee, 1)
john.address = "Brazil"

# create a new employee Jay Pritchett locally
jay = Employee(name="Jay Pritchett", age=65, address="California")
# tells the transaction to add the new employee to its context
transaction.add(jay)

If you don’t want to call register_dao for every new Transaction instance you create, you can extend Transaction in order to get this done automatically:

from restio.transaction import Transaction

from myproject.models import Employee
from myproject.dao import EmployeeDAO

...

class MyTransaction(Transaction):
    def __init__(self) -> None:
        super().__init__()

        self.register_dao(EmployeeDAO(Employee))

...

At this point, no operation has been done to the remote server yet. It is necessary to tell the Transaction to commit its changes explicitly.

Commit

transaction = MyTransaction()
...

await transaction.commit()

The commit method will inspect all models stored on the transaction’s internal cache and verify which models should be modified. In the example above, right before the commit John has state DIRTY (because it has been modified) and Jay has state NEW (because it still has to be added):

...

transaction = MyTransaction()

# retrieves John Doe's model
john = await transaction.get(Employee, 1)
john.address = "Brazil"

# create a new employee Jay Pritchett locally
jay = Employee(name="Jay Pritchett", age=65, address="California")
# tells the transaction to add the new employee to its context
transaction.add(jay)

# this is where the actual changes happen - Jay will be
# created and John will be updated
await transaction.commit()

Its is not up to the developer anymore to figure out in which order the operations need to be persisted on the remote server, and which models are unchanged. The transaction will take care of drawing the graph of dependencies between models and trigger all requests to the remote REST API in an optimal way.

By default, commit() enables the flag raise_for_status=True, which will make an extra call to Transaction.raise_for_status() at the end of the commit.

Persistency Strategy

Transactions by default are instantiated with strategy=PersistentStrategy.INTERRUPT_ON_ERROR. When an error occurs during a commit, the value of strategy will dictate the behavior:

  • INTERRUPT_ON_ERROR will cause the commit to interrupt the scheduling of new DAO Tasks and will wait until all current DAO Tasks finalize.
  • CONTINUE_ON_ERROR will cause the commit to ignore the error and continue scheduling all available DAO Tasks until all models are processed.

DAO Tasks

DAO Tasks will store the result of the calls to the DAOs during a commit, those being to either add, update or remove. If anything goes wrong in one of those methods, then it is possible to revisit the results of all tasks performed by the commit manually:

from restio.dao import DAOTask

...

tasks = await transaction.commit()

dao_task: DAOTask
for dao_task in tasks:
    try:
        # obtains the value returned by the DAO function, if any
        result = await dao_task
    except Exception:
        # if something went wrong during the commit, then it is time
        # to treat it - below, we just print the stack trace to the
        # terminal
        dao_task.task.print_stack()

It is also possible to raise a TransactionException when at least one DAOTask has thrown an exception:

transaction.raise_for_status(tasks)

TransactionException will always contain two internal structures which can be used to iterate over the successful or failed tasks: successful_tasks and exception_tasks:

from restio.transaction import TransactionException

...

try:
    transaction.commit()  # raise_for_status=True, which calls Transaction.raise_for_status()
except TransactionException as exc:
    for successful_task in exc.successful_tasks:
        model = successful_task.model
        print(f"Model {model} persisted successfully")

    for failed_task, raised_exception in exc.exception_tasks:
        model = failed_task.model  # the model that failed to update
        print(f"Can't persist {model}: {raised_exception}")

Rollback

transaction = MyTransaction()
...

transaction.rollback()

Because the operations on server-side are done in multiple HTTP requests, it is not possible to guarantee atomicity between requests. Therefore, Transaction.rollback will only revert the local changes that have not yet been persisted.

Rollbacks are useful when the cache is populated with a lot of data.