amplify-category-api icon indicating copy to clipboard operation
amplify-category-api copied to clipboard

GraphQL generated resolvers are ignoring GSI in 1-to-many relationship list calls, doing table SCAN instead of QUERY

Open jpangburn opened this issue 1 year ago • 7 comments

Environment information

System:
  OS: macOS 14.5
  CPU: (10) arm64 Apple M1 Pro
  Memory: 152.22 MB / 32.00 GB
  Shell: /bin/zsh
Binaries:
  Node: 22.2.0 - ~/.nvm/versions/node/v22.2.0/bin/node
  Yarn: undefined - undefined
  npm: 10.7.0 - ~/.nvm/versions/node/v22.2.0/bin/npm
  pnpm: undefined - undefined
NPM Packages:
  @aws-amplify/backend: 1.0.2
  @aws-amplify/backend-cli: 1.0.3
  aws-amplify: 6.3.4
  aws-cdk: 2.143.0
  aws-cdk-lib: 2.143.0
  typescript: 5.4.5
AWS environment variables:
  AWS_PROFILE = gatecode-deploy
  AWS_STS_REGIONAL_ENDPOINTS = regional
  AWS_NODEJS_CONNECTION_REUSE_ENABLED = 1
  AWS_SDK_LOAD_CONFIG = 1
No CDK environment variables

Description

The bug:

I have a model defined in resource.ts:

GateCodes: a
  .model({
    gatePhone: a.string().required(),
    ownerCode: a.string().required(),
    name: a.string().required(),
    // create a reference field to the account (there must be an account to have a GateCodes entry)
    owner: a.string().required(),
    // use that reference field to make a belongsTo relationship
    account: a.belongsTo('Account', 'owner'),
  }).identifier(['gatePhone', 'ownerCode'])
  .authorization(allow => [allow.owner()]),
Account: a
  .model({
    owner: a.string().required().authorization(allow => [allow.owner().to(['read'])]),
    comment: a.string(),
    gateCodes: a.hasMany('GateCodes', 'owner'),
  }).identifier(['owner'])
  .authorization(allow => [allow.ownerDefinedIn('owner')]),

So basically a single owner has one Account, and their account can have many GateCodes. The GateCodes schema uses the owner field as the reference field, so there's a Global Secondary Index on the owner field. However, when I fetch the GateCodes after loading an account like this:

this.account = data;
let result = await data?.gateCodes();
console.log("got gate codes- ", result);

You'd think it would do a query on the underlying Dynamo DB table because there's a GSI on the owner which is what it's using to query with. But it does not, it does a scan instead. In the CloudWatch logs (after turning on API logging in the AppSync settings) I see the following transformed template:

" \n{"version":"2018-05-29","limit":100,"filter":{"expression":"(((#owner = :and_0_or_0_owner_eq) OR (#owner = :and_0_or_1_owner_eq) OR (#owner = :and_0_or_2_owner_eq)) AND (#owner = :and_1_and_0_owner_eq))","expressionNames":{"#owner":"owner"},"expressionValues":{":and_0_or_1_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef"},":and_1_and_0_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef"},":and_0_or_0_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef::180173d0-4001-70c6-e87f-0e070c83caef"},":and_0_or_2_owner_eq":{"S":"180173d0-4001-70c6-e87f-0e070c83caef"}}},"operation":"Scan"}\n"

The very last entry in there you can see is \"operation\":\"Scan\". It's the same problem if you try to query the GateCodes directly by passing the owner in (which ought to trigger a query on the GSI as well):

this.client.models.GateCodes.observeQuery({
  filter: {
    owner: {
      eq: this.userService.currentOwner!
    }
  }
}).subscribe({
  next: ({ items, isSynced }) => {
    this.gateCodes = items;
  },
});

This also produces an \"operation\":\"Scan\" in the logs.

The culprit

In the AppSync functions it creates a resolver for this model called QueryListGateCodesDataResolverFn, which has the following relevant code:

#if( !$util.isNull($ctx.stash.modelQueryExpression) && !$util.isNullOrEmpty($ctx.stash.modelQueryExpression.expression) )
  $util.qr($ListRequest.put("operation", "Query"))
  $util.qr($ListRequest.put("query", $ctx.stash.modelQueryExpression))
  #if( !$util.isNull($args.sortDirection) && $args.sortDirection == "DESC" )
    #set( $ListRequest.scanIndexForward = false )
  #else
    #set( $ListRequest.scanIndexForward = true )
  #end
#else
  $util.qr($ListRequest.put("operation", "Scan"))
#end

This code says if there's no modelQueryExpression to do a scan. There's a related function called QuerylistGateCodespreAuth0Function which builds this variable. It's too long to put here, but it starts like this:

#set( $modelQueryExpression = {} )
## [Start] Validate key arguments. **
#if( !$util.isNull($ctx.args.ownerCode) && $util.isNull($ctx.args.gatePhone) )
  $util.error("When providing argument 'ownerCode' you must also provide arguments gatePhone", "InvalidArgumentsError")
#end
## [End] Validate key arguments. **
#if( !$util.isNull($ctx.args.gatePhone) )
  #set( $modelQueryExpression.expression = "#gatePhone = :gatePhone" )
  #set( $modelQueryExpression.expressionNames = {
  "#gatePhone": "gatePhone"
} )
  #set( $modelQueryExpression.expressionValues = {
  ":gatePhone": {
      "S": "$ctx.args.gatePhone"
  }
} )

It's looking to see if you set the partition key. After that, it goes only to check if you passed a sort key, and then how you want to sort, etc. What it doesn't do is look to see if you are querying on the GSI that was created automatically when you setup a 1-to-many relationship, and instead do a query on that.

On a large table, this would be very wasteful and expensive to do a scan all the time when a developer thought they had passed sufficient information to do a query.

jpangburn avatar Jun 03 '24 23:06 jpangburn