Compare Exchange Overview



What Compare Exchange Items Are

Compare Exchange items are key/value pairs where the key servers a unique value across your database.

  • Each compare-exchange item contains:

    • A key - A unique string identifier in the database scope.
    • A value - Can be any object (a number, string, array, or any valid JSON object).
    • Metadata - Data that is associated with the compare-exchange item. Must be a valid JSON object.
      • For example, the metadata can be used to set expiration time for the compare-exchange item.
        Learn more in compare-exchange expiration.
    • Raft index - The compare-exchange item's version.
      Any change to the value or metadata will increase this number.
  • Creating and modifying a compare-exchange item is an atomic, thread-safe compare-and-swap interlocked compare-exchange operation.

Creating and Managing Compare-Exchange Items

Compare exchange items are created and managed with any of the following approaches:

  • Document Store Operations
    You can manage a compare-exchange item as an Operation on the document store.
    This can be done within or outside of a session (cluster-wide or single-node session).

    • When inside a session:
      If the session fails, the compare-exchange operation can still succeed because store Operations do not rely on the success of the session.
      You will need to delete the compare-exchange item explicitly upon session failure if you don't want the compare-exchange item to persist.
  • Cluster-Wide Sessions
    You can manage a compare-exchange item from inside a Cluster-Wide session.
    If the session fails, the compare-exchange item creation also fails.
    None of the nodes in the group will have the new compare-exchange item.

  • Atomic Guards
    When creating documents using a cluster-wide session RavenDB automatically creates Atomic Guards,
    which are compare-exchange items that guarantee ACID transactions.
    See Cluster-wide vs. Single-node for a session comparision overview.

  • Studio
    Compare-exchange items can be created from the Studio as well.

Why Compare-Exchange Items are Not Replicated to External Databases

  • Each cluster defines its policies and configurations, and should ideally have sole responsibility for managing its own documents. Read Consistency in a Globally Distributed System to learn more about why global database modeling is more efficient this way.

  • When creating a compare-exchange item a Raft consensus is required from the nodes in the database group. Externally replicating such data is problematic as the target database may reside within a cluster that is in an unstable state where Raft decisions cannot be made. In such a state, the compare-exchange item will not be persisted in the target database.

  • Conflicts between documents that occur between two databases are solved with the help of the documents Change-Vector. Compare-exchange conflicts cannot be handled properly as they do not have a similar mechanism to resolve conflicts.

  • To ensure unique values between two databases without using compare-exchange items see Example III.

Example I - Email Address Reservation

The following example shows how to use compare-exchange to create documents with unique values.
The scope is within the database group on a single cluster.

Compare-exchange items are not externally replicated to other databases.
To establish uniqueness without using compare-exchange see Example III.

$email = "user@example.com";

$user = new User();
$user->setEmail($email);

$session = $store->openSession();
try {
    $session->store($user);

    // At this point, the user document has an Id assigned

    // Try to reserve a new user email
    // Note: This operation takes place outside of the session transaction,
    //       It is a cluster-wide reservation

    /** @var CompareExchangeResult $cmpXchgResult */
    $cmpXchgResult = $store->operations()->send(new PutCompareExchangeValueOperation("emails/" . $email, $user->getId(), 0));

    if (!$cmpXchgResult->isSuccessful()) {
        throw new RuntimeException("Email is already in use");
    }

    // At this point we managed to reserve/save the user email -
    // The document can be saved in SaveChanges
    $session->saveChanges();
} finally {
    $session->close();
}

Implications:

  • The User object is saved as a document, hence it can be indexed, queried, etc.

  • This compare-exchange item was created as an operation rather than with a cluster-wide session.
    Thus, if session.saveChanges fails, then the email reservation is not rolled back automatically.
    It is your responsibility to do so.

  • The compare-exchange value that was saved can be accessed in a query using CmpXchg:

    $query = $session->advanced()->rawQuery(User::class,
        "from Users as s where id() == cmpxchg(\"emails/ayende@ayende.com\")")
        ->toList();
    $q = $session->advanced()
        ->documentQuery(User::class)
        ->whereEquals("id", CmpXchg::value("emails/ayende@ayende.com"));
    from Users as s where id() == cmpxchg("emails/ayende@ayende.com")

Example II - Reserve a Shared Resource

In the following example, we use compare-exchange to reserve a shared resource.
The scope is within the database group on a single cluster.

The code also checks for clients which never release resources (i.e. due to failure) by using timeout.

class SharedResource
{
    private ?DateTime $reservedUntil = null;

    public function getReservedUntil(): ?DateTime
    {
        return $this->reservedUntil;
    }

    public function setReservedUntil(?DateTime $reservedUntil): void
    {
        $this->reservedUntil = $reservedUntil;
    }
}

class CompareExchangeSharedResource
{
    private ?DocumentStore $store = null;

