urql-graphql / urql

The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
https://urql.dev/goto/docs
MIT License
8.54k stars 444 forks source link

@urql/exchange-graphcache returns empty data & error although fetch response contains valid data. (when same entity is requested in parallel) #3562

Closed farin closed 2 months ago

farin commented 2 months ago

Describe the bug

Running three queries. First Q1 fetches some entity.

Then in paralel, Q2 and Q3 fetches another attributes on same entity. In some approx 20% or cases a race condition occurs and Q3 query subscription returns empty result and nothing else. {data: null, errors: undefined}

On network layer I can see all data are returned from server correctly but query.subscribie never emit them (instead null data is emitted once)

I isolated issue, used fetch mock and make example with reproducible example where issue happens always.

Some observations: When cacheExchange is not used then everything is fine. When schema was changed and attributes was moved to different entity then both queries are also always correct. When Q2 is not executed, then Q3 resolves as expected.

Reproduction

https://github.com/farin/graphcache-bug

Urql version

@urql/core 5.0.0 @urql/exchange-graphcache 7.0.1

Validations

JoviDeCroock commented 2 months ago

This issue is expected, you are returning different data for the same field at different times, this is the query you do multiple times

query { me { id } }

This is dispatched three times, 2/3 times this returns a __typename of UserType and an identifier of 1. 1/3 times this returns the same UserType but with a different identifier of 2. In a normalized cache this is illegal because Query.me is pointing at UserType:1 and then gets clobbered with UserType:2 and again with UserType:1 this is expected to fail. When a field can return multiple entities it is best to work with arguments i.e. query { user(id: $userId) { id } } as this will cleanly normalize to entries for all of the possible permutations of UserType.

The reason why it turns up with nothing is that if the alternative entity completes before the normal entity then the normal entity will fail to re-query as it knows that it should be getting EntityType:1 rather than EntityType:2 as there's a missing property on EntityType:2 which it requested originally. The refetching of this third operation will be blocked by looping protection as essentially this would infinitely refetch due to

query1 --> EntityType:1 --> dependsFrom Query.me
query2 --> EntityType:2 --> dependsFrom Query.me
query3 --> EntityType:1 --> dependsFrom Query.me

All three of the above request different properties on their respective entity meaning that they can't ever fully complete, especially when switching the queried entity. Every time one of these operations completes it will re-trigger all of them due to Query.me being a dependency, the looping is less obvious because the selection-sets of query2 and query1 are overlapping as both of them contain publicUsername this means that when we re-issue query1 we can fulfil the requirements from the data of query2, however when we re-issue query3 we can't fulfil the data-requirements as it asks for a property that isn't in the cache produced by query2 namely status.

You can always use the logger API of graphcache to be notified about this, i.e. doing { logger: console.log } in the constructor. Where it will tell you that it can't find EnityType:2.status for query3. Another helpful thing is to use the debugExchange in front of your cacheExchange.