![AWS Amplify and Flutter](/images/habitr/banner.png.webp)
To see the completed project, check out the Github page.
In the last part, we wrote a custom AppSync resolver to perform atomic voting operations that keep our social network, Habitr, fair. But how do we know it works? In this part, we’ll answer that question.
Custom Resolver
type Mutation {
vote(habitId: ID!, type: VoteType!): VoteResult
}
# The different ways to cast a vote.
enum VoteType { upvote, downvote, removeUpvote, removeDownvote }
# The result of casting a vote using the `vote` mutation.
type VoteResult {
habit: Habit
user: User
}
As a refresher, to vote on a particular habit we developed a custom mutation that is backed by an AppSync pipeline function, or a series of custom resolvers. These resolvers start by getting the current user, then perform an atomic update of the habit, and finally, update the user.
Validation
To validate our code, we’re going to perform a simple test:
- Create 1000 test accounts in Cognito
- Get the vote totals for a particular habit
- Have each of the test accounts simultaneously cast a vote on that habit
- Keep track of how they vote and the expected end totals
- Verify the end totals match what’s expected
The language I chose to accomplish this task was Go, because it was the language I was most familiar with of the languages which have an AWS SDK. It also happens to have very nice concurrency primitives which make steps 3 and 4 particularly easy. If you’re unfamiliar with Go, it has a very simple syntax and the snippets below should be familiar to anyone who has used a C-like language.
Creating the Test Accounts
To create the test accounts, we use the Cognito SDK from the AWS SDK and call the AdminCreateUser
function. We also manually add them to our AppSync backend with a GraphQL mutation to createUser
.
for i := 0; i < cfg.NumWorkers; i++ {
username := fmt.Sprintf("test%d", i)
log.Printf("Creating user %d...\n", i)
_, err := cognitoClient.AdminCreateUser(
ctx,
&cognitoidentityprovider.AdminCreateUserInput{
UserPoolId: &cfg.UserPoolId,
Username: &username,
TemporaryPassword: aws.String(DefaultPassword),
UserAttributes: []types.AttributeType{
{
Name: aws.String("email"),
Value: aws.String("test@example.com"),
},
},
},
)
if err != nil {
return err
}
var mutation struct {
CreateUser struct {
Username graphql.String
} `graphql:"createUser(input: {username: $username})"`
}
err = graphqlClient.Mutate(
ctx,
&mutation,
map[string]interface{}{
"username": graphql.String(username),
},
)
if err != nil {
return err
}
}
Logging In the Test Accounts
After the test accounts have been created, we need to get Cognito credentials for each to be able to call our vote mutation. For this we use the AdminInitiateAuth
and, if necessary, AdminRespondToAuthChallenge
, functions from the Cognito SDK. The AdminRespondToAuthChallenge
call is needed when Cognito requires the user to change their password before logging in.
We use the AccessToken
and IdToken
values returned from the endpoint to create User
objects which we’ll use in the next step.
initAuthOut, err := cognitoClient.AdminInitiateAuth(
ctx,
&cognitoidentityprovider.AdminInitiateAuthInput{
AuthFlow: types.AuthFlowTypeAdminUserPasswordAuth,
ClientId: &cfg.AppClientId,
UserPoolId: &cfg.UserPoolId,
AuthParameters: map[string]string{
"USERNAME": username,
"PASSWORD": DefaultPassword,
},
},
)
if err != nil {
return err
}
if initAuthOut.ChallengeName != "" {
authChallengeOut, err := cognitoClient.AdminRespondToAuthChallenge(
ctx,
&cognitoidentityprovider.AdminRespondToAuthChallengeInput{
ChallengeName: initAuthOut.ChallengeName,
ClientId: &cfg.AppClientId,
UserPoolId: &cfg.UserPoolId,
Session: initAuthOut.Session,
ChallengeResponses: map[string]string{
"USERNAME": username,
"NEW_PASSWORD": DefaultPassword,
},
},
)
if err != nil {
return err
}
user := &User{
Username: username,
AccessToken: *authChallengeOut.AuthenticationResult.AccessToken,
IdToken: *authChallengeOut.AuthenticationResult.IdToken,
}
resCh <- user
return nil
}
user := &User{
Username: username,
AccessToken: *initAuthOut.AuthenticationResult.AccessToken,
IdToken: *initAuthOut.AuthenticationResult.IdToken,
}
resCh <- user
return nil
Concurrently Voting
To perform the voting is fairly straightforward in Go. We will spin up a “goroutine” for each of the voters, lightweight threads which can run concurrently with respect to each other. The actual vote is performed using a GraphQL client, and should look very familiar:
func vote(cfg common.Config, client *graphql.Client, upvote bool) error {
var mutation struct {
Vote struct {
Habit struct {
ID graphql.ID
Tagline graphql.String
Category graphql.String
Ups graphql.Int
Downs graphql.Int
}
User struct {
Username graphql.String
UpvotedHabits []graphql.String
DownvotedHabits []graphql.String
}
} `graphql:"vote(habitId: $habitId, type: $type)"`
}
var voteType string
if upvote {
voteType = "upvote"
} else {
voteType = "downvote"
}
err := client.Mutate(context.Background(), &mutation, map[string]interface{}{
"habitId": graphql.ID(cfg.HabitId),
"type": VoteType(voteType),
})
return err
}
We keep track of the vote totals locally using two atomic integers, representing the number of upvotes and downvotes cast. For each of the voters, we randomly choose whether they will upvote or downvote. Then, we fire off a goroutine with the choice and schedule the respective counter to be incremented on completion.
func spawnVoters(ctx context.Context, cfg common.Config, clients []*graphql.Client) error {
eg := new(errgroup.Group)
for _, client := range clients {
client := client
eg.Go(func() error {
upvote := rand.Intn(2)
if upvote == 1 {
defer atomic.AddInt32(&upsCounter, 1)
return vote(cfg, client, true)
} else {
defer atomic.AddInt32(&downsCounter, 1)
return vote(cfg, client, false)
}
})
}
return eg.Wait()
}
Checking the Results
Finally, we simply perform a query on the AppSync API to get the number of upvotes and downvotes and compare those to the totals we calculated.
var after getHabit
err = graphqlClient.Query(ctx, &after, map[string]interface{}{
"habitId": graphql.ID(cfg.HabitId),
})
if err != nil {
common.ExitError(err)
}
expectedUps := int32(before.GetHabit.Ups) + upsCounter
expectedDowns := int32(before.GetHabit.Downs) + downsCounter
gotUps := after.GetHabit.Ups
gotDowns := after.GetHabit.Downs
log.Printf("Votes After: (Ups %d Downs %d)\n", gotUps, gotDowns)
log.Printf("Expected: (Ups %d Downs %d)\n", expectedUps, expectedDowns)
if expectedUps == int32(gotUps) && expectedDowns == int32(gotDowns) {
log.Println("Success!")
} else {
fmt.Fprintf(os.Stderr, "Error! Votes do not match.")
}
Putting it All Together
To see it all work, check out this video where 1000 voters concurrently cast their votes for the first habit on the list.
If it’s hard to see, the following logs are printed:
2021/06/09 08:46:45.964598 Logging in users...
2021/06/09 08:46:55.677857 Successfully logged in users...
2021/06/09 08:46:55.960918 Votes Before: (Ups 0 Downs 0)
2021/06/09 08:47:01.442812 Voting completed in 5480 milliseconds. 1000 votes cast.
2021/06/09 08:47:01.521798 Votes After: (Ups 497 Downs 503)
2021/06/09 08:47:01.521817 Expected: (Ups 497 Downs 503)
2021/06/09 08:47:01.521825 Success!
As it turns out, our custom resolver is working as intended. Even when multiple people cast their vote simultaneously, we can rest assured that our backend vote totals will accurately reflect their votes — in real-time, no less! To me, this really solidified how incredibly powerful AppSync and DynamoDB can be and it makes me excited to find other ways to put them to use!
And so ends our journey with Habitr. I think at this point, I have covered all of the juicy details which made this project enjoyable and exciting to work on, and I hope they have left an equally sizeable impression on you. While I haven’t covered all aspects of the project, I leave it as an exercise for those curious, and you’ll find the complete source code on the Github page, including the full code for the tests described above.
Thanks for reading, and I’ll see you in the next one.