Transactions

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 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.

Voici un exemple de mise à jour d'un champ nommé vacationDays dans une entité de genre Employee nommée Joe :

DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
Transaction txn = datastore.beginTransaction();
try {
  Key employeeKey = KeyFactory.createKey("Employee", "Joe");
  Entity employee = datastore.get(employeeKey);
  employee.setProperty("vacationDays", 10);

  datastore.put(txn, employee);

  txn.commit();
} finally {
  if (txn.isActive()) {
    txn.rollback();
  }
}

Notez que par souci de clarté, nous omettons parfois dans nos exemples le bloc finally qui procède à une annulation (rollback) si la transaction est toujours active. Dans le code de production, il est important de s'assurer que chaque transaction est validée ou fait l'objet d'un rollback de façon explicite.

Groupes d'entités

Chaque entité appartient à un groupe d'entités, un ensemble formé d'une ou de plusieurs entités susceptibles d'être manipulées au sein d'une seule transaction. Les relations de groupes d'entités indiquent à App Engine de stocker plusieurs entités dans la même partie du réseau distribué. Une transaction configure des opérations Datastore pour un groupe d'entités. Les opérations sont appliquées soit dans leur totalité, en tant que groupe, soit pas du tout si la transaction échoue.

Lorsque l'application crée une entité, elle peut affecter une autre entité comme parent de la nouvelle entité. En cas d'affectation d'un parent à une nouvelle entité, cette dernière est insérée dans le même groupe d'entités que l'entité parente.

Une entité sans parent est une entité racine. Une entité parente d'une autre entité peut elle-même avoir un parent. Une chaîne d'entités parentes, depuis une entité jusqu'à la racine, représente le chemin d'accès de l'entité, et les membres du chemin d'accès sont les ancêtres de l'entité. Le parent d'une entité est défini lors de la création de l'entité et ne peut pas être modifié ultérieurement.

Toutes les entités ayant comme ancêtre une entité racine donnée appartiennent au même groupe d'entités. Toutes les entités d'un groupe sont stockées dans le même nœud Datastore. Par l'intermédiaire d'une même transaction, vous pouvez modifier plusieurs entités d'un même groupe, ou ajouter de nouvelles entités à un groupe en faisant du parent de la nouvelle entité une entité existante du groupe. L'extrait de code suivant illustre des transactions portant sur différents types d'entités :

DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
Entity person = new Entity("Person", "tom");
datastore.put(person);

// Transactions on root entities
Transaction txn = datastore.beginTransaction();

Entity tom = datastore.get(person.getKey());
tom.setProperty("age", 40);
datastore.put(txn, tom);
txn.commit();

// Transactions on child entities
txn = datastore.beginTransaction();
tom = datastore.get(person.getKey());
Entity photo = new Entity("Photo", tom.getKey());

// Create a Photo that is a child of the Person entity named "tom"
photo.setProperty("photoUrl", "http://domain.com/path/to/photo.jpg");
datastore.put(txn, photo);
txn.commit();

// Transactions on entities in different entity groups
txn = datastore.beginTransaction();
tom = datastore.get(person.getKey());
Entity photoNotAChild = new Entity("Photo");
photoNotAChild.setProperty("photoUrl", "http://domain.com/path/to/photo.jpg");
datastore.put(txn, photoNotAChild);

// Throws IllegalArgumentException because the Person entity
// and the Photo entity belong to different entity groups.
txn.commit();

Créer une entité dans un groupe d'entités spécifique

Lorsque votre application construit une nouvelle entité, vous pouvez attribuer cette dernière à un groupe d'entités en fournissant la clé d'une autre entité. L'exemple ci-après construit la clé d'une entité MessageBoard, puis utilise cette clé pour créer et déclarer comme persistante une entité Message appartenant au même groupe d'entités que MessageBoard :

DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();

String messageTitle = "Some Title";
String messageText = "Some message.";
Date postDate = new Date();

Key messageBoardKey = KeyFactory.createKey("MessageBoard", boardName);

Entity message = new Entity("Message", messageBoardKey);
message.setProperty("message_title", messageTitle);
message.setProperty("message_text", messageText);
message.setProperty("post_date", postDate);

Transaction txn = datastore.beginTransaction();
datastore.put(txn, message);

txn.commit();

Utiliser des transactions entre groupes

Les transactions entre groupes (également appelées transactions XG) opèrent sur plusieurs groupes d'entités. Elle se comportent comme les transactions à groupe unique, mais les transactions entre groupes n'échouent pas si le code tente de mettre à jour des entités provenant de plusieurs groupes d'entités.

