Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Umbrella issue: Cache invalidation & deletion #4

Closed
hwillson opened this issue Jul 27, 2018 · 77 comments
Closed

Umbrella issue: Cache invalidation & deletion #4

hwillson opened this issue Jul 27, 2018 · 77 comments
Labels
project-apollo-client (legacy) LEGACY TAG DO NOT USE

Comments

@hwillson
Copy link
Member

Migrated from: apollographql/apollo-client#621

@legatek
Copy link

legatek commented Aug 1, 2018

Hi there,

I was following the originating thread and wanted to help set the context for this feature. Is there currently a feature doc around this cache invalidation that gives us an idea of what to expect from apollo in the future?

@ralphchristianeclipse
Copy link

Why dont we add a prop like

deleted: true

as you can see some of the mapped types

has generated true something like that

deleted: true would be ignored on all queries that are mapped to it

@ZwaarContrast
Copy link

An api that would allow for deletion by typename and id would be very helpful indeed. Refetching queries or updating only work for very simple use-cases.

store.delete('User',1)

@fbartho
Copy link

fbartho commented Aug 22, 2018

I also would appreciate deleting all objects with a given type name, and related, but it would be nice if we could deep clone a cache, safely.

@dallonf
Copy link

dallonf commented Aug 22, 2018

It'd also be nice to be able to clear out the results of a field, maybe even given certain arguments.

Example: I just deleted a Widget, so all results from Query.widgets should be cleared. Every other Widget (and result of Query.widgetById) that's currently cached is fine to remain in the cache.

Another example: I just disabled a Widget, so now I want to clear results from Query.widgets(hideDisabled: true), but Query.widgets(hideDisabled: false, search: "some name") could remain cached.

@Christilut
Copy link

I'm abusing resetStore now but that really ruins the whole point of using GraphQL... And getting errors like Error: Store reset while query was in flight(not completed in link chain) which even makes this (very nasty) workaround a pain to use.

I really don't get why I can't just invalidate a part of the store... Seems like a basic feature

@sammysaglam
Copy link

sammysaglam commented Aug 26, 2018

Below, a solution for this; hopefully could work for your use cases. It's kinda hacky 😞 , but does the job:

In the example below, we needed to delete an Event -- so, to make it work, simply set your mutation's update to something similar to below:

update: store => {
   const deletedEvent = {
      ...store.data.get(`Event:${eventId /* <-- put your ID */}`),
      id: '__DELETED__',
   };

   store.data.set(`Event:${eventId /* <-- put your ID */}`, deletedEvent);
}

additionally, in your queries (which for me was the query for getting Events), set a filter as such:

eventsData: data.events.filter(({ id }) => id !== '__DELETED__')

@Christilut
Copy link

Thanks, that does work (and is so hacky haha, but at this point anything goes) but the query is run before the update so it doesn't filter out the deleted item. I could use a getter for that but then I'm doing all the graphql stuff through vuex getters which seems like I'm going further away from proper solution 😛

I'm gonna see if I can a satisfactory solution by combining apollo-vue and some of these hacks

@chris-guidry
Copy link

chris-guidry commented Sep 27, 2018

@Christilut - to delete/invalidate a part of the store, I use the function below, and pass in the name of the query that should be deleted. The function will delete all instances of the query, regardless of parameters. I am using React, but in Vue it should basically be the same except the path to ROOT_QUERY might be slightly different.

import { withApollo } from 'react-apollo';
....
deleteStoreQuery = name => {
  try {
    // Apollo 1.x
    // let rootQuery = this.props.client.store.getState().apollo.data.ROOT_QUERY;
    let rootQuery = this.props.client.store.cache.data.data.ROOT_QUERY;
    Object.keys(rootQuery)
      .filter(query => query.indexOf(name) === 0)
      .forEach(query => {
        delete rootQuery[query];
      });
  } catch (error) {
    console.error(`deleteStoreQuery: ${error}`);
  }
};

In React, 'withApollo' gets exported as part of the component. The Vue equivalent would be required in order for the store to be available.

@AdamYee
Copy link

AdamYee commented Oct 5, 2018

Suggestion for an api name – client.resetQuery to invalidate (and delete?) cache. Similar to client.resetStore.

usage:

client.resetQuery({ query, variables })

@herclogon
Copy link

herclogon commented Oct 8, 2018

Suggestion for an api name – client.resetQuery to invalidate (and delete?) cache. Similar to client.resetStore.

usage:

client.resetQuery({ query, variables })

