Transações

O Datastore é compatível com transações. Transação é uma operação ou um conjunto de operações atômico. Todas as operações na transação ocorrem ou nenhuma delas ocorre. Um aplicativo pode realizar várias operações e cálculos em uma única transação.

Como usar transações

Transação é um conjunto de operações do Datastore que ocorrem em uma ou mais entidades. Cada transação é certamente atômica, o que significa que transações jamais são aplicadas parcialmente. Todas as operações na transação são aplicadas ou nenhuma delas é aplicada. As transações têm uma duração máxima de 60 segundos, com um tempo de expiração por inatividade de 10 segundos após 30 segundos.

Uma operação poderá falhar quando:

  • muitas modificações simultâneas forem tentadas no mesmo grupo de entidades;
  • a transação exceder um limite de recursos;
  • o Datastore encontrar um erro interno.

Em todos esses casos, a API do Datastore retornará um erro.

As transações são um recurso opcional do Datastore. Elas não precisam ser usadas para realizar operações do Datastore.

A função datastore.RunInTransaction executa a função fornecida em uma transação.


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)
}

Se a função retorna nil, RunInTransaction tenta confirmar a transação, retornando nil se tiver êxito. Se a função retorna um valor de erro diferente de nil, alterações no Datastore não são aplicadas e retorna RunInTransaction o mesmo erro.

Se RunInTransaction não conseguir confirmar a transação devido a um conflito, ele tentará novamente, desistindo depois de três tentativas. Isso significa que a função da transação precisa ser idempotente, ou seja, ela tem o mesmo resultado quando executada várias vezes. Observe que datastore.Get não é idempotente ao desmembrar campos parciais.

O que pode ser feito em uma transação

O Datastore impõe restrições sobre o que pode ser feito dentro de uma única transação.

Quando a transação é feita em um único grupo, todas as operações do Datastore precisam operar em entidades do mesmo grupo. Quando a transação é feita entre grupos, ela precisa operar em um máximo de 25 grupos. Isso inclui consultar entidades por ancestral, recuperar entidades por chave, atualizar entidades e excluir entidades. Cada entidade raiz pertence a um grupo separado de entidades. Dessa forma, uma única transação não pode criar nem operar em mais de uma entidade raiz, a menos que seja uma transação entre grupos.

Quando duas ou mais transações tentam modificar entidades simultaneamente em um ou mais grupos de entidades comuns, somente a primeira transação que faz o commit das suas alterações pode ser bem-sucedida. Todas as outras falharão no commit. Por causa desse design, usar grupos de entidades limita o número de gravações simultâneas que você pode fazer em qualquer entidade nos grupos. Quando uma transação é iniciada, o Datastore usa o controle de simultaneidade otimista verificando o horário da última atualização dos grupos de entidades usados na transação. Após a confirmação de uma transação nos grupos de entidades, o Datastore verifica novamente o horário da última atualização dos grupos de entidades usados na transação. Se ela tiver sido alterada depois da verificação inicial, um erro será retornado.

Isolamento e consistência

Fora das transações, o nível de isolamento do Datastore está mais próximo da confirmação de leitura. Dentro, por sua vez, o isolamento serializável é imposto. Isso significa que outra transação não pode modificar simultaneamente os dados lidos ou modificados por essa transação.

Em uma transação, todas as leituras refletem o estado atual e consistente do Datastore no momento em que a transação foi iniciada. As consultas e os recebimentos dentro de uma transação garantem a visualização de um snapshot único e consistente do Datastore desde o início da transação. Entidades e linhas de índice no grupo de entidades da transação são totalmente atualizadas. Dessa forma, as consultas retornam o conjunto completo e correto de entidades de resultado, sem os falsos positivos ou falsos negativos que podem ocorrer em consultas fora das transações.

A visualização desse instantâneo consistente também se estende a leituras após gravações dentro de transações. Diferentemente da maioria dos bancos de dados, as consultas e os recebimentos dentro de uma transação do Datastore não exibem os resultados de gravações anteriores dentro dessa transação. Mais especificamente, se uma entidade tiver sido modificada ou excluída dentro de uma transação, a consulta ou o recebimento retornará a versão original da entidade desde o início da transação, ou nada, caso ela não exista.

Usos em transações

Este exemplo demonstra um uso de transações: atualizar uma entidade com um novo valor de propriedade relativo ao valor atual.

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)
}

Isso requer uma transação porque o valor poderá ser atualizado por outro usuário depois que esse código buscar o objeto, mas antes de ele salvar o objeto modificado. Sem uma transação, a solicitação do usuário usa o valor de count antes da atualização do outro usuário, e a gravação substitui o novo valor. Com uma transação, o aplicativo é informado sobre a atualização. Caso a entidade seja atualizada durante a transação, esta será repetida até que todas as etapas estejam concluídas sem interrupção.

Outro uso comum para transações é buscar uma entidade com uma chave nomeada ou criá-la caso ela ainda não exista:

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)
}

Como antes, uma transação é necessária para manipular o caso quando outro usuário estiver tentando criar ou atualizar uma entidade com o mesmo ID de string. Sem uma transação, se a entidade não existir e dois usuários tentarem criá-la, o segundo substituirá o primeiro sem saber o que aconteceu.

Quando uma transação falha, você pode fazer o app repetir a transação até ser bem-sucedido, ou pode deixar que os usuários lidem com o erro propagando-o até o nível de interface do usuário do app. Não é necessário criar um loop de repetição em cada transação.

Por fim, use a transação para ler um snapshot consistente do Datastore. Isso pode ser útil quando várias leituras são necessárias para renderizar uma página ou exportar dados que precisam ser consistentes. Esses tipos de transações costumam ser chamadas de transações somente leitura, porque elas não realizam gravações. As transações de grupo único somente leitura nunca falham por causa de modificações simultâneas. Dessa forma, você não precisa implementar repetições após uma falha. Porém, as transações entre grupos podem falhar por causa de modificações simultâneas. Dessa maneira, elas devem ter novas tentativas. O commit e a reversão de uma transação somente leitura são de ambiente autônomo.

Enfileiramento de tarefa transacional

É possível enfileirar uma tarefa como parte de uma transação do Datastore, de modo que ela seja enfileirada e garantida para ser enfileirada apenas se a transação for confirmada. Depois de enfileirada, a tarefa não terá garantia de execução imediata. Dessa maneira, a tarefa não é atômica com a transação. Ainda assim, uma vez enfileirada, a tarefa tentará novamente até conseguir. Isso se aplica a qualquer tarefa enfileirada durante uma função RunInTransaction.

As tarefas transacionais são úteis porque permitem combinar ações que não são do Datastore com uma transação que depende do êxito da transação, como o envio de um e-mail para confirmar uma compra. É possível também vincular ações do Datastore à transação, como confirmar alterações em grupos de entidades fora dela, apenas em caso de êxito dessa transação.

Um aplicativo não pode inserir mais de cinco tarefas transacionais em filas de tarefas durante uma única transação. As tarefas transacionais não têm nomes especificados pelo usuário.

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)