    public function printWork(): void
    {
        // Try to get hold of the printer resource
        $reservationIndex = $this->lockResource($this->store, "Printer/First-Floor", Duration::ofMinutes(20));

        try {
            // Do some work for the duration that was set.
            // Don't exceed the duration, otherwise resource is available for someone else.
        } finally {
            $this->releaseResource($this->store, "Printer/First-Floor", $reservationIndex);
        }
    }

    /**  throws InterruptedException */
    public function lockResource(DocumentStoreInterface $store, ?string $resourceName, Duration $duration): int
    {
        while (true) {
            $now = new DateTime();

            $resource = new SharedResource();
            $resource->setReservedUntil($now->add($duration->toDateInterval()));

            /** @var CompareExchangeResult<SharedResource> $saveResult */
            $saveResult = $store->operations()->send(
                new PutCompareExchangeValueOperation($resourceName, $resource, 0));

            if ($saveResult->isSuccessful()) {
                // resourceName wasn't present - we managed to reserve
                return $saveResult->getIndex();
            }

            // At this point, Put operation failed - someone else owns the lock or lock time expired
            if ($saveResult->getValue()->getReservedUntil() < $now) {
                // Time expired - Update the existing key with the new value
                /** @var CompareExchangeResult<SharedResource> takeLockWithTimeoutResult */
                $takeLockWithTimeoutResult = $store->operations()->send(
                    new PutCompareExchangeValueOperation($resourceName, $resource, $saveResult->getIndex()));

                if ($takeLockWithTimeoutResult->isSuccessful()) {
                    return $takeLockWithTimeoutResult->getIndex();
                }
            }

            // Wait a little bit and retry
            usleep(20000);
        }
    }

    public function releaseResource(DocumentStoreInterface $store, ?string $resourceName, int $index): void
    {
        $deleteResult = $store->operations()->send(
            new DeleteCompareExchangeValueOperation(SharedResource::class, $resourceName, $index)
        );

        // We have 2 options here:
        // $deleteResult->isSuccessful is true - we managed to release resource
        // $deleteResult->isSuccessful is false - someone else took the lock due to timeout
    }
}

Example III - Ensuring Unique Values without Using Compare Exchange

Unique values can also be ensured without using compare-exchange.

The below example shows how to achieve that by using reference documents.
The reference documents' IDs will contain the unique values instead of the compare-exchange items.

Using reference documents is especially useful when External Replication is defined between two databases that need to be synced with unique values.
The reference documents will replicate to the destination database, as opposed to compare-exchange items, which are not externally replicated.

Sessions which process fields that must be unique should be set to TransactionMode::clusterWide().

// When you create documents that must contain a unique value such as a phone or email, etc.,
// you can create reference documents that will have that unique value in their IDs.
// To know if a value already exists, all you need to do is check whether a reference document with such ID exists.

public

class PhoneReference
{
    public ?string $id = null;
    public ?string $companyId = null;

    public function getId(): ?string
    {
        return $this->id;
    }

    public function setId(?string $id): void
    {
        $this->id = $id;
    }

    public function getCompanyId(): ?string
    {
        return $this->companyId;
    }

    public function setCompanyId(?string $companyId): void
    {
        $this->companyId = $companyId;
    }
}

// The reference document class
class UniquePhoneReference
{
    public function sample(): void
    {
        // A company document class that must be created with a unique 'Phone' field
        $newCompany = new Company();
        $newCompany->setName("companyName");
        $newCompany->setPhone("phoneNumber");

        $newContact = new Contact();
        $newContact->setName("contactName");
        $newContact->setTitle("contactTitle");

        $newCompany->setContact($newContact);

        $this->createCompanyWithUniquePhone($newCompany);
    }

    public function createCompanyWithUniquePhone(Company $newCompany): void
    {
        // Open a cluster-wide session in your document store
        $sessionOptions = new SessionOptions();
        $sessionOptions->setTransactionMode(TransactionMode::clusterWide());
        $session = DocumentStoreHolder::getStore()->openSession($sessionOptions);

        try {
            // Check whether the new company phone already exists
            // by checking if there is already a reference document that has the new phone in its ID.
            $phoneRefDocument = $session->load(PhoneReference::class, "phones/" . $newCompany->getPhone());
            if ($phoneRefDocument != null) {
                $msg = "Phone '" . $newCompany->getPhone() . "' already exists in ID: " . $phoneRefDocument->getCompanyId();
                throw new ConcurrencyException($msg);
            }

            // If the new phone number doesn't already exist, store the new entity
            $session->store($newCompany);
            // Store a new reference document with the new phone value in its ID for future checks.
            $newPhoneReference = new PhoneReference();
            $newPhoneReference->setCompanyId($newCompany->getId());
            $session->store($newPhoneReference, "phones/" . $newCompany->getPhone());

            // May fail if called concurrently with the same phone number
            $session->saveChanges();
        } finally {
            $session->close();
        }
    }
}