Transactions in Google Cloud Datastore

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.

Note

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.

Using transactions to maintain counters

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.

Tip

It's best to keep the amount of work inside a transaction as small as possible because you are essentially blocking other people while the transaction is underway. Outside of transactions, Google Cloud Datastore is extremely fast because it isn't making the same kinds of guarantees.

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.

Tip

Ancestor keys are special in Google Cloud Datastore, and it is recommended that you read about the nuances behind them in the documentation on the Google Cloud Platform website.

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

Tip

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.

Avoiding early abstraction

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.33.235