Compared to most popular databases, Google Firestore is very minimalistic.

A very important feature that it lacks, is that of unique constraints (unique indexes). This feature is particularly important when we are building a system that allows users to pick a username. If we don’t enforce uniqueness we could end with two users with the same username, which is not what we want.

Using Document ID and transactions

If we are able to use the username as the user id, then things are a little easier. We can follow these steps to create a new user (wrapped in a transaction):

  • Get user by ID (username)
  • If it exists, user is taken, return an error
  • If it doesn’t exist, add it

Before we proceed with this solution, there are a few things that are important to know about transactions in firestore:

  • Reads before writes - We must perform all reads before any write
  • Only queries by ID - We can only get documents by ID in a transactions. We can’t lock a whole table by doing a query
  • Optimistic concurrency - Steps are performed without locks. Before committing, Firestore checks if any of the resources involved in the transaction have changed. If they have, it starts over, if they haven’t, it commits all changes atomically.

Let’s see what would happen if we don’t use a transaction and a user tries to pick a username that has already been taken:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package main

import (
  "context"
  "errors"
  "log"

  "cloud.google.com/go/firestore"
  "google.golang.org/api/option"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
)

// Constants necessary to create the firestore client
const GcpCredentialsFile = "/tmp/key.json"
const ProjectId = "project-1234"

// When done with the client close it using:
// defer client.Close()
func createClient(ctx context.Context) *firestore.Client {
  client, err := firestore.NewClient(ctx, ProjectId, option.WithCredentialsFile(GcpCredentialsFile))

  if err != nil {
    log.Fatalf("Failed to create client: %v", err)
  }

  return client
}

func saveUser(username string) error {
  ctx := context.Background()

  client := createClient(ctx)
  defer client.Close()

  _, err := client.Collection("users").Doc(username).Get(ctx)
  if err == nil {
    log.Printf("User %s already taken", username)
    return errors.New("DuplicateEntry")
  }

  if err != nil && status.Code(err) != codes.NotFound {
    log.Printf("Error retrieving data from DB. %v", err)
    return err
  }

  _, err = client.Collection("users").Doc(username).Set(ctx, map[string]interface{}{
    "username": username,
  })

  return err
}

func main() {
  // Sequential:
  // Try to save user and then we try to save another user with the same name.
  // In this case there is no problem since we will notice the user is already
  // taken
  username := "carlos"
  err := saveUser(username)
  if err != nil {
    log.Printf("This won't happen since its the first time we create the user")
    return
  } else {
    log.Printf("%s saved successfully", username)
  }

  err = saveUser(username)
  if err != nil {
    log.Printf("User not saved because it already exists. %v", err)
  }
}

In this case things work well because we check if the user exists before we try to save it.

Let’s now see what happens if the two users try to pick the same username at the same time:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package main

import (
  "context"
  "errors"
  "log"
  "time"

  "cloud.google.com/go/firestore"
  "google.golang.org/api/option"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
)

// Constants necessary to create the firestore client
const GcpCredentialsFile = "/tmp/key.json"
const ProjectId = "project-1234"

// When done with the client close it using:
// defer client.Close()
func createClient(ctx context.Context) *firestore.Client {
  client, err := firestore.NewClient(ctx, ProjectId, option.WithCredentialsFile(GcpCredentialsFile))

  if err != nil {
    log.Fatalf("Failed to create client: %v", err)
  }

  return client
}

func saveUser(username string) <- chan error {
  errorChannel := make(chan error)

  go func() {
    ctx := context.Background()
    client := createClient(ctx)
    defer client.Close()


    _, err := client.Collection("users").Doc(username).Get(ctx)
    if err == nil {
      log.Printf("User %s already taken", username)
      errorChannel <- errors.New("DuplicateEntry")
      return
    }

    // Inject a delay here to show the race condition
    time.Sleep(200 * time.Millisecond)

    if err != nil && status.Code(err) != codes.NotFound {
      log.Printf("Error retrieving data from DB. %v", err)
      errorChannel <- err
      return
    }

    _, err = client.Collection("users").Doc(username).Set(ctx, map[string]interface{}{
      "username": username,
    })

    errorChannel <- err
  }()

  return errorChannel
}

func main() {
  // Concurrent:
  // Try to save user and then we try to save another user concurrently.
  // Since none of the threads see the existing user, they both try to save a new
  // user. Both users think they got the username, but only one actually got it
  username := "carlos"
  errorChannel1 := saveUser(username)
  errorChannel2 := saveUser(username)
  err1 := <- errorChannel1
  err2 := <- errorChannel2

  if err1 == nil && err2 == nil {
    // This is a race condition
    log.Printf("Seems like everything went well, but only one of them was actually saved")
  } else {
    log.Printf("User already existed")
  }
}

In the case above, we have a problem. One of the users thinks they got the username they selected, but they didn’t.

We can use transactions to prevent this problem:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package main

import (
  "context"
  "errors"
  "log"
  "time"

  "cloud.google.com/go/firestore"
  "google.golang.org/api/option"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
)

// Constants necessary to create the firestore client
const GcpCredentialsFile = "/tmp/key.json"
const ProjectId = "project-1234"

// When done with the client close it using:
// defer client.Close()
func createClient(ctx context.Context) *firestore.Client {
  client, err := firestore.NewClient(ctx, ProjectId, option.WithCredentialsFile(GcpCredentialsFile))

  if err != nil {
    log.Fatalf("Failed to create client: %v", err)
  }

  return client
}

