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 les cas précédents, l'API Datastore renvoie une erreur.

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

La fonction datastore.RunInTransaction exécute la fonction fournie dans une transaction.


package counter

import (
	"context"
	"fmt"
	"net/http"

	"google.golang.org/appengine"
	"google.golang.org/appengine/datastore"
	"google.golang.org/appengine/log"
	"google.golang.org/appengine/taskqueue"
)

func init() {
	http.HandleFunc("/", handler)
}

type Counter struct {
	Count int
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := appengine.NewContext(r)

	key := datastore.NewKey(ctx, "Counter", "mycounter", 0, nil)
	count := new(Counter)
	err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
		// Note: this function's argument ctx shadows the variable ctx
		//       from the surrounding function.
		err := datastore.Get(ctx, key, count)
		if err != nil && err != datastore.ErrNoSuchEntity {
			return err
		}
		count.Count++
		_, err = datastore.Put(ctx, key, count)
		return err
	}, nil)
	if err != nil {
		log.Errorf(ctx, "Transaction failed: %v", err)
		http.Error(w, "Internal Server Error", 500)
		return
	}

	fmt.Fprintf(w, "Current count: %d", count.Count)
}

Si la fonction renvoie nil, RunInTransaction tente de valider la transaction et renvoie nil si l'opération réussit. Si la fonction renvoie une valeur d'erreur non-nil, tout changement apporté dans Datastore n'est pas appliqué et RunInTransaction renvoie cette même erreur.

Si RunInTransaction ne peut pas valider la transaction en raison d'un conflit, il tente à nouveau l'opération avant d'abandonner après trois tentatives. Cela signifie que la fonction de transaction doit être idempotente ; en d'autres termes, le même résultat est obtenu lorsque la transaction est exécutée plusieurs fois. Notez que datastore.Get n'est pas idempotent lors du rétablissement des champs de tranches.

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 l'heure a changé depuis la première vérification, une erreur est renvoyée.

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é concernant sa valeur actuelle.

func increment(ctx context.Context, key *datastore.Key) error {
	return datastore.RunInTransaction(ctx, func(ctx context.Context) error {
		count := new(Counter)
		if err := datastore.Get(ctx, key, count); err != nil {
			return err
		}
		count.Count++
		_, err := datastore.Put(ctx, key, count)
		return err
	}, nil)
}

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 :

type Account struct {
	Address string
	Phone   string
}

func GetOrUpdate(ctx context.Context, id, addr, phone string) error {
	key := datastore.NewKey(ctx, "Account", id, 0, nil)
	return datastore.RunInTransaction(ctx, func(ctx context.Context) error {
		acct := new(Account)
		err := datastore.Get(ctx, key, acct)
		if err != nil && err != datastore.ErrNoSuchEntity {
			return err
		}
		acct.Address = addr
		acct.Phone = phone
		_, err = datastore.Put(ctx, key, acct)
		return err
	}, nil)
}

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.

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.

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.

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 garantie d'être mise en file d'attente) que si la transaction est correctement validée. Une fois placée en file d'attente, la tâche ne s'exécute pas nécessairement de façon immédiate ; la tâche et la transaction ne sont donc pas atomiques. Cependant, une fois placée en file d'attente, la tâche renouvelle la tentative jusqu'à ce que celle-ci réussisse. Cela s'applique à toute tâche mise en file d'attente pendant l'exécution d'une fonction RunInTransaction.

Les tâches transactionnelles sont utiles, car elles vous permettent de combiner dans une transaction des actions non liées à Datastore à une transaction qui dépend de la réussite de cette dernière (par exemple, envoyer un e-mail pour confirmer un achat). Vous pouvez également associer des actions 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.

datastore.RunInTransaction(ctx, func(ctx context.Context) error {
	t := &taskqueue.Task{Path: "/path/to/worker"}
	if _, err := taskqueue.Add(ctx, t, ""); err != nil {
		return err
	}
	// ...
	return nil
}, nil)