3

Based on this SO answer I came to know that firestore does not have collection level locking in a transaction. In my case, I have to ensure that the username field in users collection is unique before I write to a collection. For that, I write a transaction that does this:

  1. Executes a query on users collection to check if a document exists where username=something
  2. If it does exist, fail and return error from transaction
  3. If it does not exist, just run the write operation for the userId I want to update/create.

Now the issue here is that if two clients simultaneously try to run this transaction, both might query the collection and since the collection is not locked, one client might insert/update a document in collection while other won't see it.

Is my assumption correct? And if yes, then how to deal with such scenarios?

rahulserver
  • 10,411
  • 24
  • 90
  • 164

2 Answers2

5

What you're trying to do is actually not possible to do atomically, as it's not possible to transact safely on a document that you can't identify with an ID. The problem here is that a transaction is only "safe" if you can get() the specific document to add or modify. Since you can't get() a document using a field value in the document, you're at a loss.

If you want to ensure uniqueness of anything in Firestore, that uniqueness will need to be coded into the document ID itself. In the simplest case, you can use the username as the ID of a document in a new collection. If you do that, your transaction can simply get() the required document by username, check to see if it exists, then write the document if it doesn't. Else, the transaction can fail.

Bear in mind that because there are limitations to document IDs in Firestore, you might need to escape or encode that username if your usernames could possibly violate the rules.

Doug Stevenson
  • 297,357
  • 32
  • 422
  • 441
  • Using username as document id won't allow to enforce authorization via rules which means two authenticating users might still use same username. In that case client1-> reads the full collection(does get()) and does not get any matching document. Same with client 2. client1's transaction is successful. Same with client2 because we are not doing anything with data contained in that "get" document(so client1's updates are lost). Is my assumption correct? – rahulserver Aug 05 '20 at 02:41
  • Security rules can certainly [check if a document already exists](https://firebase.google.com/docs/firestore/security/rules-conditions#access_other_documents) to decide what to do with a given write request. You can also allow creates but deny updates and deletes with [granular operations](https://firebase.google.com/docs/firestore/security/rules-structure#granular_operations). This is totally doable if you make full use of the rules language features. – Doug Stevenson Aug 05 '20 at 03:03
  • But via security rules I might have to deny the update operation on all fields. Which might not make sense. And anyways I would prefer using the auth uid as keys. – rahulserver Aug 05 '20 at 03:09
  • The way I see it, your preference really isn't possible here, and rules are sufficient. You don't have to store everything under the username, but you do need at least one document in another collection to protect with that id. – Doug Stevenson Aug 05 '20 at 03:13
  • 1
    I would prefer keeping a separate collection of usernames as here https://medium.com/@jqualls/firebase-firestore-unique-constraints-d0673b7a4952 Just can you confirm if a .get() operation would retry the transaction if initially the document does not exist, but happens to exist later? – rahulserver Aug 05 '20 at 03:46
  • 1
    It should, otherwise I would consider the transaction system to be broken. A set() on a document that already exists should always fail if not merge:true. – Doug Stevenson Aug 05 '20 at 03:49
  • Thanks for the answer. If you could add the username collection(if you feel like) as a part of your answer, I would definitely accept it. Upvoted already!! – rahulserver Aug 05 '20 at 03:53
  • 1
    That was kind of implied in the answer actually. – Doug Stevenson Aug 05 '20 at 03:58
1

An alternative to coding this data into the doc id is to use a separate collection as a sort of manual index. Security rules can then enforce uniqueness on the index. So something like this:

/docs/${documentId} => {uniqueField: "foo", ...}
/docmap/${uniqueField} => {docId: "doc2"}

The idea here is that one must first write the docmap entry containing the new doc id before they are allowed to writet he doc. Since the docmap is keyed on our unique field, it enforces uniqueness.

Security rules would look roughly like so:

  function getPath(childPath) {
     return path('/databases/'+database+'/documents/'+childPath)
  }

  // we can only write to our doc if the unique field exists in docmap/
  // and matches our doc id
  match /docs/{docid} {
     let docMapPath = 'docmap/' + request.resource.data.uniqueField;
     allow write: if getData(docMapPath).docId == docId;
     //todo validate data schema
  }

  // It is only possible to add a uniqueField to the docmap
  // if it doesn't already exist for another doc
  // we also validate that the doc id matches our schema
  match /docmap/{uniqueField} {
     allow write: if resource.data.size() == 0 && 
            request.resource.data.docId is string &&
            request.resource.data.docId.size() < 100
  }

And a write would look roughly like so:

 const db = firebase.firestore();
 db.doc('docmap/foo').set('doc2')
   .then(() => db.doc('docs/doc2').set({uniqueField: 'foo'})
   .then(doc => console.log("success"))
   .catch(e => console.error(e));

You could also do this in a transaction or even a batch operation to make it atomic, but it's probably not necessary to add complexity to the process; the security rules will enforce the constraints.

Kato
  • 40,352
  • 6
  • 119
  • 149