Amazon Web Services has an excellent GraphQL API service – AppSync – that is a powerful mechanism to easily and quickly build highly scalable applications that can benefit from the general capabilities of GraphQL – defining schema and data types to outline the operations that the application will support and seamlessly integrating with fast querying, performant writing and real time subscription signaling.
Where AppSync shines truly is not just because of its support for GraphQL – there are of course other implementations doing just that – but because of how it seamlessly integrates GraphQL components – data sources and resolvers with AWS’ components like DynamoDB and Lambdas. You can directly define request/response mapping templates that directly work against DynamoDB Tables with AppSync taking care of all the wiring magic. Likewise, Lambdas can be used for complex implementations that will not fit neatly into a traditional data source like a database.
This is all just scratching the surface – if you are interested in seeing how AppSync can be used to seamlessly design and quickly implement a fairly complex application serverless application – or want to learn a bit about GraphQL and AppSync by following some code – do check out this article where I outline an example in detail. It covers a good chunk of working with AppSync from the obligatory schema definition to working with both DynamoDB and Lambdas.
The focus of this particular post though is to cover a specific topic related to triggering AppSync subscriptions when you don’t want to actually do a real data write operation.
The problem – How GraphQL subscriptions are triggered
While this is not meant to be a discourse on GraphQL – some context is probably useful here and hence this section. Also we will be using the example schema here to demonstrate how to use a NONE data source with a Cloudformation template in the coming sections.
An AppSync GraphQL schema is typically defined with the operations it supports – which could be one of the following three categories – Queries, Mutations and Subscriptions.
Queries are your good old operations providing data – the types of objects being returned are themselves “types” also defined in the schema. Mutations are your data writes and update operations. Subscriptions are requests for real time notifications to certain data types and is implemented in actuality by AppSync using the Web Socket Protocol.
And in a GraphQL schema – the way to define a subscription operation is to link it with a particular mutation operation – lets demonstrate this with an example of simple forum application – we have forum with different topics of discussion – we want to allow users to subscribe to specific topics – so they get a notification of a post within that topic. Keeping stuff simple – this is how we could possibly define the AppSync GraphQL schema for such an application.
ForumAppSyncSchema:
Type: "AWS::AppSync::GraphQLSchema"
Properties:
ApiId: !GetAtt AppSync.ApiId
Definition: |
schema {
mutation: Mutation
subscription: Subscription
}
type post {
post_id: String!
topic: String!
subject: String!
text: String!
}
type Mutation {
postToTopic(
post_id: String!
topic: String!
subject: String!
text: String!
): post!
}
type Subscription {
topicPosting(topic: String!): post
@aws_subscribe(mutations: ["postToTopic""])
}
So we have a “post” type with a mutation operation that accepts new posts. The subscription filters on topics requested but in order for it to be triggered – it needs to be linked to the mutation – “postToTopic”.
Once this schema is implemented – there would be some kind of database resolver going against DynamoDB or perhaps a relational RDS data source to write new post data and subscriptions would be sent out via web sockets to subscribers who have asked for specific topics.
Everything will work out just fine for this forum application.
So what is problem to solve?
In the above case – there really is no problem – new posts need to be saved in a database – subscribers are notified. And this paradigm will work just fine for a lot of cases.
However lets add some complexity to our application – we want the Forum UI to show an upvote and downvote option for each post with an updated overall up/down count ticking in real time.
1. Every time a post is upvoted or downvoted – the counts are updated.
2. The users are able to see this happen in real time without refreshing the page.
Now if we were to run the calculations with every upvote or downvote synchronously – we potentially slow down the “write” mutation operation and make the experience bad for users – it makes sense to have a background job fire asynchronously so that the operation of registering a user’s upvote or downvote is an instantaneous write that doesn’t keep them waiting for a calculation to return. It isn’t a critical transaction like a financial or ecommerce one – the count can be updated on the client side on the users click – with the actual aggregate count from all other users coming with a slight lag whenever the background job completes.
But then the problem is – how do we trigger a counts subscription without saving stuff to an actual database – we don’t want to save these calculations – it will be redundant to the actual writes that have already happened by registering an up/down vote. A GraphQL subscription after all needs to be tied to a mutation – and a mutation needs to be resolved against a GraphQL data source.
And this is where AppSync’s NONE data source with a local resolver come in handy.
NONE Data Sources and Local Resolvers Explained
A NONE data source is just that – nothing – no data store. Though not really nothing in the AppSync implementation terms. It is an actual data source type within AppSync that tells AppSync that there isn’t going to be an actual data mutation happening and there is no underlying data store.
For a typical data source like AMAZON_DYNAMODB – AppSync will wire up the connection to the data source and will expect operations to be defined against in the request mapping template for a particular mutation in the schema. With a NONE data source however – there will be no wiring up of any connections of any kind – and AppSync will simply pass the request context final result of the mutation from the request mapping template to the response mapping template.
And the Local Resolver? The request/response template pair is called a “Local Resolver” because it is not going against any remote data store. So this combination of a passthrough request/response mapping template pair with a NONE data source as the target of the operation is called an AppSync Local Resolver.
Defining a NONE Data Source and a Local Resolver in Cloudformation
First lets update the schema example above to add the new operations for our upvote downvote feature. Note – we are not going to be actually implementing resolvers for all of them – this is for context for the Local Resolver with the NONE data source.
ForumAppSyncSchema:
Type: "AWS::AppSync::GraphQLSchema"
Properties:
ApiId: !GetAtt AppSync.ApiId
Definition: |
schema {
mutation: Mutation
subscription: Subscription
}
type post {
post_id: String!
topic: String!
subject: String!
text: String!
}
type postStats{
post_id: String!
net_count: Int!
}
type Mutation {
postToTopic(
post_id: String!
topic: String!
subject: String!
text: String!
): post!
votePost(
upOrDown: String!
): String!
triggerPostStats(
post_id: String!
net_count: Int!
): postStats!
}
type Subscription {
topicPosting(topic: String!): post
@aws_subscribe(mutations: ["postToTopic"])
postStatsUpdate(): post
@aws_subscribe(mutations: ["triggerPostStats"])
}
So we now have a votePost mutation for the upvote downvote operation – this will trigger an asynchronous call to a statistics operation which will invoke the “triggerPostStats” mutation whenever it is done with the net count of up/down votes for the particular posts. As mentioned – I am not implementing this in this post – however my example application here has a Python Lambda which invokes a similar trigger for this if you would like to see how it can be done in code.
What we will demonstrate next are how to define the NONE data source with a local resolver that will act on any calls to the triggerPostStats mutation and passthrough the stats from the call to it to subscribers via the postStatsUpdate subscription.
The NONE Data Source
ForumStatsNoneDataSource:
Type: "AWS::AppSync::DataSource"
Properties:
ApiId: !GetAtt AppSync.ApiId
Name: "ForumStatsNoneDataSource"
Type: "NONE"
ServiceRoleArn: !GetAtt AppSyncRole.Arn
The Local Resolver against the triggerPostStats mutation using the NONE data source
TriggerPostStatsResolver:
Type: "AWS::AppSync::Resolver"
Properties:
ApiId: !GetAtt AppSync.ApiId
TypeName: "Mutation"
FieldName: "triggerPostStats"
DataSourceName: !GetAtt ForumStatsNoneDataSource.Name
RequestMappingTemplate: |
{
"version": "2018-05-29",
"payload": $util.toJson($context.arguments)
}
ResponseMappingTemplate: |
$util.toJson($context.result)
Any implementation that passes the postStats object in the payload to the triggerPostStats mutation
postStats{
post_id: String!
net_count: Int!
}
will result in the payload simply passed through to the subscribers.
An extremely useful feature of AppSync that I use wherever I need to update subscribers of the result of background calculations without affecting the performance of a user initiated write operation.