Session

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

Sessions 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 Session by running Session.register_dao:

See also

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

from restio.session import Session

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

session = Session()

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

A session 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 session to retrieve an Employee from the remote server:

employee = await session.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 Session cache:

employee = await session.get(Employee, 1)

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

employee_again = await session.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 in most cases it won’t implement transactions. Therefore, it is never guaranteed that the session will perform all actions atomicly when interacting with the remote server. However, replicating the remote models as local abstractions within the Session module allows the framework to anticipate common issues, such as model relationship inconsistencies or bad data, apart from keeping cache.

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

...

session = Session()
session.register_dao(EmployeeDAO(Employee))

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

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

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

from restio.session import Session

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

...

class MySession(Session):
    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 Session to commit its changes explicitly.

Commit

session = MySession()
...

await session.commit()

The commit method will inspect all models stored on the session’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):

...

session = MySession()

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

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

# this is where the actual changes happen - Jay will be
# created and John will be updated
await session.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 in runtime, and which models are unchanged. The session 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 Session.raise_for_status() at the end of the commit.

Persistency Strategy

Sessions 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 session.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 SessionException when at least one DAOTask has thrown an exception:

session.raise_for_status(tasks)

SessionException 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.session import SessionException

...

try:
    session.commit()  # raise_for_status=True, which calls Session.raise_for_status()
except SessionException 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

Session.rollback will only revert the local changes that have not yet been persisted. You can interpret this as a mechanism to revert all local changes (within the session) to their persistent state.

Warning

Because server-side operations are done in multiple HTTP requests, it is not possible to guarantee atomicity between multiple requests. It is equally difficult to make sure that partial requests are reverted after they have been submitted. Therefore, a Session rollback will not undo changes already persisted on the server.

session = MySession()
...

session.rollback()

Rollbacks are useful when the cache is populated with a lot of data. For example, if you have retrieved hundreds of models in order to analyze data and then further update a few models. If one update fails, you might still want to keep the data around to avoid loading everything again for the next operation.

Context Manager

All session instances can be used as context managers. The code in Commit could be re-written as follows:

async with MySession() as session:
    john = await session.get(Employee, 1)
    john.address = "Brazil"

    jay = Employee(name="Jay Pritchett", age=65, address="California")

Context managers simplify the manipulation of objects, the commit and the rollback workflows. The a context will automatically handle the following actions:

  • Adding models to the session: If a model is instantiated within the context body for session, then session.add() is automatically called for that model.
  • Commit: At the end of the context body, the session is automatically commited with session.commit(raise_for_error=True). If the call fails, the corresponding SessionException is propagated back to the context.
  • Rollback on exception thrown: Any exception thrown within the context body will first cause session.rollback() to be triggered. The exception is propagated back to the context, and session.commit() is never called.
  • Rollback on commit error: If a commit fails, the context will automatically rollback the remaining non-persisted models with session.rollback().