Front-End Web & Mobile

Enhancing Live Sports with the new AWS AppSync filtering capabilities

This article was written by Stefano Sandrini, Principal Solutions Architect, AWS

With AWS AppSync you can create serverless GraphQL APIs that simplify application development by providing a single endpoint to securely access data from multiple data sources, and leverage GraphQL subscriptions to implement engaging real-time application experiences by automatically publishing data updates to subscribed API clients via serverless WebSockets connections.

Taking advantage of GraphQL subscriptions to perform real-time operations, AppSync pushes data to clients that choose to listen to specific events from the backend. This means that you can easily and effortlessly make any supported data source in AppSync real-time. Connection management between clients and your API endpoint is handled automatically. Real-time data, connections, scalability, fan-out and broadcasting are all handled by AppSync, allowing you to focus on your business use cases and requirements instead of dealing with the complex infrastructure required to manage WebSockets connections at scale.

When it comes to real-time with GraphQL, subscriptions filtering is an important capability as there are use cases that require restricting or filtering the data specific groups of subscribed clients receive. AWS AppSync recently released new filtering capabilities, enabling developers to build a broader range of real-time experiences in their applications by leveraging new logical operators, server-side filtering, and the ability to trigger subscription invalidations to unsubscribe clients.

In addition to server-side filtering it’s possible to implement dynamic client-defined filters that can be configured using a filter argument while invoking the subscription operation. This enables clients to define their own filters dynamically, based on the different use cases depending on the application requirements.

GraphQL subscriptions are particularly important in the Media and Entertainment industry, where companies offer applications that enable their customers to view sports scores as they happen, track live game/match information and statistics, receive fantasy sports updates, and interact with fellow subscribers. Delivering this sort of data in real-time is critical in the media business. Hence, in order to help enable entertainment companies deliver sports information in real-time at high scale, AWS released the Real-Time Live Sports Updates Using AWS AppSync solution.

The solution creates an easily deployable reference architecture implemented with best practices in mind, aiming to address challenges commonly found in the media and entertainment industry specifically related to live sports real-time updates. The reference architecture addresses common use cases in sports such as delivering live game updates in a second screen application, fantasy score updates, additional statistics and information on-demand (e.g. via OTT, over-the-top services).

Data from a data feed provider is ingested into Amazon Kinesis Data Streams, then a Lambda function transforms and enriches the data using configuration information provided by an Amazon DynamoDB table, adapting data records received from a third-party data provider to the expected GraphQL data type format. The Lambda function calls AppSync to invoke a GraphQL mutation that save game events data to DynamoDB tables. Once the mutation is completed, AppSync automatically notifies multiple subscribers in real-time about a new event. The real-time message is delivered via secure WebSockets, as described in the AppSync documentation.

With AppSync GraphQL subscriptions a media company can address real-time use cases and scale automatically to handle peak usage and reach millions of connected clients with real-time notifications. With the launch of enhanced subscriptions filtering capabilities in AppSync, customers using the solution can now add additional features and further enhance the user experience during live games.

How AppSync’s enhanced filtering simplifies and enables new use cases

Let’s use a Fantasy League application to highlight the value of AppSync’s enhanced filtering capabilities. In Fantasy League games, participants assemble an imaginary virtual team of real life players and score points based on the players’ actual statistical performance. For example, in Fantasy Football you can score points when the quarterback of your team scores a touchdown, a wide receiver receive 10+ yards or a running back runs for 10+ yards. Equally, in basketball you may score 3 points any time one of your virtual team players scores a 3 points field goal or 1 point for each rebound. Same for soccer, 3 points for a goal or 1 point for an assist made by one of your players.

A common challenge with the fantasy league scenario is that if you want to provide live fantasy scores updates then you must collect scores and stats in real-time from multiple games at the same time, from each game played by at least one member of your team.

Before the introduction of enhanced filtering capabilities in AWS AppSync, this use case could be implemented by subscribing to all games, using the onCreateGameEvent subscription defined in the Real-Time Live Sports Updates solution:

onCreateGameEvent(gameId:ID): GameEvent @aws_subscribe(mutations: ["createGameEvent"])

The gameId argument is optional, therefore it is possible for clients to subscribe to all games by removing the argument when performing the subscription operation.

Once subscribed, our clients must parse the payload of all game events received in real-time, discard all events unrelated to our team’s players and aggregate the remaining events stats and scores. This approach may add complexity to the implementation and bandwidth usage inefficiency, an important aspect if clients are running on mobile devices such as tablets or phones.

