Chapter 9
Transactions

HyperDex Warp is a superset of HyperDex that provides transactions. For this chapter, you’ll need to install the hyperdex-warp demo package available by contacting the HyperDex support team.

By the end of this chapter you’ll be familiar with HyperDex Warp’s transactions.

9.1 Setup

As in the previous chapter, the first step is to deploy the cluster and connect a client. First we launch and initialize the coordinator:

  hyperdex coordinator -f -l 127.0.0.1 -p 1982

Next, let’s launch a few daemon processes to store data. Execute the following commands (note that each instance binds to a different port and has a different /path/to/data):

  hyperdex daemon -f --listen=127.0.0.1 --listen-port=2012 \
                     --coordinator=127.0.0.1 --coordinator-port=1982 --data=/path/to/data1
  hyperdex daemon -f --listen=127.0.0.1 --listen-port=2013 \
                     --coordinator=127.0.0.1 --coordinator-port=1982 --data=/path/to/data2
  hyperdex daemon -f --listen=127.0.0.1 --listen-port=2014 \
                     --coordinator=127.0.0.1 --coordinator-port=1982 --data=/path/to/data3

This brings up three different daemons ready to serve in the HyperDex cluster. Finally, we create a space which makes use of all three systems in the cluster. In this example, let’s create a space that may be suitable for storing virtual currency in a banking application:

  >>> import hyperclient
  >>> c = hyperclient.Client(’127.0.0.1’, 1982)
  >>> c.add_space(’’’
  ... space accounts
  ... key id
  ... attribute
  ...    int balance
  ... ’’’)

Let’s create some accounts with some starting balances:

  >>> c.put(’accounts’, ’joe’, {’balance’: 100})
  True
  >>> c.put(’accounts’, ’brian’, {’balance’: 25})
  True

9.2 Using Transactions

The prototypical example of where transactions come in handy is a standard bank debit/credit scenario. If Joe wanted to transfer currency to Brian, it would be bad for Joe and Brian if the money were to leave Joe’s account and not find its way into Brian’s. It would be bad for the bank if money were to be created out of thin air by a deposit in Brian’s account without a corresponding withdrawal from Joe’s – manipulating markets in such a fashion is reserved for those who are too big to fail.

In the prototypical, ”Hello World,” example involving transactions, we transfer $10 from Joe to Brian:

  >>> x = c.begin_transaction()
  >>> brian = x.get(’accounts’, ’brian’)[’balance’]
  >>> joe = x.get(’accounts’, ’joe’)[’balance’]
  >>> x.put(’accounts’, ’brian’, {’balance’: brian + 10})
  True
  >>> x.put(’accounts’, ’joe’, {’balance’: joe - 10})
  True
  >>> x.commit()
  True
  >>> print "Brian’s balance =", c.get(’accounts’, ’brian’)[’balance’]
  Brian’s balance = 35
  >>> print "Joe’s balance =", c.get(’accounts’, ’joe’)[’balance’]
  Joe’s balance = 90

This transaction is relatively straight-forward. There are three critical properties that will distinguish this transaction. One, either both of these operations execute, or neither do. It is impossible for half of a transaction to be lost.

Two, there is no eventual consistency. All transaction results are immediately visible to all future events. There is no need to reconcile or maintain multiple versions. There is no inconsistency.

Finally, HyperDex Warp is fault tolerant. A user-configurable fault tolerance threshold, f allows HyperDex Warp to remain available in the presence of concurrent failures.

For a real bank application, we’d likely want to charge Joe an (excessive) overdraft fee if he didn’t have the money to cover the transfer. With transactions, implementing this case is trivial:

  >>> x = c.begin_transaction()
  >>> brian = x.get(’accounts’, ’brian’)[’balance’]
  >>> joe = x.get(’accounts’, ’joe’)[’balance’]
  >>> x.put(’accounts’, ’brian’, {’balance’: brian + 10})
  True
  >>> if joe < 10:
  ...     # assess an overdraft fee on Joe
  ...     joe -= 35
  ...
  >>> x.put(’accounts’, ’joe’, {’balance’: joe - 10})
  True
  >>> x.commit()
  True
  >>> print "Brian’s balance =", c.get(’accounts’, ’brian’)[’balance’]
  Brian’s balance = 45
  >>> print "Joe’s balance =", c.get(’accounts’, ’joe’)[’balance’]
  Joe’s balance = 80

Here, we’ve successfully transferred money from Joe to Brian and cover the case where the bank wishes to charge Joe a fee for not having enough money in the first place.

The astute reader will notice that the example above is very different from doing the following:

  >>> # an example of broken code
  >>> c.atomic_add(’accounts’, ’brian’, {’balance’: 10})
  True
  >>> c.atomic_sub(’accounts’, ’joe’, {’balance’: 10})
  True
  >>> print "Brian’s balance =", c.get(’accounts’, ’brian’)[’balance’]
  Brian’s balance = 55
  >>> print "Joe’s balance =", c.get(’accounts’, ’joe’)[’balance’]
  Joe’s balance = 70

