Transactions

Remarque : Les développeurs qui créent des applications sont vivement encouragés à utiliser la bibliothèque cliente NDB qui présente plusieurs avantages supplémentaires par rapport à cette bibliothèque cliente, tels que la mise en cache automatique des entités via l'API Memcache. Si vous utilisez actuellement l'ancienne bibliothèque cliente DB, consultez le guide de migration de DB vers NDB.

Datastore accepte les transactions. Une transaction est une opération ou un ensemble d'opérations atomique : toutes les opérations d'une transaction se produisent, ou aucune. Une application peut effectuer plusieurs opérations et calculs au sein d'une transaction unique.

Utiliser des transactions

Une transaction est un ensemble d'opérations Datastore sur une ou plusieurs entités. Chaque transaction est atomique, ce qui signifie que les transactions ne sont jamais partiellement appliquées. Toutes les opérations de la transaction sont appliquées ou aucune d'entre elles ne l'est. Les transactions ont une durée maximale de 60 secondes avec un délai d'inactivité avant expiration de 10 secondes après 30 secondes.

Une opération peut échouer lorsque :

  • trop de modifications simultanées sont envoyées sur le même groupe d'entités ;
  • la transaction dépasse une limite de ressources ;
  • Datastore rencontre une erreur interne.

Dans tous ces cas, l'API Cloud Datastore déclenche une exception.

Les transactions représentent une fonctionnalité facultative de Datastore. Vous n'êtes pas obligé de les utiliser pour effectuer des opérations Datastore.

Une application peut exécuter un ensemble d'instructions et d'opérations Datastore en une seule transaction. Ainsi, si une instruction ou une opération déclenche une exception, aucune des opérations Datastore de l’ensemble n’est appliquée. L'application définit les actions à effectuer dans le cadre de la transaction à l'aide d'une fonction Python. L'application démarre la transaction à l'aide de l'une des méthodes run_in_transaction, selon que la transaction accède à des entités au sein d'un seul groupe d'entités ou qu'elle consiste en une transaction entre groupes.

Pour le cas d'utilisation courant d'une fonction qui n'est utilisée que dans des transactions, utilisez le décorateur @db.transactional :

from google.appengine.ext import db

class Accumulator(db.Model):
    counter = db.IntegerProperty(default=0)

@db.transactional
def increment_counter(key, amount):
    obj = db.get(key)
    obj.counter += amount
    obj.put()

q = db.GqlQuery("SELECT * FROM Accumulator")
acc = q.get()

increment_counter(acc.key(), 5)

Si la fonction est parfois appelée sans transaction, au lieu de la décorer, appelez db.run_in_transaction() avec la fonction en tant qu'argument :

from google.appengine.ext import db

class Accumulator(db.Model):
    counter = db.IntegerProperty(default=0)

def increment_counter(key, amount):
    obj = db.get(key)
    obj.counter += amount
    obj.put()

q = db.GqlQuery("SELECT * FROM Accumulator")
acc = q.get()

db.run_in_transaction(increment_counter, acc.key(), 5)

db.run_in_transaction() utilise l'objet fonction, ainsi que les arguments de position et de mot clé à transmettre à la fonction. Si la fonction affiche une valeur, db.run_in_transaction() renvoie cette valeur.

Si la fonction renvoie un résultat, la transaction fait l'objet d'un commit. Tous les effets des opérations Cloud Datastore sont appliqués. Si la fonction génère une exception, la transaction fait l'objet d'un rollback. Les effets ne sont pas appliqués. Consultez la remarque ci-dessus relative aux exceptions.

Lorsqu'une fonction de transaction est appelée à partir d'une autre transaction, @db.transactional et db.run_in_transaction() ont un comportement par défaut différent. @db.transactional permet cette opération. La transaction interne devient identique à la transaction externe. Lorsque vous appelez la fonction db.run_in_transaction(), celle-ci tente d'imbriquer une autre transaction dans la transaction existante. Ce comportement n'est pas accepté pour l'instant et génère une erreur db.BadRequestError. Vous pouvez spécifier un autre comportement. Pour en savoir plus, consultez la documentation de référence sur les fonctions qui traite des options de transaction.

Utiliser des transactions entre groupes (XG)

Les transactions entre groupes, qui s'appliquent à plusieurs groupes d'entités, se comportent comme les transactions à groupe unique. Toutefois, les transactions entre groupes n'échouent pas si le code tente de mettre à jour des entités de plusieurs groupes. Pour appeler une transaction entre groupes, utilisez les options de transaction.

En utilisant @db.transactional :

from google.appengine.ext import db

@db.transactional(xg=True)
def make_things():
  thing1 = Thing(a=3)
  thing1.put()
  thing2 = Thing(a=7)
  thing2.put()

make_things()

En utilisant db.run_in_transaction_options :

from google.appengine.ext import db

xg_on = db.create_transaction_options(xg=True)

def my_txn():
    x = MyModel(a=3)
    x.put()
    y = MyModel(a=7)
    y.put()

db.run_in_transaction_options(xg_on, my_txn)

Que peut-on faire dans une transaction ?