func saveUser(username string) <- chan error {
  errorChannel := make(chan error)

  go func() {
    ctx := context.Background()
    client := createClient(ctx)
    defer client.Close()

    ref := client.Collection("users").Doc(username)
    // We use RunTransaction to start a transaction
    txError := client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
      _, err := tx.Get(ref) // Get inside a transaction
      if err == nil {
        log.Printf("User %s already taken", username)
        return errors.New("DuplicateEntry")
      }

      if err != nil && status.Code(err) != codes.NotFound {
        log.Printf("Error retrieving data from DB. %v", err)
        return err
      }

      // Inject a delay. Even when this delay, the race condition will be prevented
      // by the transactions. There will be a clear winner
      time.Sleep(200 * time.Millisecond)

      return tx.Set(ref, map[string]interface{}{ // Set inside a transaction
        "username": username,
      })
    })

    errorChannel <- txError
  }()

  return errorChannel
}

func main() {
  // Concurrent:
  // Try to save user and then we try to save another user concurrently.
  // One of the threads will see that there is no user and try to save, but
  // because it detects that there was a change in the data, it will fail
  username := "carlos"
  errorChannel1 := saveUser(username)
  errorChannel2 := saveUser(username)
  err1 := <- errorChannel1
  err2 := <- errorChannel2

  if err1 == nil {
    log.Printf("Thread 1 saved the user")
  } else {
    log.Printf("Thread 1 failed to saved the user. %v", err1)
  }

  if err2 == nil {
    log.Printf("Thread 2 saved the user")
  } else {
    log.Printf("Thread 2 failed to saved the user. %v", err2)
  }
}

The example above shows how to avoid a conflict between 2 users trying to pick the same username. Let’s now see the slightly more complicated scenario where we can’t use the username as Document ID.

Using transactions without relying on Document ID

There are some scenarios where we don’t want to (or can’t) use the username as Document ID. I stumbled into this scenario in a system that uses Open ID for sign in. In that case, the system uses the Open ID user identifier as the Document ID. The username is just a field in the document.

Even though the title of the section mentions not relying on Document ID, this is a small lie. We are going to use a Document ID, but we are going to create a different collection for this.

We will have our main users collection where we will use a random ID as Document ID, but we are going to create another collection named users-usernames where we will store the username and use a similar technique to the one in the previous section.

Since we already showed the race condition in the previous section, we are just going to see the solution here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package main

import (
  "context"
  "errors"
  "log"
  "os/exec"
  "time"

  "cloud.google.com/go/firestore"
  "google.golang.org/api/option"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
)

// Constants necessary to create the firestore client
const GcpCredentialsFile = "/tmp/key.json"
const ProjectId = "project-1234"

// When done with the client close it using:
// defer client.Close()
func createClient(ctx context.Context) *firestore.Client {
  client, err := firestore.NewClient(ctx, ProjectId, option.WithCredentialsFile(GcpCredentialsFile))

  if err != nil {
    log.Fatalf("Failed to create client: %v", err)
  }

  return client
}

func saveUser(username string) <- chan error {
  errorChannel := make(chan error)

  go func() {
    ctx := context.Background()
    client := createClient(ctx)
    defer client.Close()

    // Generate a UUID (Only works on linux)
    uuidByteArray, err := exec.Command("uuidgen").Output()
    if err != nil {
      log.Printf("Error generating UUID. %v", err)
      errorChannel <- err
      return
    }

    uuid := string(uuidByteArray)

    ref := client.Collection("users-usernames").Doc(username)
    userRef := client.Collection("users").Doc(uuid)
    // We use RunTransaction to start a transaction
    txError := client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
      // First check if the username is taken
      _, err := tx.Get(ref)
      if err == nil {
        log.Printf("User %s already taken", username)
        return errors.New("DuplicateEntry")
      }

      if err != nil && status.Code(err) != codes.NotFound {
        log.Printf("Error retrieving data from DB. %v", err)
        return err
      }

      // Inject a delay. Even when this delay, the race condition will be prevented
      // by the transactions. There will be a clear winner
      time.Sleep(200 * time.Millisecond)



      // If the user is not taken, we create it
      err = tx.Set(ref, map[string]interface{}{
        // Save the user id so we know who got the username
        "user-id": uuid,
      })
      if err != nil {
        log.Printf("Error saving into `user-usernames`. %v", err)
        return err
      }

      // Finally, save the `user`
      return tx.Set(userRef, map[string]interface{}{
        // Save the username here
        "username": username,
      })
    })

    errorChannel <- txError
  }()

  return errorChannel
}

func main() {
  // Concurrent:
  // Try to save user and then we try to save another user concurrently.
  // One of the threads will see that there is no user and try to save, but
  // because it detects that there was a change in the data, it will fail
  username := "carlos"
  errorChannel1 := saveUser(username)
  errorChannel2 := saveUser(username)
  err1 := <- errorChannel1
  err2 := <- errorChannel2

  if err1 == nil {
    log.Printf("Thread 1 saved the user")
  } else {
    log.Printf("Thread 1 failed to saved the user. %v", err1)
  }

  if err2 == nil {
    log.Printf("Thread 2 saved the user")
  } else {
    log.Printf("Thread 2 failed to saved the user. %v", err2)
  }
}

Conclusion

Although it is possible to write code to overcome the lack of unique indexes in Google Firestore, the code ends up being a lot more complicated than the SQL equivalent. While we wait for the feature to be supported, we have to resource to tricks like these.

[ databases  gcp  golang  ]
Dealing with Firestore 10 limit when using IN operator
Running Google Firestore locally
Introduction to Google Firestore