Compare Exchange sync multiple reservations (UNIQUE-constraints)

Viewed 113

When I was reading up on Compare Exhange for RavenDB I found the following user case in the documentation for reserving a email. Basically a way to enforcing a UNIQUE-constraint. This works great if you want to only enforce this constraint for one property but ones you introduce multiple properties (email and user name) it no longer works as expected.

See in Docs: Link

class Program
{
    public class User
    {
        public string Id { get; set; }

        public string Name { get; set; }

        public string Email { get; set; }
    }

    static void Main(string[] args)
    {
        var store = new DocumentStore()
        {
            Urls = new[] {
                "http://127.0.0.1:8080/"
            }, 
            Database = "example",
        }.Initialize();

        string name = "admin";
        string email = "admin@example.com";

        var user = new User
        {
            Name = name,
            Email = email
        };

        using (IDocumentSession session = store.OpenSession())
        {
            session.Store(user);

            // Try to reserve a new user email 
            // Note: This operation takes place outside of the session transaction, 
            //       It is a cluster-wide reservation
            CompareExchangeResult<string> namePutResult
                = store.Operations.Send(
                    new PutCompareExchangeValueOperation<string>("names/" + name, user.Id, 0));


            if (namePutResult.Successful == false)
            {
                throw new Exception("Name is already in use");
            }
            else
            {
                // Try to reserve a new user email 
                // Note: This operation takes place outside of the session transaction, 
                //       It is a cluster-wide reservation
                CompareExchangeResult<string> emailPutResult
                    = store.Operations.Send(
                        new PutCompareExchangeValueOperation<string>("emails/" + email, user.Id, 0));

                // Unlock name again (Because if we dont the name wil be locked)
                if (emailPutResult.Successful == false)
                {
                    // First, get existing value
                    CompareExchangeValue<string> readResult =
                        store.Operations.Send(
                            new GetCompareExchangeValueOperation<string>("names/" + name));

                    // Delete the key - use the index received from the 'Get' operation
                    CompareExchangeResult<string> deleteResult
                        = store.Operations.Send(
                            new DeleteCompareExchangeValueOperation<string>("names/" + name, readResult.Index));

                    // The delete result is successful only if the index has not changed between the read and delete operations
                    if (deleteResult.Successful == false)
                    {
                        throw new Exception("The name is forever lost");
                    }
                    else
                    {
                        throw new Exception("Email is already in use");
                    }
                }
            }

            // At this point we managed to reserve/save both the user name and email
            // The document can be saved in SaveChanges
            session.SaveChanges();
        }
    }
}

In the example above you can see why this no longer works as expected. Because now if the email Compare Exchange failed or is already taken there is a change the name Compare Exchange cannot be reversed/removed because removing a Compare Exchange can theoretically fail. Now because of this there is a change the user name will get permanently locked and can't be used again. This same problem also happens when you try to update the user name because you will have to unlock/remove the Compare Exchange for the old user name once the new one is reserved.

What is the best approach for something like this and what are the changes of this happening?

2 Answers

if you are in namePutResult.Successful context then you know for sure that namePutResult.Index is the unique index that was used to create the CompareExchange, so in case that email is taken, you can straight use the namePutResult.Index to remove the CompareExchange, in case of failure you can handle the exception (resend the DeleteCompareExchangeValueOperation`).

        using (IDocumentSession session = store.OpenSession())
        {
            session.Store(user);

            // Try to reserve a new user email 
            // Note: This operation takes place outside of the session transaction, 
            //       It is a cluster-wide reservation
            CompareExchangeResult<string> namePutResult
                = store.Operations.Send(
                    new PutCompareExchangeValueOperation<string>("names/" + name, user.Id, 0));


            if (namePutResult.Successful == false)
            {
                throw new Exception("Name is already in use");
            }
            else
            {
                // Try to reserve a new user email 
                // Note: This operation takes place outside of the session transaction, 
                //       It is a cluster-wide reservation
                CompareExchangeResult<string> emailPutResult
                    = store.Operations.Send(
                        new PutCompareExchangeValueOperation<string>("emails/" + email, user.Id, 0));

                // Unlock name again (Because if we dont the name wil be locked)
                if (emailPutResult.Successful == false)
                {
                    // Delete the key - use the index of PUT operation
                    
                    // TODO: handle failure of this command
                    CompareExchangeResult<string> deleteResult
                        = store.Operations.Send(
                            new DeleteCompareExchangeValueOperation<string>("names/" + name, namePutResult.Index));

                    if (deleteResult.Successful == false)
                    {
                        throw new Exception("The name is forever lost");
                    }
                    else
                    {
                        throw new Exception("Email is already in use");
                    }
                }
            }

            // At this point we managed to reserve/save both the user name and email
            // The document can be saved in SaveChanges
            session.SaveChanges();
        }
Related