Help me please sort out, how to use resetQuery you suggest in case when three components should reset data to each other. For example:
First component: query { blocks(is_visible: true) {name} }, second: query { blocks(has_data: true) {name} }, and third one: query { blocks {name} }, and I add block by mutation blockAdd.

Should every component know queries for each other to reset? The case also well described here: #29

@redbmk
Copy link

redbmk commented Oct 8, 2018

Why dont we add a prop like

deleted: true

as you can see some of the mapped types

has generated true something like that

deleted: true would be ignored on all queries that are mapped to it

This feels like the best approach to me. Apollo should be able to detect that User:1 was returned in an another query and delete that cache. Or in the case of an array that contains User:1, it should be able to remove that item from the array, or at the very least mark that query as stale and tell it to refetch.

Manually keeping track of all queries that might be affected and all combinations of parameters is very tedious.

However, there's no real standard for that marking something as deleted afaik. Maybe there should be one, but in the meantime I think the following approach would work best.

An api that would allow for deletion by typename and id would be very helpful indeed. Refetching queries or updating only work for very simple use-cases.

store.delete('User',1)

If a standard is established later for marking an item as deleted from the server, then most the code will already be in place - the main difference being we wouldn't need to call store.delete anymore and it would all be automatic.

@AdamYee
Copy link

AdamYee commented Oct 8, 2018

@herclogon in your example, since those three separate queries are independently cached, invalidating after you add a block would look something like this in the mutation's update:

client.resetQuery({query: isVisibleQuery});
client.resetQuery({query: hasDataQuery});
client.resetQuery({query: allBlocksQuery});

Perhaps there could also be a client.resetNamedQuery(queryName /* operationName */) that invalidates any query with that operationName, regardless of query params. But that's just for convenience.

@herclogon
Copy link

herclogon commented Oct 9, 2018

@AdamYee Going this way each mutation should know queries which must be re-fetched. When you have a lot of components it will increase code viscosity, cause you must add resetQuery to mutation on "every" component add. In case when each component has his own queries (as graphql recommend, if I understand documentation right) and mutations it looks very strange. Or do you have global (single) mutation list for whole application?

I assume that a mutation should be able to mark part of cache dirty using cache path or object type and id maybe. And each watchQuery which contains this dirty record should re-fetches a new data automatically.
For now we are using reFetchObservableQueries method after some mutations, however this leads to over-fetching.

@AdamYee
Copy link

AdamYee commented Oct 10, 2018

@herclogon You're correct. This is the challenge when managing cache! I'm more just trying to help guide the discussion for the potential API.

Or do you have global (single) mutation list for whole application?

I do realize and have experienced the increase in code viscosity (complexity?). In our codebase, we use a central mechanism that tracks cached queries which we care about and updates them after certain mutations. Shameless plug - my colleague (@haytko) was able to extract this pattern into a link, https://www.npmjs.com/package/apollo-link-watched-mutation. I think you'll find it solves that need to manage cache in one place.

Something like resetQuery would be useful in a number of situations, but is by no means the end all solution.

@jpikora
Copy link

jpikora commented Oct 12, 2018

Maybe additionally, or instead of client.resetQuery(...), how about invalidateQueries key directly in mutation options, similarly to refetchQueries.

The params could be the same in both options (passing query names, or specific objects with query and variables), but the difference would be that by invalidating query it is just marked as dirty, meaning that only once the query is reused it would be force fetched. It probably should respect fetchPolicy, e.g. cache-only won't refetch even if invalidated, but otherwise there would be a server hit. If the query is being watched, it is reused immediately, thus refetched with current variables right away. By refetching invalidated query, it would be marked as clean once again and additional query calls would be resolved normally according to fetchPolicy.

I believe we would be able to happily resolve queries with filters from cache and after mutation invalidate all relevant queries at once, e.g. by query name regardless of variables. Of course it would require to know which queries to invalidate after certain mutation, but if we don't have live queries, we always have to know this on client side.

@herclogon
Copy link

@jpikora As for me it will be useful to have not invalidateQueries, but invalidateCache in directly mutation options, to make part of cache dirty using not query or query name, but cache path or cache object id. For now in my app I use watchQuery for each visible component, declaration query of each component in mutation looks not so cool.

@joshjg
Copy link

joshjg commented Oct 18, 2018

If anyone is looking for a workaround in the meantime, good news, there is one. Just pass an update function like this to your mutation.

const update = (proxy) => proxy.data.delete('AnyCacheKey');