L'utilisation d'une transaction entre groupes est semblable à celle d'une transaction à groupe unique, sauf que vous devez spécifier que vous souhaitez une transaction entre groupes au début de la transaction, à l'aide de TransactionOptions :

DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
TransactionOptions options = TransactionOptions.Builder.withXG(true);
Transaction txn = datastore.beginTransaction(options);

Entity a = new Entity("A");
a.setProperty("a", 22);
datastore.put(txn, a);

Entity b = new Entity("B");
b.setProperty("b", 11);
datastore.put(txn, b);

txn.commit();

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.

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 lien avec sa valeur en cours. Étant donné que l'API Datastore ne relance pas les transactions, nous pouvons ajouter une logique pour la transaction à relancer, au cas où une autre demande met à jour le même MessageBoard ou l'un de ses Messages en même temps.

int retries = 3;
while (true) {
  Transaction txn = datastore.beginTransaction();
  try {
    Key boardKey = KeyFactory.createKey("MessageBoard", boardName);
    Entity messageBoard = datastore.get(boardKey);

    long count = (Long) messageBoard.getProperty("count");
    ++count;
    messageBoard.setProperty("count", count);
    datastore.put(txn, messageBoard);

    txn.commit();
    break;
  } catch (ConcurrentModificationException e) {
    if (retries == 0) {
      throw e;
    }
    // Allow retry to occur
    --retries;
  } finally {
    if (txn.isActive()) {
      txn.rollback();
    }
  }
}

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 pendant la transaction, celle-ci échoue avec une exception ConcurrentModificationException. L'application peut répéter la transaction afin d'utiliser les nouvelles données.

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 :

Transaction txn = datastore.beginTransaction();
Entity messageBoard;
Key boardKey;
try {
  boardKey = KeyFactory.createKey("MessageBoard", boardName);
  messageBoard = datastore.get(boardKey);
} catch (EntityNotFoundException e) {
  messageBoard = new Entity("MessageBoard", boardName);
  messageBoard.setProperty("count", 0L);
  boardKey = datastore.put(txn, messageBoard);
}
txn.commit();

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. Avec une transaction, la seconde tentative échoue atomiquement. Si besoin, l'application peut réitérer la tentative pour récupérer l'entité et la mettre à jour.

Lorsqu'une transaction échoue, vous pouvez demander à l'application de relancer la transaction jusqu'à ce qu'elle réussisse, 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.

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.

DatastoreService ds = DatastoreServiceFactory.getDatastoreService();

// Display information about a message board and its first 10 messages.
Key boardKey = KeyFactory.createKey("MessageBoard", boardName);

Transaction txn = datastore.beginTransaction();

Entity messageBoard = datastore.get(boardKey);
long count = (Long) messageBoard.getProperty("count");

Query q = new Query("Message", boardKey);

// This is an ancestor query.
PreparedQuery pq = datastore.prepare(txn, q);
List<Entity> messages = pq.asList(FetchOptions.Builder.withLimit(10));

txn.commit();

Ajouter une tâche transactionnelle en file d'attente

Dans le cadre d'une transaction Datastore, une tâche n'est placée en file d'attente (et est garantie d'être mise en file d'attente) que si la transaction est correctement validée. La validation de la transaction garantit le placement en file d'attente de la tâche. Une fois placée en file d'attente, la tâche ne s'exécute pas nécessairement de façon immédiate, et toutes les opérations définies dans la tâche s'exécutent indépendamment de la transaction d'origine. La tâche fait l'objet de plusieurs tentatives jusqu'à ce qu'elle aboutisse. Cette règle s'applique à toutes les tâches placées en file d'attente dans le contexte d'une transaction.

Les tâches transactionnelles sont utiles car elles vous permettent d'inscrire des actions différentes de Datastore dans une transaction Datastore (comme l'envoi d'un courrier électronique pour confirmer un achat). Vous pouvez également lier des actions Datastore à la transaction, comme la validation de modifications d'autres groupes d'entités en dehors de la transaction si et seulement si la transaction 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 doivent pas porter de noms spécifiés par l'utilisateur.

DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
Queue queue = QueueFactory.getDefaultQueue();
Transaction txn = datastore.beginTransaction();
// ...

queue.add(txn, TaskOptions.Builder.withUrl("/path/to/handler"));

// ...

txn.commit();