Thanks to the new enhanced filtering capabilities, a more efficient implementation is now possible. The following table lists the new logical filter operators available with AppSync server-side filtering:

Operator Description
eq Equal
ne Not equal
le Less than or equal
lt Less than
ge Greater than or equal
gt Greater than
contains Checks for a subsequence, or value in a set
notContains Checks for absence of a subsequence, or absence of a value in a set
beginsWith Checks for a prefix
in Checks for matching elements in a list
notIn Checks for matching elements not in a list
between Between two values

The GameEvent type defined in the solution reflects a specific sports event and it is implemented with multiple fields:

type GameEvent  @aws_iam @aws_api_key {
  id: ID!
  game: Game
  gameId: ID!
  type: String
  clock: String
  section: GameSection
  competitor: Competitor
  homeScore: Int
  awayScore: Int
  scorer: Player
  assist: Player
  playerIn: Player
  playerOut: Player
  commentary: String
  players: [Player]
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

There are different fields related to the concept of a player as the solution provides a way to evaluate what is the impact of each player in the game event. For example, this design allows you to identify who is the player that scored the goal and what player made the assist. In fantasy league scenarios, this is important because scoring a goal and making an assist differ in terms of points for your team.

To define our filtering option we could leverage the contains operator to check for matching elements in a list of players, however currently the enhanced filtering feature doesn’t support nested fields. Therefore we can’t use the players: [Player] field in the GameEvent type. For the same reason, we can’t inspect all fields of type Player and look for a specific ID.

As a workaround we can modify slightly the GraphQL schema, create a new top level field as a list of player IDs, and populate the list with references to players that are involved in the game event, using what it is already part of the GameEvent type. We can use fields such as scorer: Player , assist: PlayerplayerIn: PlayerplayerOut: Player .

The modified GameEvent type now includes a new playerIds field, as follows:

type GameEvent  @aws_iam @aws_api_key {
  id: ID!
  game: Game
  gameId: ID!
  type: String
  clock: String
  section: GameSection
  competitor: Competitor
  homeScore: Int
  awayScore: Int
  scorer: Player
  assist: Player
  playerIn: Player
  playerOut: Player
  commentary: String
  players: [Player]
  playerIds: [String]
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

As the type was modified we need to reflect the change in the createGameEvent mutation resolver response template accordingly:

## [Start] Copy result into a new map. **
#set( $returnMap = $ctx.result )
#set ( $returnMap.playerIds = [] )
## Check players and copy IDs in the playerIds array *
#if( $ctx.result.playerIn)
    $util.qr($returnMap.playerIds.add($ctx.result.playerIn.id))
#end
#if( $ctx.result.playerOut)
    $util.qr($returnMap.playerIds.add($ctx.result.playerOut.id))
#end
#if( $ctx.result.scorer)
    $util.qr($returnMap.playerIds.add($ctx.result.scorer.id))
#end
#if( $ctx.result.assist)
    $util.qr($returnMap.playerIds.add($ctx.result.assist.id))
#end
$util.toJson($returnMap)
## [End] **

The response template for the resolver populates the playerIds field by copying into the array the value of the id from the fields playerIn, playerOut, scorer, and assist.

Now we move on to define enhanced filters from the client side, as we need to implement these filters dynamically based on the roster of our own fantasy team.

Dynamic client-defined enhanced filters can be configured using a filter argument in a new subscription we create called onCreateGameEventForMyPlayers:

onCreateGameEventForMyPlayers(filter: String): GameEvent @aws_subscribe(mutations: ["createGameEvent"])

A new resolver utility $util.transform.toSubscriptionFilter() generates dynamic enhanced filters based on the filter definition passed in the filter argument. We can use this resolver utility in the response mapping template for the onCreateGameEventForMyPlayers subscription resolver.

We create the subscription resolver and attach it to a Local/None data source, creating what is called a Local Resolver. This type of resolver it’s not attached to an external data source, and it executes in AppSync itself and just forwards the result of the request mapping template to the response mapping template.

We can create the Local/None data source from the Data Sources section in the AppSync console.

Leveraging the new utility  $util.transform.toSubscriptionFilter() , the response mapping template for the new subscription resolver can be configured as follows:

## Response Mapping Template - onCreateGameEventForMyPlayers subscription

$extensions.setSubscriptionFilter($util.transform.toSubscriptionFilter($util.parseJson($ctx.args.filter)))

## In case of subscription with custom response template you must provide a valid payload that respects any mandatory fields defined in the GameEvent type 
$util.toJson({"id": "","gameId": "", "createdAt":"2022-04-26T22:11:46.703Z", "updatedAt":"2022-04-26T22:11:46.703Z"} )

Note that we return a JSON object that provides all mandatory fields for the GameEvent type, as we’re executing a custom response template for a subscription that has GameEvent as data type.

As described in the documentation for transformation helpers, the input object for the utility method is a Map with the following key values for a simplified filter definition:

  • field_names
  • and
  • or

In our fantasy league scenario, we must use an or filter with a contains operator for each playerId in our team.

As an example, let’s assume we are managing a soccer fantasy league and our team is based on players from different clubs playing different games at the same time, such as:

"player": {
    "id": "123",
    "name": "Thiago Silva"
 }
 
"player": {
    "id": "456",
    "name": "Neymar"
 }
"player": {
    "id": "789",
    "name": "Lucas Paqueta"
 }

In order to listen for events related to our players, we need to use a filter such as:

"or" : [
            {
                "playerIds" : {
                    "contains" : "123"             
                }
            },
            
            {
                "playerIds" : {
                    "contains" : "456"             
                }
            },
            {
                "playerIds" : {
                    "contains" : "789"             
                }
            }
    
        ]

The the filter evaluates to true if the subscription notification has the playerIds field with an Array value that contains at least of one of the playerIds provided.

With this configuration, a client would then execute the following query to subscribe to data changes based on the filter criteria dynamically defined at client side based on the playerIds of the user’s fantasy team:

subscription MySubscription {
  onCreateGameEventForMyPlayers(filter: "{\"or\": [\n    {\n      \"playerIds\": {\n        \"contains\": \"123\"\n      }\n    },\n    {\n      \"playerIds\": {\n       \"contains\": \"456\"\n       }\n    }\n  ]}") {
    awayScore
    clock
    commentary
    createdAt
    gameId
    game {
      home {
        name
      }
      away {
        name
      }
    }
    homeScore
    id
    type
    updatedAt
    playerIds
    playerIn {
      id
      name
    }
    playerOut {
      id
      name
    }
    scorer {
      id
      name
    }
    assist {
      id
      name
    }
  }
}

The subscription filter is evaluated against the notification payload, based on the mutation response payload. Therefore, it is important to remember that in order to use this approach the createGameEvent mutation should have the playerIds within the mutation response fields:

mutation MyMutation {
  createGameEvent(input: {id: "68724100334", gameId: "so0102sim:match:01", type: "match_started", awayScore: 0, homeScore: 1, scorer: {id: "456", name: "Neymar"}}) {
    awayScore
    clock
    commentary
    createdAt
    gameId
    homeScore
    id
    game {
      createdAt
      id
      plannedKickoffTime
      stageId
      stats
      updatedAt
    }
    type
    updatedAt
    playerIds
    playerIn {
      id
      name
    }
  }
}

With this new configuration, our clients can be notified only when a new GameEvent is related to one of our fantasy league player. For instance, clients subscribed to events from a single player player Messi would have data related to Neymar filtered in the AppSync backend:

Subscription invalidation when a sport event ends

During live sports main events, like a motorsports race or the championship game of the year, media and entertainment companies may have millions of connected devices subscribed and listening for real-time event data. To avoid inefficiency and unnecessary costs, it is important to disconnect clients as soon as the event ends when no more real-time data needs to be published to subscribers.

Initially this could be achieved by listening to specific events notified through a subscription, and then implement the unsubscribe logic at client side.  This approach can be tricky and may cause problems if, for example, a client misses the notification due to lack of connectivity or due to a bug in the client-side business logic.

AWS AppSync now supports the ability to invalidate subscriptions, allowing to trigger the automatic closure of multiple WebSocket client connections from the service side. Invalidation allows to simplify application development and reduces data sent to clients with improved authorization logic over data.

Subscriptions Invalidation in AppSync is executed in response to a special payload or event defined in a GraphQL mutation. When the mutation is invoked, the invalidation payload defined in the resolver is forwarded to a linked subscription that has a related invalidation filter configured to unsubscribe the connection. If the invalidation payload sent as argument(s) in the mutation match the invalidation filter criteria in the subscription, invalidation takes place and the WebSocket connection is closed. In our use case, we configure the match_ended type in the GameEvent as the mutation invalidation payload. If the type sent in the createGameEvent mutation request matches the type defined in the invalidation filter, the WebSocket connection related to the onCreateGameEvent subscription is invalidated and clients are unsubscribed.

To implement this scenario, we first need to change the response mapping template for the createGameEvent mutation resolver by adding the following code snippet at the beginning of the template:

#if ($ctx.result.type == "match_ended")
    $extensions.invalidateSubscriptions({
        "subscriptionField": "onCreateGameEvent",
        "payload": {
                "gameId": $context.result.gameId
        }
    })    
#end

The extension defines that when the resulting type is match_ended, the subscription invalidation process is initiated for the linked subscrioption onCreateGameEvent . The subscription invalidation operation has a specific payload that refers to the gameId in the GameEvent, so we can invalidate only subscriptions for the game that is ended.

We attach a new resolver to the OnCreateGameEvent subscription with the Local/None data source configured earlier. We must change the response mapping template to provide the subscription invalidation filter that describes what are the conditions to be matched to invalidate clients subscriptions, as follows:

#if( $ctx.args.gameId )
$extensions.setSubscriptionInvalidationFilter({
"filterGroup": [
    {
        "filters" : [
            {
                "fieldName" : "gameId",
                "operator" : "eq",
                "value" : $ctx.args.gameId
            }
        ]  
    }
  ]
})
#end

## In case of subscription with custom response template you must provide a valid payload that respects any mandatory fields defined in the GameEvent type
$util.toJson({"id": "","gameId": "", "createdAt":"2022-04-26T22:11:46.703Z", "updatedAt":"2022-04-26T22:11:46.703Z"} )

Now, let’s assume we subscribe to the OnCreateGameEvent subscription related to the gameId so0102sim:match:01 .

subscription MySubscription {
  onCreateGameEvent(gameId: "so0102sim:match:01") {
    awayScore
    clock
    commentary
    createdAt
    gameId
    homeScore
    id
    playerIds
    type
    updatedAt
  }
}

We start receiving game events in real-time for that specific game. If the game ends and we receive the game event match_ended from the feed, the solution invokes a mutation similar to:

mutation MyMutation {
  createGameEvent(input: {id: "68724100347", gameId: "so0102sim:match:01", type: "match_ended", awayScore: 0, homeScore: 1}) {
    awayScore
    clock
    commentary
    createdAt
    gameId
    homeScore
    id
    game {
      createdAt
      id
      plannedKickoffTime
      stageId
      stats
      updatedAt
    }
    type
    updatedAt
    playerIds
    playerIn {
      id
      name
    }
  }
}

The WebSocket connection is closed and the client avoids keeping an idle connection alive for no reason. You can test this scenario in the AppSync console by invoking the subscription operation in one tab of your web browser.

subscription MySubscription {
  onCreateGameEvent(gameId: "so0102sim:match:01") {
    awayScore
    clock
    commentary
    createdAt
    gameId
    homeScore
    id
    playerIds
    type
    updatedAt
  }
}

We invoke the following mutation on another tab in the web browser (note the type field of the input with value match_ended):

mutation MyMutation {
  createGameEvent(input: {id: "68724100305", gameId: "so0102sim:match:01", type: "match_ended", awayScore: 0, homeScore: 0}) {
    awayScore
    clock
    commentary
    createdAt
    gameId
    homeScore
    id
    game {
      createdAt
      id
      plannedKickoffTime
      stageId
      stats
      updatedAt
    }
    type
    updatedAt
    playerIds
    playerIn {
      id
      name
    }
  }
}


After invoking the mutation with type:“match_ended”, we receive a Subscription complete message as response. The WebSocket connection is closed, the client unsubscribed and does not receive new upcoming messages related to the specific event.

Conclusion

Enhanced filtering in AppSync allows for more flexibility when developing real-time applications such as the ones our media and entertainment customers are building. Using the  Real-Time Live Sports Updates Using AWS AppSync solution now in conjunction with enhanced filtering enables advanced use cases and an easier implementation for existing use cases with less business logic code to write on the client side and more efficiency in bandwidth usage, subscriptions connection time, and data transfer.

AppSync helps you improve your real-time user experience with no need to worry about managing WebSockets connections and the infrastructure required to provide real-time data at scale. For more information on enhanced GraphQL subscriptions filtering, refer to the AppSync documentation.

Stefano Sandrini

Stefano Sandrini is a Principal Solutions Architect at AWS helping customers and partners to design and implement well-architected solutions in the cloud. When not working, he can be found coding prototypes for next-gen apps, talking about sports with anyone or playing guitar.