Cloud Datastore impose des restrictions sur les opérations qui peuvent être effectuées au sein d'une transaction unique.

Toutes les opérations Datastore d'une transaction doivent s'effectuer sur des entités d'un même groupe d'entités si la transaction est une transaction à groupe unique, ou sur des entités d'un maximum de 25 groupes d'entités s'il s'agit d'une transaction entre groupes. Cela inclut l'interrogation d'entités par ancêtre, la récupération d'entités par clé, la mise à jour et la suppression d'entités. Notez que chaque entité racine appartient à un groupe d'entités distinct. Ainsi, une transaction à groupe unique ne peut pas se créer ou s'effectuer sur plusieurs entités racines à moins qu'il ne s'agisse d'une transaction entre groupes.

Lorsque deux ou plusieurs transactions tentent simultanément de modifier des entités dans un ou plusieurs groupes d'entités communs, seule la première transaction qui valide ses modifications peut aboutir. Toutes les autres échoueront lors du commit. En raison de cette conception, l'utilisation de groupes d'entités limite le nombre d'écritures simultanées qu'il est possible d'effectuer sur n'importe quelle entité des groupes. Lorsqu'une transaction démarre, Datastore utilise un contrôle de simultanéité optimiste en vérifiant l'heure de la dernière mise à jour des groupes d'entités utilisés dans la transaction. Lors du commit d'une transaction pour les groupes d'entités, Datastore vérifie à nouveau l'heure de la dernière mise à jour pour les groupes d'entités utilisés dans la transaction. Si elle a changé depuis la vérification initiale, une exception est levée.

Une application peut effectuer une requête lors d'une transaction, mais uniquement si elle inclut un filtre ancêtre. Une application peut également obtenir des entités du datastore par clé lors d'une transaction. Vous pouvez préparer les clés avant la transaction ou les générer au sein de la transaction à l'aide d'identifiants ou de noms de clés.

Tous les autres codes Python sont autorisés au sein d'une fonction de transaction. Vous pouvez déterminer si le champ d'application actuel est imbriqué dans une fonction de transaction à l'aide de db.is_in_transaction(). La fonction de transaction ne doit pas avoir d'effets secondaires autres que les opérations Datastore. Elle peut être appelée plusieurs fois si une opération Datastore échoue en raison de la mise à jour simultanée des entités du groupe par un autre utilisateur. Dans ce cas, l'API Datastore effectue un nombre fixe de nouvelles tentatives d'exécution de la transaction. Si toutes les tentatives échouent, db.run_in_transaction() génère une erreur TransactionFailedError. Vous pouvez modifier le nombre d'exécutions de la transaction en utilisant db.run_in_transaction_custom_retries() au lieu de db.run_in_transaction().

De même, la fonction de transaction ne doit pas avoir d'effets secondaires qui dépendent de la réussite de la transaction, à moins que le code qui appelle la fonction puisse annuler ces effets. Par exemple, si la transaction stocke une nouvelle entité Datastore et enregistre l'identifiant de l'entité créée pour un usage ultérieur, puis échoue, alors l'identifiant enregistré ne désigne pas l'entité prévue car la création de l'entité a fait l'objet d'un rollback. Dans ce cas, le code d'appel ne devra pas utiliser l'ID enregistré.

Isolation et cohérence

En dehors des transactions, le niveau d'isolation de Datastore est le plus proche du niveau "read committed" (lecture de données validées). À l'intérieur des transactions, l'isolation sérialisable est appliquée. Cela signifie qu'une autre transaction ne peut pas modifier simultanément les données lues ou modifiées par cette transaction.

Dans une transaction, toutes les opérations de lecture reflètent l'état actuel et cohérent de Datastore au moment du démarrage de la transaction. Les requêtes et les recherches au sein d'une transaction sont certaines d'afficher un seul instantané cohérent de Datastore au début de la transaction. Les entités et les lignes d'index du groupe d'entités de la transaction sont entièrement mises à jour de façon à ce que les requêtes renvoient l'ensemble complet et correct d'entités de résultat, sans les faux positifs, ni les faux négatifs pouvant survenir dans les requêtes en dehors des transactions.

Cet instantané cohérent englobe également les opérations de lecture ayant lieu après les opérations d'écriture dans les transactions. Contrairement à la plupart des bases de données, les requêtes et les opérations "get" au sein d'une transaction Datastore n'affichent pas les résultats des écritures précédentes dans cette transaction. Plus spécifiquement, si une entité est modifiée ou supprimée dans une transaction, une requête ou une méthode "get" renvoie la version originale de l'entité telle qu'elle existait au début de la transaction, ou aucun résultat si elle n'existait pas encore.

Possibilités d'utilisation des transactions

Cet exemple présente une utilisation possible des transactions : la mise à jour d'une entité avec une nouvelle valeur de propriété en rapport avec sa valeur en cours.

def increment_counter(key, amount):
    obj = db.get(key)
    obj.counter += amount
    obj.put()

