Transactions in GraphQL

Running multiple GraphQL mutations concurrently can cause transaction abort. This post proposes a way to deal with it.

Background

Every GraphQL mutation starts a new transaction. A mutation is done via an upsert query, and then a request is sent to Dgraph to commit the transaction. Dgraph would then successfully commit the transaction or abort the transaction if it conflicts with any other concurrent transaction.
Example:

mutation {
   mut1(...) {...} // Transaction 1
   mut2(...) {...} // Transaction 2
   ...
}

In this case, if txn 1 passes but txn 2 errors out, Only txn 2 is rolled back, not txn 1.

Proposal

  1. Retry aborted transactions with a newer timestamp
    A simpler way is to have infinite retry for a transaction until it succeeds. For the client/user, this is transparent and the user won’t see any error due to transaction abort.
    But we would need a way to prioritize older transactions; otherwise, it could lead to starvation.
    Also, while sending a request, we need to be careful if we are not sending conflicting requests. This can be achieved by tracking the transactions and conflict keys as currently done in the live loader.

  2. Support atomic mutation in GraphQL
    One of the users reported that he needs to rollback all the mutation if any one of them errors out.

mutation {
   mut1(...) {...}
   mut2(...) {...}
   ...
}

Currently, we run these serially and if one of the mutations fails then we return, but the preceding mutations are not rolled back.
I looked at the GraphQL specs and competitors. GraphQL specs don’t contain any specification about transaction abort and rollback. So, it’s left to the implementor. But Hasura seems to do mutations atomically.


We can support the same with @transaction directive.
mutation @transaction(commit: true)  {
  addReview(input : [{comment : "reviewComment1"}]) {
  	review {
    	id
    	comment
    }
  }
  addRegion(input : [{name : "regName1"}]) {
    region {
    	id
    	name
    }
  }
}

This internally would signal the GraphQL to consider both mutations as part of a single transaction and after performing both the mutation we issue a commit. We abort the transaction in case of any error. So, all the mutations will be rolled back. The user is responsible for retrying in case of aborts.

  1. Natively support transaction in GraphQL queries and let the user handle transaction aborts (Finalized Solution)
    I looked at other GraphQL servers and none of them supports Transaction natively as of now. FaunaDB has support for transaction but it’s via FQL layer. It’s similar to GraphQL± and others are using custom resolvers to support transactions.
    The transaction could span over multiple queries and mutations. So, we need a way to start a transaction, perform query and mutation in the transaction and then finally commit it.
    We can support two types of transactions.
  • Single request transaction
    This is the same as the atomic transaction suggested above. The user doesn’t need to handle any transaction context at the client end.

  • Transaction spanning multiple requests
    In this case, the transaction context is passed to the client and the client is responsible for maintaining the transaction state. The transaction state is passed to the client via GraphQL extensions.
    Example of transaction context in extension.

type DgraphTxn { // Transaction context
  txnID : ...
  conflictKeys : [...]
}

...
...

data: {...}
extensions : { txn { ... } }

...
...

mutation @transaction(txn: $txn) {
 m1(...) {...}
 m2(...){...}
}

In order to help the user manage the transaction context, we’d write an extension to ApolloLink that handles all the keys, etc. and all a developer has to worry about is the txnID. Pass extension to the client using Apollo Link.

The following request will start a transition and keep it open. It returns the transaction context in the extensions as shown above

// Query Example
query @transaction {
   ...
}

// Mutation Example
mutation @transaction {
   ...
}

We pass the same transaction context in the succeeding transactions.

query @transaction(txnContext: $txnContext) {
   ...
}
mutation @transaction(txnContext: $txnContext) {
   ...
}

Finally, we can call commit or abort on the transaction.
To commit the transaction:

mutation @transaction(txnContext: $txnContext, commit: true) {
   ...
}

To abort the transaction:

mutation @transaction(txnContext: $txnContext) {
    ...
    abort {...}
}

Jira Issue: https://dgraph.atlassian.net/browse/GRAPHQL-429

1 Like

Don’t think it would lead to starvation if the timestamp is new for every try. Use the simpler approach of infinite retry.

So how would this work for a GraphQL user? Can they turn this on/off?

What’s the key use case here? Does it just apply i the case of two transactions trying to add an xid, or are there other spots where it is useful?

How do we detect? Is it through the error message containing “please retry”?

But if a transaction is retired with a newer timestamp it will be treated as a new transaction and it could still conflict with ongoing transaction and retry again. This could happen again and again if the system is receiving lot of mutation request. But I agree that we could initially go with the infinte retry approach and investigate it later if there are any issues with it.

Retry logic is transparent for the client/user, and the user won’t see any error due to transaction abort.
This feature is not configurable and will be used by any mutation starting a transaction.

