Transactions

The save use case demonstrates the use of raise_on_conflict argument. What it does is actually implement by hand a transaction. Amazon’s DynamoDB has no “out of the box” transaction engines but provides this parameter as an elementary block for this purpose.

Transaction concepts

Transactions are a convenient way to logically group database operations while trying as much as possible to enforce consistency. In Dynamodb-mapper, transactions are plain DynamoDBModel thus allowing them to persist their state. Dynamodb-mapper provides 2 grouping level: Targets and sub-transactions.

Transactions operates on a list of ‘targets’. For each target, it needs list of transactors. transactors are tuples of (getter, setter). The getter is responsible of either getting a fresh copy of the target either create it while setter performs the modifications. The call to save is handled by the engine itself.

For each target, the transaction engine will successively call getter and setter until save() succeeds. save() will succeed if and only if the target has not been altered by another thread in the mean time thus avoiding the lost update syndrome.

Optionally, transactions may define a method _setup() which will be called before any transactors.

Sub-transactions, if applicable, are ran after the main transactors if they all succeeded. Hence, _setup() and the transactors may dynamically append sub-transactions to the main transactions.

Unless the transaction is explicitely marked transient, its state will be persisted to a dedicated table. Transaction base class embeds a minimal schema that should suit most applications but may be overloaded as long as a datetime range_key is preserved along with a unicode status field.

Since version 1.7.0, transactions may operate on new (not yet persisted) Items.

Using the transaction engine

To use the transaction engine, all you have to do is to define __table__ and overload _get_transactors(). Of course the transactors will themselves will need to be implemented. Optionally, you may overload the whole schema or set transient=True. A _setup() method may also be implemented.

During the transaction itself, please set requester_id field to any relevant interger unless the transaction is transient. _setup() is a good place to do it.

Note: transient flag may be toggled on a per instance basis. It may even be toggled in one of the transactors.

Use case: Bundle purchase

from dynamodb_mapper.transactions import Transaction, TargetNotFoundError

# define PlayerExperience, PlayerPowerUp, PlayerSkins, Players with user_id as hash_key

class InsufficientResourceError(Exception):
    pass

bundle = {
    u"cost": 150,
    u"items": [
        PlayerExperience,
        PlayerPowerUp,
        PlayerSkins
    ]
}

class BundleTransaction(Transaction):
    transient = False # Make it explicit. This is anyway the default.
    __table__ = u"mygame-dev-bundletransactions"

    def __init__(self, user_id, bundle):
        super(BundleTransaction, self).__init__()
        self.requester_id = user_id
        self.bundle = bundle

    # _setup() is not needed here

    def _get_transactors(self):
        transactors = [(
            lambda: Players.get(self.requester_id), # lambda
            self.user_payment # regular callback
        )]

        for Item in self.bundle.items:
            transactors.append((
                lambda: Item.get(self.requester_id),
                lambda item: item.do_stuff()
            ))

        return transactors

    def user_payment(self, player):
        if player.balance < self.bundle.cost:
            raise InsufficientResourceError()
        player.balance -= self.bundle.cost

# Run the transaction
try:
    transaction = BundleTransaction(42, bundle)
    transaction.commit()
except InsufficientResourceError:
    print "Ooops, user {} has not enough coins to proceed...".format(42)

#That's it !

This example has been kept simple on purpose. In a real world application, you certainly would not model your data this way ! You can notice the power of this approach that is compatible with lambda niceties as well as regular callbacks.

Use case: PowerUp purchase

This example is a bit more subtle than the previous one. The customer may purchase a ‘surprise‘ bundle of powerups. The database knows what is in the pack while the client application does not. As bundles may change from time to time, we want to log what exactly was purchased. Also, the actual PowerUp registration should not start until the Coins transaction has succeeded.

To reach this goal, we could

  • pre-load the Bundle
  • dynamically use the content in get_transactors
  • save the detailed status in a specially overloaded Transaction’s __schema__

But that’s more hand work.

A much better way is to split the transaction into PowerupTransaction and UserPowerupTransaction. The former handles the coins and the registration of the sub-transaction while the later handles the PowerUo magic.

from dynamodb_mapper.transactions import Transaction, TargetNotFoundError

# define PlayerPowerUp, Players with user_id as hash_key

class InsufficientResourceError(Exception):
    pass

# Sub-Transaction of PowerupTransaction. Will have i's own status
class UserPowerupTransaction(transaction):
    __table__ = u"mygame-dev-userpoweruptransactions"

    def __init__(self, player, powerup):
        super(UserPowerupTransaction, self).__init__()
        self.requester_id = player.user_id
        self.powerup = powerup

    def _get_transactors(self):
        return [(
            lambda: PlayerPowerUp.get(self.requester_id, self.powerup),
            do_stuff()
        )]

# Main Transaction class. Will have it's own status
class PowerupTransaction(Transaction):
    __table__ = u"mygame-dev-poweruptransactions"

    cost = 150 # hard-coded cost for the demo
    powerups = ["..."] # hard-coded powerups for the demo

    def _get_transactors(self):
        return [(
            lambda: Players.get(self.requester_id),
            self.user_payment
        )]

    def user_payment(self, player):
        # Payment logic
        if player.balance < self.cost:
            raise InsufficientResourceError()
        player.balance -= self.cost

        # Register (overwrite) sub-transactions
        self.subtransactions = []
        for powerupName in self.powerups:
            self.subtransactions.append = (player, powerupName)


# Run the transaction
try:
    transaction = PowerupTransaction(requester_id=42)
    transaction.commit()
except InsufficientResourceError:
    print "Ooops, user {} has not enough coins to proceed...".format(42)

#That's it !

Note: In some special “real-World(tm)” situations, it may be necessary to modify the behavior of subtransactions. It is possible to overload the method Transaction._apply_subtransactions() for this purpose. Use case: sub-transactions have been automatically/randomly generated by the main transaction and the application needs to know wich one were generated or perform some other application specific tasks when applying.

Project Versions

Table Of Contents

Previous topic

Data manipulation

Next topic

Migrations

This Page