Ici, une transaction est nécessaire, car la valeur peut être mise à jour par un autre utilisateur après que ce code a récupéré l'objet, mais avant qu'il n'enregistre l'objet modifié. En l'absence de transaction, la requête de l'utilisateur utilise la valeur count avant la mise à jour effectuée par l'autre utilisateur, puis la sauvegarde écrase la nouvelle valeur. Avec une transaction, l'application est prévenue de la mise à jour effectuée par l'autre utilisateur. Si l'entité est mise à jour au cours de la transaction, la transaction est alors relancée jusqu'à ce que toutes les étapes soient terminées sans interruption.

Une autre utilisation courante des transactions consiste à récupérer une entité avec une clé nommée ou de la créer si elle n'existe pas encore :

class SalesAccount(db.Model):
    address = db.PostalAddressProperty()
    phone_number = db.PhoneNumberProperty()

def get_or_create(parent_key, account_id, address, phone_number):
    obj = db.get(db.Key.from_path("SalesAccount", account_id, parent=parent_key))
    if not obj:
        obj = SalesAccount(key_name=account_id,
                           parent=parent_key,
                           address=address,
                           phone_number=phone_number)
        obj.put()
    else:
        obj.address = address
        obj.phone_number = phone_number

Comme précédemment, une transaction est nécessaire au cas où un autre utilisateur tente de créer ou de mettre à jour une entité avec le même identifiant de chaîne. En l'absence de transaction, si l'entité n'existe pas et que deux utilisateurs essaient de la créer, le second écrase la contribution du premier sans même en être conscient. Si une transaction est utilisée, une troisième tentative est effectuée. L'application constate que l'entité existe et la met à jour.

Lorsqu'une transaction échoue, vous pouvez demander à l'application de la relancer jusqu'à ce qu'elle aboutisse ou laisser les utilisateurs traiter l'erreur en la propageant au niveau de l'interface utilisateur de l'application. Il n'est pas nécessaire de créer une boucle afin de relancer chaque transaction.

La fonction "Get-or-create" est si utile qu'une méthode intégrée lui est associée : Model.get_or_insert(). Celle-ci utilise un nom de clé, un parent facultatif et les arguments à transmettre au constructeur de modèle s'il n'existe aucune entité correspondant à ce nom et à ce chemin. Les opérations "Get" et "Create" s'effectuent dans le cadre d'une même transaction de sorte que la méthode renvoie toujours une instance de modèle représentant une entité réelle (si la transaction aboutit).

Enfin, vous pouvez utiliser une transaction pour lire un instantané cohérent de Datastore. Cela peut s'avérer utile lorsque plusieurs opérations de lecture sont nécessaires pour afficher une page ou exporter des données qui doivent être cohérentes. Les transactions de ce type sont souvent appelées transactions en lecture seule, car elles n'effectuent aucune opération d'écriture. Les transactions à groupe unique en lecture seule n'échouent jamais en raison de modifications simultanées. Ainsi, vous n'avez pas besoin de mettre en œuvre des tentatives en cas d'échec. Toutefois, les transactions entre groupes peuvent échouer à cause de modifications simultanées. Par conséquent, celles-ci doivent comprendre plusieurs tentatives. Le commit et le rollback d'une transaction en lecture seule sont sans effet.

class Customer(db.Model):
    user = db.StringProperty()

class Account(db.Model):
    """An Account has a Customer as its parent."""
    address = db.PostalAddressProperty()
    balance = db.FloatProperty()

def get_all_accounts():
    """Returns a consistent view of the current user's accounts."""
    accounts = []
    for customer in Customer.all().filter('user =', users.get_current_user().user_id()):
        accounts.extend(Account.all().ancestor(customer))
    return accounts

Ajouter une tâche transactionnelle en file d'attente

Dans le cadre d'une transaction Datastore, vous pouvez placer une tâche en file d'attente à condition que la transaction ait été validée avec succès. Si le commit échoue, la tâche n'est pas mise en file d'attente. Si le commit réussit, la tâche est mise en file d'attente. Une fois placée en file d'attente, la tâche ne s'exécute pas immédiatement. La tâche et la transaction ne sont donc pas atomiques. Cependant, une fois en file d'attente, la tâche renouvelle la tentative jusqu'à ce que celle-ci réussisse. Cela s'applique à toutes les tâches placées en file d'attente au cours de l'exécution d'une fonction run_in_transaction().

Les tâches transactionnelles sont utiles, car elles vous permettent de combiner dans une transaction des actions non liées à Cloud Datastore et une transaction qui dépend de la réussite de la transaction (par exemple, envoyer un e-mail pour confirmer un achat). Vous pouvez également associer des actions Cloud Datastore à la transaction (par exemple, procéder au commit des modifications apportées aux groupes d'entités en dehors de la transaction, uniquement si celle-ci aboutit).

Une application ne peut pas insérer plus de cinq tâches transactionnelles dans des files d'attente de tâches au cours d'une même transaction. Les tâches transactionnelles ne peuvent pas avoir de nom spécifié par l'utilisateur.

def do_something_in_transaction(...)
    taskqueue.add(url='/path/to/my/worker', transactional=True)
  ...

db.run_in_transaction(do_something_in_transaction, ....)