This example is broken in multiple ways. First, even though each individual operation is atomic, the two operations are not indivisible, and will be interspersed with all other operations on the accounts. Second, the client is a single point of failure for this transaction. Imagine if the server executing these operations failed between the two atomic operations. No matter what the order of operations, someone would lose money.

Transactions are especially useful when multiple competing processes are executing transactions concurrently. HyperDex Warp guarantees *one-copy serializability*. Any number of transactions may execute simultaneously. The final state of the database will always be identical to executing the committed transactions sequentially without concurrency.

Let’s illustrate HyperDex’s behavior with concurrent transactions. In the same terminal, let’s begin a new transaction to pay Joe his yearly 5% interest payment:

  >>> x = c.begin_transaction()
  >>> balance = x.get(’accounts’, ’joe’)[’balance’]
  >>> balance = int(balance * 1.05)
  >>> x.put(’accounts’, ’joe’, {’balance’: balance})
  True

At this point, transaction x has not committed. Any modifications performed within the context of a transaction are visible only within the transaction. No other clients or transactions may observe the state. Verify this for yourself with:

  >>> print "Joe’s balance =", c.get(’accounts’, ’joe’)[’balance’]
  Joe’s balance = 70
  >>> print "Joe’s balance =", x.get(’accounts’, ’joe’)[’balance’]
  Joe’s balance = 73

Joe’s balance is still the same as before. Now, open another window and start a second, concurrent transaction where Joe deposits cash at a branch location:

  >>> import hyperclient
  >>> c2 = hyperclient.Client(’127.0.0.1’, 1982)
  >>> y = c2.begin_transaction()
  >>> balance = y.get(’accounts’, ’joe’)[’balance’]
  >>> balance += 386
  >>> y.put(’accounts’, ’joe’, {’balance’: balance})
  True

At this point, both x and y are ready to commit, but neither has actually altered the account balance. In fact, there is no way for x and y to commit that doesn’t violate consistency. If x commits before y, the ending balance will be $466, shorting Joe of the $4 in interest he is due. If, however, y commits before x, then the ending balance of $84 will cause Joe’s cash deposit of $386 to be lost into the ether. In this situation, one transaction must abort. If y commits first, then x will abort:

In the second terminal, commit y:

  >>> y.commit()
  True
  >>> print "Joe’s balance =", c2.get(’accounts’, ’joe’)[’balance’]
  Joe’s balance = 456

Trying to commit x in the first terminal will result in the transaction being aborted by HyperDex Warp:

  >>> x.commit()
  Traceback (most recent call last):
  HyperClientException: HyperClient(HYPERCLIENT_ABORTED, Transaction was aborted)
  Traceback (most recent call last):
  HyperClientException: HyperClient(HYPERCLIENT_ABORTED, Transaction was aborted)

As expected, transaction x will not alter Joe’s balance:

  >>> print "Joe’s balance =", c2.get(’accounts’, ’joe’)[’balance’]
  Joe’s balance = 456

A transaction will only abort if it was executed simultaneously with another transaction that operates on the same data. The typical process for handling aborted transactions is to repeatedly try the transaction until it succeeds:

  >>> committed = False
  >>> while not committed:
  ...     try:
  ...         x = c.begin_transaction()
  ...         balance = x.get(’accounts’, ’joe’)[’balance’]
  ...         balance = int(balance * 1.05)
  ...         x.put(’accounts’, ’joe’, {’balance’: balance})
  ...         committed = x.commit()
  ...     except hyperclient.HyperClientException as e:
  ...         if e.status != hyperclient.HYPERCLIENT_ABORTED:
  ...             raise e
  ...
  True

9.3 Rich API

HyperDex Warp Transactions expose the full set of atomic and asynchronous operations from the HyperDex API. All operations performed under a transaction are fully transactional. The following example shows asynchronous atomic math operations combined with an asynchronous commit:

  >>> t = c.begin_transaction()
  >>> d1 = t.async_atomic_add(’accounts’, ’brian’, {’balance’: 10})
  >>> d2 = t.async_atomic_sub(’accounts’, ’joe’, {’balance’: 10})
  >>> d1.wait()
  True
  >>> d2.wait()
  True
  >>> d3 = t.async_commit()
  >>> d3.wait()
  True

All transactions operate on the most recent copy of the data. When a transaction commits, the transaction is consistently applied. HyperDex Warp really means it when it says a transaction has committed. There are no conflicting operations to reconcile later (after the system commits), and there is no eventual consistency. All effects are immediate and permanent.

9.4 Summary

HyperDex Warp provides decentralized, distributed ACID transactions. Committed transactions are one-copy serializable, making it extremely easy to reason about the concurrent operations. The rich API coupled with transactional guarantees provide a rare combination of ease-of-use, correctness, and performance that other NoSQL systems cannot provide.