Transactions allow you to specify a series of changes to the data store and commit them as one. If any of the individual operations fails, the whole transaction will not be applied. This is extremely useful if you want to maintain counters or have multiple entities that depend on each other's state. During a transaction in Google Cloud Datastore, all entities that are read are locked (other code is prevented from making changes) until the transaction is complete, providing an additional sense of security and preventing data races.
If you were building a bank (it seems crazy, but the guys at Monzo in London are indeed building a bank using Go), you might represent user accounts as an entity called Account
. To transfer money from one account to another, you'd need to make sure the money was deducted from account A and deposited into account B as a single transaction. If either fails, people aren't going to be happy (to be fair, if the deduction operation failed, the owner of account A would probably be happy because B would get the money without it costing A anything).
To see where we are going to use transactions, let's first add model answers to the questions.
Create a new file called answers.go
and add the following struct and validation method:
type Answer struct { Key *datastore.Key `json:"id" datastore:"-"` Answer string `json:"answer"` CTime time.Time `json:"created"` User UserCard `json:"user"` Score int `json:"score"` } func (a Answer) OK() error { if len(a.Answer) < 10 { return errors.New("answer is too short") } return nil }
Answer
is similar to a question, has datastore.Key
(which will not be persisted), has CTime
to capture the timestamp, and embeds UserCard
(representing the person answering the question). It also has a Score
integer field, which will go up and down as users vote on the answers.
Our Question
struct has a field called AnswerCount
, where we intend to store an integer that represents the number of answers that a question has solicited.
First, let's look at what can happen if we don't use a transaction to keep track of the AnswerCount
field by tracking the concurrent activity of answers 4 and 5 of a question:
Step |
Answer 4 |
Answer 5 |
Question.AnswerCount |
1 |
Load question |
Load question |
3 |
2 |
AnswerCount=3 |
AnswerCount=3 |
3 |
3 |
AnswerCount++ |
AnswerCount++ |
3 |
4 |
AnswerCount=4 |
AnswerCount=4 |
3 |
5 |
Save the answer and question |
Save the answer and question |
4 |
You can see from the table that without locking Question, AnswerCount would end up being 4 instead of 5 if the answers came in at the same time. Locking with a transaction will look more like this:
Step |
Answer 4 |
Answer 5 |
Question.AnswerCount |
1 |
Lock the question |
Lock the question |
3 |
2 |
AnswerCount=3 |
Waiting for unlock |
3 |
3 |
AnswerCount++ |
Waiting for unlock |
3 |
4 |
Save the answer and question |
Waiting for unlock |
4 |
5 |
Release lock |
Waiting for unlock |
4 |
6 |
Finished |
Lock the question |
4 |
7 |
AnswerCount=4 |
4 | |
8 |
AnswerCount++ |
4 | |
9 |
Save the answer and question |
5 |
In this case, whichever answer obtains the lock first will perform its operation, and the other operation will wait before continuing. This is likely to slow down the operation (since it has to wait for the other one to finish), but that's a price worth paying in order to get the numbers right.
In code, we use the datastore.RunInTransaction
function. Add the following to answers.go
:
func (a *Answer) Create(ctx context.Context, questionKey *datastore.Key) error { a.Key = datastore.NewIncompleteKey(ctx, "Answer", questionKey) user, err := UserFromAEUser(ctx) if err != nil { return err } a.User = user.Card() a.CTime = time.Now() err = datastore.RunInTransaction(ctx, func(ctx context.Context) error { q, err := GetQuestion(ctx, questionKey) if err != nil { return err } err = a.Put(ctx) if err != nil { return err } q.AnswersCount++ err = q.Update(ctx) if err != nil { return err } return nil }, &datastore.TransactionOptions{XG: true}) if err != nil { return err } return nil }
We first create a new incomplete key (using the Answer
kind) and set the parent as the question key. This will mean that the question will become the ancestor to all these answers.
Using our UserFromAEUser
function, we get the user who is answering the question and set UserCard
inside Answer
before setting CTime
to the current time, as done earlier.
Then, we start our transaction by calling the datastore.RunInTransaction
function that takes a context as well as a function where the transactional code will go. There is a third argument, which is a set of datastore.TransactionOptions
that we need to use in order to set XG
to true
, which informs the data store that we'll be performing a transaction across entity groups (both Answer
and Question
kinds).
When it comes to writing your own functions and designing your own APIs, it is highly recommended that you place any function arguments at the end; otherwise, inline function blocks such as the ones in the preceding code obscure the fact that there is another argument afterwards. It's quite difficult to realize that the TransactionOptions
object is an argument being passed into the RunInTransaction
function, and I suspect somebody on the Google team regrets this decision.
Transactions work by providing a new context for us to use, which means that code inside the transaction function looks the same, as if it weren't in a transaction. This is a nice piece of API design (and it means that we can forgive the function for not being the final argument).
Inside the transaction function, we use our GetQuestion
helper to load the question. Loading data inside the transaction function is what obtains a lock on it. We then put the answer to save it, update the AnswerCount
integer, and update the question. If all is well (provided none of these steps returns an error), the answer will be saved and AnswerCount
will increase by one.
If we do return an error from our transaction function, the other operations are canceled and the error is returned. If that happens, we'll just return that error from our Answer.Create
method and let the user try again.
Next, we are going to add our GetAnswer
helper, which is similar to our GetQuestion
function:
func GetAnswer(ctx context.Context, answerKey *datastore.Key) (*Answer, error) { var answer Answer err := datastore.Get(ctx, answerKey, &answer) if err != nil { return nil, err } answer.Key = answerKey return &answer, nil }
Now we are going to add our Put
helper method in answers.go
:
func (a *Answer) Put(ctx context.Context) error { var err error a.Key, err = datastore.Put(ctx, a.Key, a) if err != nil { return err } return nil }
These two functions are very similar to the GetQuestion
and Question.Put
methods, but let's resist the temptation of abstracting it and drying up the code for now.
Copying and pasting is generally seen by programmers as a bad thing because it is usually possible to abstract the general idea and DRY (Don't repeat yourself) up the code. However, it is worth resisting the temptation to do this right away because it is very easy to design a bad abstraction, which you are then stuck with since your code will start to depend on it. It is better to duplicate the code in a few places first and later revisit them to see whether a sensible abstraction is lurking there.
3.145.33.235