The transactions could conflict due to various reasons. It could be due to mutation on xids or on index keys as well. The idea for using transactions is to keep data consistent for concurrent mutation.

The transaction conflict can be detected when we call CommitOrAbort() on the transaction. It would return codes.Aborted if the transaction is aborted.

In general, I really like the idea. I’m kinda thinking that it should be configurable as there might be cases where the transaction conflict is what you want.

I think I’m seeing that there’s a bit of a broader picture here, so maybe we need to scope out the whole area and then pull off pieces. For example:

  • There was also a request to run a block of mutations as a single transaction and roll back if any failed here - currently we run each in a transaction
  • If we aren’t exposing Dgraph or GraphQL± at all in Slash GraphQL, then you really do need transactions sometimes, so we’d need to expose that through GraphQL.

Let’s

  1. find out what might and might not be exposed in Slash GraphQL
  2. broaden this to cover transactions a bit more in general.

Updated the post. Case 2 will cover the first case and Case 3 will cover both of them.

@arijit GraphQL Transaction for entire mutation

1 Like

His use-case can be solved by case 3. Replied to him.

How about remove @atomic and just make it @transaction with no args means I want all of these mutations in one transaction and please commit it at the end (or roll back).

When I open a transaction how doe the TS come back? In extensions?

Also wonder if the @transaction opens up the possiblity for case 1 to have an argument like @transaction(..., retry: true) and make retrying or not dependent on the args

Seems like there are a bunch of different things that we are thinking of implementing here.

  1. Automatically retry mutations on conflict - I am assuming this applies to a single mutation which is being committed right away? We’d have to be careful to only retry only in the case where there is an actual conflict and not when for e.g. an xid already exists etc. otherwise we might be stuck retrying mutations that might never succeed.

  2. Execute all mutations atomically - This seems an easy one to do and also has been asked by some users. What you have here looks good with the @transaction directive on the mutation.

  3. Expose the transaction functionality to the user - Something similar to what we do in Dgraph would work here. I doubt this can be combined with the retry functionality because we would allocate new timestamps for retries.
    I would propose this for the semantics.

  • @transaction without any arguments starts a new transaction and returns the startTs in response headers or extensions. Also @transaction(commit: true) starts a new transaction, commits it if possible or rollbacks any mutations.
  • @transaction(id: 123) executes an operation with that startTs.
  • @transaction(id: 123, commit: true) commits the transaction.
  • @transaction(id: 123, abort: true) aborts it.

Yes. This makes more sense. Updated it.

Yes. A transaction will have unique StartsTs and it will be returned in extensions and all succeeding requests should use that value.

I was thinking of retrying transactions if we don’t support transactions in the GraphQL query. But if we expose transaction, then the user can add the retry logic at his end. Because add retry, in that case, would unnecessarily complicate the code.

This looks good to me, and I agree with the point mentioned for retrying. If we are supporting transactions in the query, then we should offload the retrying to the client.

I’ve got two concerns about usage:

  1. is it easy to use the @transaction that sits outside the query/mutation block using variables. E.g. we’d need this to work nicely mutation myMut($txn: Int, ...) @transaction(id: $txn) { ... } for it to be useable from the client
  2. it’s not easy to get at graphql extensions in things like Apollo Client (e.g., see here), so that would make it hard to get the transaction id back out of the request that opens up the transaction.

Before going ahead we’d need to solve both those problems, so that it’s easy to use. Or at least have nice docs to show how to get the extensions in clients.

The actual extensions will have to be more like…

type DgraphTxn {
  txnID : ...
  keys : [...]
}

...
...

data: {...}
extensions : { txn { ... } }

...
...

mutation @transaction(txn: $txn) {
 m1(...) {...}
 m2(...){...}
}

Then, we’d write an extension to ApolloLink that handles all the keys etc. and all a developer has to worry about is the txn ID, so

This will mean open a new transaction, do m1 and m2, then commit. otherwise fail all.

mutation @transaction(commit: true) {
 m1(...) {...}
 m2(...){...}
}

This will mean keep the transaction open and return the details in the extensions

mutation @transaction {
 m1(...) {...}
 m2(...){...}
}

This one is continue txn 2 and then commit at end

mutation @transaction(id: 2, commit: true) {
 m1(...) {...}
 m2(...){...}
}

this means continue txn 2, but don’t commit

mutation @transaction(id: 2) {
 m1(...) {...}
 m2(...){...}
}

this is abort txn 2

mutation @transaction(id: 2) {
  abort {...}
}

That all looks fantastic to me. I believe you’ve covered my use case and more.

1 Like

Yes, we wanted to allow users to get all the benefits of transactions that Dgraph supports.