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
-
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. -
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.
- 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