-
Notifications
You must be signed in to change notification settings - Fork 277
How AppScale implements Transaction support
AppScale provides support for pluggable databases, allowing users to run their Google App Engine applications with Cassandra as a backing store, and then to switch over to the same app running on HBase or Hypertable. To provide this pluggable database support, we provide a layer of abstraction between the App Server (which can be written in Python, Java, or Go) and the databases, which we call AppDB. This post explains how AppDB provides support for transactions while providing pluggable database support, and how we have extended it to support cross-group (XG) transactions in Google App Engine.
The Google App Engine Datastore API lets users write Python, Java, or Go code to save and retrieve data. Let's start off with an example. First, we define a Model, representing a class we want to store in the Datastore:
class BlogComment(db.Model):
text = db.StringProperty(required=True)
Here, our BlogComment has only one field, text
, which is a string. This isn't a new concept - ORM has been around for a while now, which is why it's nice that Google took this on instead of creating something drastically new. You can make a new BlogComment in your app by typing:
comment = BlogComment(key_name=key, text="hello")
And this instantiated object is referred to as an Entity. Entities are organized into Entity Groups. Standard transactions within Google App Engine operate within a single entity group, so you could add and delete BlogComments for a single BlogPost within a transaction, but you can't operate on BlogComments across two BlogPosts within one transaction.
Now that you know what transactions look like to users, let's talk about how we make them work in AppScale.
In AppScale, we want to provide transactions with the same semantics that Google App Engine provides, but for any database that our database-agnostic layer (AppDB) supports. To do this, we need to be able to atomically acquire and release locks, so we leverage ZooKeeper to do this for us.
Let's look at a transaction a user writes and see how this gets converted to calls to our database-agnostic layer. Suppose we have a transaction that retrieves a comment, changes its value, and stores it back in the Datastore:
def boo():
comment1 = BlogComment.get_by_key_name("key1")
comment1.text = "new text"
comment1.put()
db.run_in_transaction(boo)
This transaction gets reduced into the following steps:
- BeginTransaction (called when
run_in_transaction
starts) - Zero or more Puts, Gets, Queries, or Deletes (called by the user's function).
- CommitTransaction (called when
run_in_transaction
ends)
Let's break down each of those steps from the AppDB point of view.
Transactions are identified by the entity group that they operate on. AppDB begins by asking ZooKeeper for a sequential node (a node whose name ends in a monotonically increasing ID) with the following path:
/appscale/apps/appid/txn_ids/tx001
where appid
is the name of the application we're running the transaction for, and tx001
means this is the first transaction being performed (the 001
is sequentially given to us by ZooKeeper).
BeginTransaction
sets up the ZooKeeper path for the transaction, but doesn't actually acquire any locks. We acquire locks in response to puts, gets, and deletes. When a put, get, query, or delete happens, AppDB looks at the entity the operation is occurring on. If we don't have the lock for that entity, we check the "lock path" for the transaction, located at:
/appscale/apps/appid/txn_ids/tx001/lockpath
If the lock path doesn't exist (which it doesn't for the first operation), then we create it and set its value to the entity group we're operating on. If the lock path does exist, we look at its value and see if it's the same as the entity group we're operating on. If it is, we have the entity group locked and can safely operate on it. If it isn't the same, then that means we're trying to operate on more than one entity group, which isn't allowed in the standard transaction model, so we rollback the transaction and abort it.
In our BlogComment example, the get_by_key_name
will call get
, which causes the lock path to be created and set to key1
. When the put
happens, we look at the lock path, see that it exists and is set to key1
, and thus proceed with the put
operation.
Finally, the last step of a db.run_in_transaction
is to commit the transaction. This is essentially the opposite of the BeginTransaction
step, so we clean up all the transaction state we created earlier. We start by deleting the lock path from our transaction, as well as the sequential node we created earlier. Presuming that those delete operations succeed, we're good to go!
For the sake of brevity and clarity, this example assumes that everything succeeded without any problems. We do implement rollback and transaction ID blacklisting for scenarios when there are problems acquiring ZooKeeper locks, or when a transaction tries to touch multiple entity groups.
Now that we've shown how AppScale implements transaction support within a single entity group, let's look at how we've expanded it to work on multiple entity groups (XG).
Let's look at a cross-group transaction a user writes and see how this gets converted to calls to our database-agnostic layer. Suppose we have a transaction that gets two comments, in different entity groups, updates their text, and stores it back in the Datastore:
def boo():
comment1 = BlogComment.get_by_key_name("key1")
comment1.text = "new text 1"
comment1.put()
comment2 = BlogComment.get_by_key_name("key2")
comment2.text = "new text 2"
comment2.put()
xg_on = db.create_transaction_options(xg=True)
db.run_in_transaction_options(xg_on, boo)
Like before, this transaction gets reduced into the following steps:
- BeginTransaction
- Zero or more Puts, Gets, Queries, or Deletes
- CommitTransaction
Let's break down what we change in AppDB to support cross-group transactions.
This step is mostly the same as before, but after we create the transaction path (with the sequential ID), we look in the BeginTransaction request and see if the user has specified xg=True
. If they have, we create a ZooKeeper node at the following path and set its value to True:
/appscale/apps/appid/txn_ids/tx001/is_xg
Datastore operations occur very similarly as before, but now, instead of there being a lock path, we change it to be a "lock list path", which instead of being a pointer to one entity group, is now a list of pointers to entity groups. The new path is called:
/appscale/apps/appid/txn_ids/tx001/lock_list_path
Like before, if the lock path doesn't exist (which it doesn't for the first operation), then we create it and set its value to the entity group we're operating on. If the lock path does exist, we look at its value and see if it's the same as the entity group we're operating on. If it is, we have the entity group locked and can safely operate on it. If it isn't the same, then we look at the is_xg
node we set earlier. If it doesn't exist, we're not in a XG transaction and this isn't allowed, so we abort. If it does exist and is set to True, then we are in an XG transaction. We then look at the number of locks in the lock list and see how many locks have been acquired (Google App Engine limits you to 25 locks for XG transactions). If acquiring this lock would push us over the limit, we abort the transaction. Otherwise, we add it to the lock list and write the new list back to ZooKeeper.
In our comment example, the first get_by_key_name
will call get
, which causes the lock path to be created and set to key1
. When the first put
happens, we look at the lock list path, see that it exists and is set to key1
, and thus proceed with the put
operation. When the second get_by_key_name
happens, we see that our entity group key2
isn't in the lock list, but since xg=True
is set, that's ok and we add key2
to the lock list and proceed. The second put
occurs, and we see that key2
is in the lock list, so we proceed.
This step is also similar to the non-XG version, but here, instead of deleting the lock path, we delete the lock list path. Presuming that those delete operations succeed, we're good to go!
So that's a quick writeup on how transactions work in AppScale. For the adventurous who are looking to operate on more than 25 entity groups at a time, check out appscale/AppDB/zkappscale/zktransaction.py
and look for:
MAX_GROUPS_FOR_XG = 25
and change that to your heart's content :)