And if you don't know the exact key, but want to match against a regex you could do something like this:

const update = (proxy) => Object.keys(proxy.data.data).forEach((key) => {
    if (key.match(/someregex/) {
        proxy.data.delete(key);
    }
});

@beeplin
Copy link

beeplin commented Oct 19, 2018

@joshjg It works according to the apollo devTools/cache panel~!

Is there any downside effect when deleting from proxy.data directly in such a way?

@martinseanhunt
Copy link

@joshjg Thanks so much for this, it's far simpler than the workaround that I had implemented! One thing I noticed is that if the mutation you're using this on changes data that's being displayed on the current page without a re-route after the mutation completes then the Query does not seem to get updated. You need to manually call refetch to get it to grab the up to date information from the server. This is at least the case in the current version of react-apollo that I'm using.

If anyone is interested I wrote up an implementation example of the above idea for dealing with paginated queries here https://medium.com/@martinseanhunt/how-to-invalidate-cached-data-in-apollo-and-handle-updating-paginated-queries-379e4b9e4698

@Tam
Copy link

Tam commented Oct 30, 2018

@joshjg I'm using proxy.data.delete('Person:ecb8fe44-c7ba-11e8-97ac-b7982643657d') and can confirm it works by comparing proxy.data.data before and after. However, all queries, whether they reference the deleted item or not, return undefined immediately after the delete is run and remain like that until the query is run again. Is there a way to fix this?

@Baloche
Copy link

Baloche commented Nov 8, 2018

@Tam I had the same issue. I figured it was because the delete method actually removes the entry related to the provided key from the cache but does not removes the references of this entry. Here is what I finally come up with :

import isArray from 'lodash/isArray'
import isPlainObject from 'lodash/isPlainObject'
import { InMemoryCache } from 'apollo-cache-inmemory'

/**
 * Recursively delete all properties matching with the given predicate function in the given value
 * @param {Object} value
 * @param {Function} predicate
 * @return the number of deleted properties or indexes
 */
function deepDeleteAll(value, predicate) {
  let count = 0
  if (isArray(value)) {
    value.forEach((item, index) => {
      if (predicate(item)) {
        value.splice(index, 1)
        count++
      } else {
        count += deepDeleteAll(item, predicate)
      }
    })
  } else if (isPlainObject(value)) {
    Object.keys(value).forEach(key => {
      if (predicate(value[key])) {
        delete value[key]
        count++
      } else {
        count += deepDeleteAll(value[key], predicate)
      }
    })
  }
  return count
}


/**
 * Improve InMemoryCache prototype with a function deleting an entry and all its
 * references in cache.
 */
InMemoryCache.prototype.delete = function(entry) {
  // get entry id
  const id = this.config.dataIdFromObject(entry)

  // delete all entry references from cache
  deepDeleteAll(this.data.data, ref => ref && (ref.type === 'id' && ref.id === id))

  // delete entry from cache (and trigger UI refresh)
  this.data.delete(id)
}

In this manner, delete can be used within the update function :

const update = cache => cache.delete(entry)

@infogulch
Copy link

infogulch commented Nov 16, 2018

In many libraries, it's not uncommon for destructive actions on collections to return the destroyed items. What I'd love to see is an optional extension to queries & mutations that's analogous, where your mutation resolver can return an object field that indicates deleted status. Something like:

mutation DeleteEvent($id: ID) {
  deleteEvent(eventId: $id) {
    id
    __typename
    __deleted    # hypothetical optional built-in boolean field: true indicates Event with this id no longer exists
  }
}

Then any cache can watch query and mutation results just like normal and when it encounters an object with __deleted = true it knows to remove it from the cache. This would be a very natural, granular, flexible, and even automatic.

I could see this fitting in nicely when deleting:

  • Ranges of objects (e.g. everything named "abc") where you get back a list of deleted ids
  • Live subscriptions where items can be removed in addition to created/updated.
  • A special "deleted since X" query that returns a list of object ids that have been deleted since some checkpoint X, and includes a new checkpoint.

I don't know if the actual mechanism can look like what I showed above without some really big changes to the whole ecosystem, but maybe there's another mechanism to do this.

@developdeez
Copy link

withApollo

@Christilut - to delete/invalidate a part of the store, I use the function below, and pass in the name of the query that should be deleted. The function will delete all instances of the query, regardless of parameters. I am using React, but in Vue it should basically be the same except the path to ROOT_QUERY might be slightly different.

import { withApollo } from 'react-apollo';
....
deleteStoreQuery = name => {
  try {
    // Apollo 1.x
    // let rootQuery = this.props.client.store.getState().apollo.data.ROOT_QUERY;
    let rootQuery = this.props.client.store.cache.data.data.ROOT_QUERY;
    Object.keys(rootQuery)
      .filter(query => query.indexOf(name) === 0)
      .forEach(query => {
        delete rootQuery[query];
      });
  } catch (error) {
    console.error(`deleteStoreQuery: ${error}`);
  }
};

In React, 'withApollo' gets exported as part of the component. The Vue equivalent would be required in order for the store to be available.

Doesn't seem to work. I see the query is removed from the list but even calling refetch after has no change to UI.

@chris-guidry
Copy link

chris-guidry commented Dec 3, 2018

@developdeez at the end of the component do you have something along these lines?

export default compose(
  withRouter,
  withApollo,
)(MyComponent);

benjamn added a commit to apollographql/apollo-client that referenced this issue Sep 11, 2019
Eviction always succeeds if the given options.rootId is contained by the
cache, but it does not automatically trigger garbage collection, since the
developer might want to perform serveral evictions before triggering a
single garbage collection.

Resolves apollographql/apollo-feature-requests#4.
Supersedes #4681.
benjamn added a commit to apollographql/apollo-client that referenced this issue Sep 12, 2019
Eviction always succeeds if the given options.rootId is contained by the
cache, but it does not automatically trigger garbage collection, since the
developer might want to perform serveral evictions before triggering a
single garbage collection.

Resolves apollographql/apollo-feature-requests#4.
Supersedes #4681.
StephenBarlow pushed a commit to apollographql/apollo-client that referenced this issue Oct 1, 2019
Eviction always succeeds if the given options.rootId is contained by the
cache, but it does not automatically trigger garbage collection, since the
developer might want to perform serveral evictions before triggering a
single garbage collection.

Resolves apollographql/apollo-feature-requests#4.
Supersedes #4681.
@dallonf
Copy link

dallonf commented Oct 29, 2019

The new InMemoryCache.evict() method is a very good start, but I'm concerned it doesn't solve the biggest pain point I've had with Apollo Caching, namely paginated lists where fetchMore isn't an option (ex. where the UI allows you to choose a specific page instead of continuously loading more).

For that to work, I think you'd have to be able to evict fields rather than objects - ex. all instances of Query.widgets with any arguments. (maybe WidgetConnection would be marked with the new keyFields: false option to prevent them from being normalized)

@studiosciences
Copy link

studiosciences commented Nov 21, 2019

Agree with @dallonf regarding paginated requests.

I'd like refetchQueries() to refetch active queries and also invalidate any cached queries. Alternately, an invalidateQueries() method could follow the same pattern.

@wtrocki
Copy link

wtrocki commented Dec 19, 2019

For that to work, I think you'd have to be able to evict fields rather than objects

The community have solution:
https://www.npmjs.com/package/@jesstelford/apollo-cache-invalidation

I'm using a heavily customized version of that package myself that does build abstractions like pagination that is very specific for my needs.

@dallonf
Copy link

dallonf commented Dec 19, 2019

@wtrocki Yes, apollo-cache-invalidation has been how I've solved this problem in the past, but it hasn't been updated in over a year and doesn't seem to have kept up with changes to Apollo's internals.

@benjamn
Copy link
Member

benjamn commented Dec 19, 2019

Field-level eviction is coming in Apollo Client 3, thanks to apollographql/apollo-client#5643.

Funny story: I vaguely remembered reading @dallonf's comment when I implemented that feature, which is why I made sure evicting a field by name would evict all instances of the field within the object, regardless of arguments… but I could not find that comment again for the life of me. Now, at long last, we are reunited!

@onderonur
Copy link

onderonur commented Dec 22, 2019

I use a similar workaround like some of the suggestions here and just wanted to share it.
For example, when I delete a record with a mutation, the mutation resolver returns something like;

{
    __typename: Post,
    id: 1234,
    __deleted: true
}

So, Apollo updates the cached records automatically. Not even need to use the "update" function manually on the client.

The only downside is, the record just doesn't get deleted from the cache actually. I simply use a lightly wrapped useQuery hook and filter out records those have __deleted:true property.

Very, very basic approach, but sometimes it can be messy when dealing with nested data structures.

Instead of filtering these records with a "hacky" way like this and just hiding them from the UI, Apollo might delete the records when some sort of a __deleted field returns from the API.
This would result with a "convention" of course. Some of the developers would be irritated, even if the only thing we need to do is returning a recommended result object when a "delete" mutation occurs on the api.

This is a very simple idea, just wanted to share it. Just solves some of my use-cases.

@tafelito
Copy link

@dallonf were you able to use evict for the use case you described here

I tried many different ways of implementing that but it never felt right. As you said, it's been the biggest issue I also have with apollo cache

@dallonf
Copy link

dallonf commented Jan 28, 2020

I haven't gotten to use Apollo Client 3.0 yet, but it seems like the field-level eviction described would be able to handle it.

@tafelito
Copy link

Yes I've been trying to wrap my head around and playing with v3 now that is possible.

I wonder tho if you found a workaround for your use case using v2, before migrating everything to v3

@dallonf
Copy link

dallonf commented Jan 28, 2020

For 1.x, I used https://github.com/lucasconstantino/apollo-cache-invalidation. But if I remember right, it's not compatible with 2.x. So I wound up resetting the entire store whenever anything needed to be invalidated. But this would often throw an unhelpful message: apollographql/apollo-client#3766

Soooo yes I'm very much looking forward to native support in 3.x! 😄

@raiskila
Copy link

I likewise ran into that issue when trying to reset the store in 2.x and resorted to throwing away the entire Apollo Client object and recreating it from scratch after some sufficiently complex mutations.

More flexible and reliable cache eviction methods are definitely welcomed.

@tusksupport
Copy link

Is this still in roadmap? I need to clear all the permutations of a query, when something is changed by some user and i receive a notification with signalR

@danReynolds
Copy link

If anyone on 3.0 wants to try out a beta we have been using for invalidation policies as an extension of the InMemoryCache it should solve some use cases: https://github.com/NerdWalletOSS/apollo-invalidation-policies

@3nvi
Copy link

3nvi commented Jul 11, 2020

@danReynolds Looks really cool. TTL is something that's def missing from Apollo.

In the repo's example, when an Employee gets evicted, their related EmployeeMessage entities also get evicted. Out of curiosity, how does the repo handle any GraphQL queries that were holding refs pointing to those evicted EmployeeMessage entities? Are they dangling or do they get automatically "pruned" by the package?

My main problem has always been that when you delete an entity, all queries that were having it as a result had issues...

@danReynolds
Copy link

@danReynolds Looks really cool. TTL is something that's def missing from Apollo.

In the repo's example, when an Employee gets evicted, their related EmployeeMessage entities also get evicted. Out of curiosity, how does the repo handle any GraphQL queries that were holding refs pointing to those evicted EmployeeMessage entities? Are they dangling or do they get automatically "pruned" by the package?

My main problem has always been that when you delete an entity, all queries that were having it as a result had issues...

The library is just using onWrite and onEvict policies to control when to run cache.evict and cache.modify, so it doesn't clean up those dangling refs automatically. As Ben talked about in this thread: apollographql/apollo-client#6325 (comment), dangling refs should be filtered out of reads now though, so they've been less of a problem for me now. If you did want to make sure they're removed, you could have an onWrite policy like this:

DeleteEmployeeResponse: {
  onWrite: {
    EmployeeMessagesResponse: ({ modify, readField }, { storeFieldName, parent: { variables } }) => {
      modify({
        fields: {
          [storeFieldName]: (employeeMessagesResponse) => {
            return {
              ...employeeMessagesResponse,
                data: employeeMessagesResponse.data.filter(
                  messageRef => readField('employee_id', messageRef) !== variables.employeeId
                ),
              }
            }
          }
        }
      });
    },
  }
},

but in general I've found it okay to leave the dangling refs in cached queries with the new read behaviour.

@3nvi
Copy link

3nvi commented Jul 12, 2020

@danReynolds That's perfect. Thanks, I somehow missed Ben's PRR on automatic filtering of dangling refs. Good to know that you can confirm no issues were found.

@KeithGillette
Copy link

We've adapted @Baloche's deepDeleteAll for our apollo-angular project, which has radically simplified many query updates on deletion since it removes all references to the deleted object. Will this approach continue to work in ApolloClient 3?

@tastafur
Copy link

Any news on improving cache invalidation, I've read some interesting version 3 stuff for it but not an easy api to implement

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
project-apollo-client (legacy) LEGACY TAG DO NOT USE
Projects
None yet
Development

No branches or pull requests