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

Memory leak with optimistic response #4210

Closed
mpoeter opened this issue Dec 4, 2018 · 19 comments
Closed

Memory leak with optimistic response #4210

mpoeter opened this issue Dec 4, 2018 · 19 comments

Comments

@mpoeter
Copy link

mpoeter commented Dec 4, 2018

When using mutations with an optimisticResponse we found a significant increase in memory usage.

Intended outcome:
No or only small increase in memory usage.

Actual outcome:
Huge increase in memory usage.

How to reproduce the issue:
Given an application with multiple watched queries:

  • take a memory snapshot
  • perform a mutation with an optimistic response (it is sufficient to update a single field of some object)
  • take another memory snapshot

Alternatively use the memory profiling function of Chrome instead of taking separate snapshots - this has the advantage that we get callstacks for the allocations.

Versions

  • apollo-cache-inmemory: 1.3.8
  • apollo-client: 2.4.5
  • apollo-link: 1.2.3
  • apollo-link-context: 1.0.9
  • apollo-link-error: 1.1.1
  • apollo-link-http: 1.5.5
  • react-apollo: 2.2.4

We suspect that this was introduced in #3394. In particular I suspect the commit 45c4169 to be the main cause.

If InMemoryCache.optimistic contains some entry, read and diff create temporary stores with that optimistic response:

const store = (query.optimistic && this.optimistic.length)
? defaultNormalizedCacheFactory(this.extract(true))
: this.data;

const store = (query.optimistic && this.optimistic.length)
? defaultNormalizedCacheFactory(this.extract(true))
: this.data;

These temporary stores are than used in the calls to readQueryFromStore/ diffQueryAgainstStore and should later get garbage collected. But unfortunately these store objects are internally used as key in calls to cacheKeyRoot.lookup:
return reader.cacheKeyRoot.lookup(
reader.keyMaker.forQuery(query).lookupQuery(query),
contextValue.store,
fragmentMatcher,
JSON.stringify(variableValues),
rootValue.id,
);

return reader.cacheKeyRoot.lookup(
reader.keyMaker.forQuery(execContext.query).lookupSelectionSet(selectionSet),
execContext.contextValue.store,
execContext.fragmentMatcher,
JSON.stringify(execContext.variableValues),
rootValue.id,
);

45c4169 replaced the defaultMakeCacheKey function from optimism (which internally uses a WeakMap) with the newly introduced CacheKeyNode concept, causing these temporary stores to end up as key in one of the (strong) Maps, effectively leaking them.

The more watched queries you have, the bigger the problem because maybeBroadcastWatch calls diff for every single watcher. So we end up creating (and leaking) a temporary store for every single watcher for a single optimistic response (apart from the leakage, there is certainly some room for optimization here).

Not sure if this is the only problem or if there are other leaks as well. We saw a single mutation (that updates a single field) to cause a memory increase of 40-80MBs - not sure if such a huge increase can be caused by this problem alone.

@benjamn Since you were the main author of #3394, could you take a look at this? Let me know if you need more information!

@eltonio450
Copy link

Hello,

Does someone has a workaround so far ? Downgrading is a bit painful because of #4125 :( (version matching...).

Thank you :)

@SnidelyWhiplash
Copy link

Yeah this bit me too I think. Had to temporarily comment out all my optimisticResponses... would love an update here plz :)

@brianlovin
Copy link

Currently experiencing this on Spectrum (https://github.com/withspectrum/spectrum) - this is particularly painful for a chat app that uses optimistic responses for each message sent. I can reliably crash my browser with OOM error, and the trace is coming from apollo-cache-inmemory - removing the optimistic response removes the problem, but degrades the user experience.

@barbalex
Copy link

barbalex commented Dec 11, 2018

Even with optimistic response turned off (before which the app crashed after about 10 updates after reaching over 1GB of memory usage) it seems very weird how memory usage of my app changes over time:

  • After starting up memory usage is a few dozen MB
  • After very basic updates (checking a checkbox) it increases by a number between 0 and 100 MB
  • Often the increase rises with the number of following updates: during the first few only a little, then more and more
  • Then suddenly after an update it drops down back to a few dozen MB
  • By this time the memory usage has risen to about 600 or 700MB

This gives me a very bad feeling.

@dajay0
Copy link

dajay0 commented Dec 19, 2018

We have been hit by this issue too. Would love to see a fix for it. Any updates / workarounds would be highly appreciated.

Also is there anything we can do to help?

benjamn added a commit that referenced this issue Dec 19, 2018
This should help with #4210, since temporary optimistic ObjectCache
objects will no longer be instanceof DepTrackingCache, so the result
caching system will be disabled for those reads, and thus the temporary
objects will not be retained in any cache keys:

https://github.com/apollographql/apollo-client/blob/7d0ed16a97e57cb3789f352fd4f359eaaa2eec0b/packages/apollo-cache-inmemory/src/readFromStore.ts#L123

https://github.com/apollographql/apollo-client/blob/7d0ed16a97e57cb3789f352fd4f359eaaa2eec0b/packages/apollo-cache-inmemory/src/readFromStore.ts#L144
@benjamn
Copy link
Member

benjamn commented Dec 19, 2018

Can folks try running

npm install apollo-cache-inmemory@1.3.12-beta.0

per #4251?

@barbalex
Copy link

I did not see a difference. But my project still has optimisticResponce turned off, so I guess that was to be expected.

@benjamn
Copy link
Member

benjamn commented Dec 19, 2018

That's right, this change would only prevent memory leaks stemming from optimistic reads. If you're feeling adventurous, you could try reenabling optimistic responses to see whether the additional memory usage is less than before?

@mpoeter
Copy link
Author

mpoeter commented Dec 19, 2018

@barbalex The fact the your memory drops again to a few MB shows that you don't have a real leak, otherwise the GC would not be able to collect those 600-700MB. The question only is why the GC does not kick in earlier or why collection of those objects is not possible at that time. I recommend to investigate this with the Chrome Allocation Profiler.

@benjamn Thanks, I will give it a try tomorrow. Quick question: wouldn't it be sufficient to use the UniversalWeakMap from immutable-tuple for the children in CacheKeyNode?

@barbalex
Copy link

O.k., tested with optimistic response. Here comes the test of clicking an option repeatedly and watching memory usage:

  • Without optimistic response: Memory rises, beginning at 70 MB. Stopps rising and drops a little at 400 MB after about 20 clicks. After some inactivity falls back to 70MB.
  • With optimistic response, without this update: Memory keeps rising, beginning at 250 MB. App crashing at 2GB after 80 clicks.
  • With optimistic response, with this update: Memory keeps rising, beginning at 70 MB. Does not seem to rise over 350 MB. After some inactivity falls back to 70MB.

The difference between using optimistic response and not may be accidental, as the numbers vary. But the update definitely seems to solve the crashing when using optimistic response in my app.

@brianlovin
Copy link

Tested this locally for Spectrum:

  • Without this update: started at 60mb, initiated 20 optimistic updates, ended at 1gb memory. Chart is continuous line up in heap memory, no apparent gc.
  • With this update: started at 60mb, initiated 20 optimistic updates, peaked at 304mb memory with very clear gc resets.

benjamn added a commit that referenced this issue Dec 19, 2018
Although this is a rather drastic option, creating an InMemoryCache that
does not attempt to cache previous results may help reduce memory usage,
at the expense of cache performance on repeated reads:

  new InMemoryCache({
    resultCaching: false, // defaults to true
  })

See this comment by @barbalex for one such situation:
#4210 (comment)

At the very least, this option should help diagnose problems with result
caching, even if it's not the right long-term solution.
@benjamn
Copy link
Member

benjamn commented Dec 19, 2018

@barbalex Since result caching is just an optimization, and it sounds like it may be contributing to memory problems that significantly worsen performance for you, I've pushed another change (in apollo-cache-inmemory@1.3.12-beta.1) that allows disabling result caching for a given InMemoryCache instance: cfc8fdf.

I imagine the { resultCaching: false } option will be useful for diagnosing result caching problems in the future, even if it's not something we recommend as a permanent solution.

@benjamn
Copy link
Member

benjamn commented Dec 19, 2018

Ok, apollo-cache-inmemory@1.3.12 has just been published to npm. Closing this issue now, perhaps somewhat optimistically, because I believe the memory leaks that were specifically related to optimistic responses have been addressed. Please open new issues for any other memory problems. Thanks!

@benjamn benjamn closed this as completed Dec 19, 2018
@brianlovin
Copy link

Thank you @benjamn - really appreciate the fix here!

@benjamn
Copy link
Member

benjamn commented Dec 19, 2018

Quick question: wouldn't it be sufficient to use the UniversalWeakMap from immutable-tuple for the children in CacheKeyNode?

@mpoeter Yes, but the choice was deliberate! In 45c4169 we switched from defaultMakeCacheKey (which uses immutable-tuple) to the CacheKeyNode abstraction, specifically to avoid using WeakMap, which apparently isn't fully supported in React Native Android, like so many other long-standard ECMAScript features. Also, the local TypeScript implementation was simpler and easier to consume in other TypeScript modules.

@jeremycolin
Copy link

@benjamn I was using directly InMemoryCache for a project, I was writing one big query and reading pieces using readFragments. With some very little load, my instance was quickly running out of memory because of memory spikes. With latest version, it does not.

Thanks for the quick fix on this, very very much appreciated from graphQL lovers out there

@mpoeter
Copy link
Author

mpoeter commented Dec 20, 2018

@benjamn Thanks for the clarification. I saw the commit to remove the WeakMap, but it wasn't clear to me why this was necessary.

BTW: our tests also confirm that the memory issue is now fixed! 👍

@benjamn
Copy link
Member

benjamn commented Jan 17, 2019

As I suspected when we were originally working on this issue, optimistic cache reads could be implemented much more efficiently. This PR should improve the speed and memory consumption of optimistic reads and writes considerably: #4319

@mpoeter
Copy link
Author

mpoeter commented Jan 17, 2019

Sounds great!

benjamn added a commit that referenced this issue Jan 17, 2019
Previously, the `InMemoryCache` maintained an array of recorded optimistic
updates, which it would merge together into an entirely new `ObjectCache`
whenever performing any single optimistic read.

This merging behavior was wasteful, but the real performance killer was
calling `this.extract(true)` each time, which copied the entire underlying
(non-optimistic) cache, just to create a throw-away `ObjectCache` for the
duration of the optimistic read. Worse still, `this.extract(true)` was
also called in `recordOptimisticTransaction`, to create a throw-away
`RecordingCache` object.

Instead of creating temporary copies of the entire cache, `InMemoryCache`
now maintains a linked list of `OptimisticCacheLayer` objects, which
extend `ObjectCache` and implement the `NormalizedCache` interface, but
cleverly delegate to a parent cache for any missing properties. This
delegation happens simply by calling `this.parent.get(dataId)`, so there
is no need to extract/copy the parent cache.

When no optimistic data are currently active, `cache.optimisticData` will
be the same (`===`) as `cache.data`, which means there are no additional
layers on top of the actual data. Lookup time is proportional to the
number of `OptimisticCacheLayer` objects in the linked list, so it's best
if that list remains reasonably short, but at least that's something the
application developer can control.

Calling `removeOptimistic(id)` removes all `OptimisticCacheLayer` objects
with the given `id`, and then reapplies the remaining layers by re-running
their transaction functions.

These improvements probably would have made the excessive memory usage
reported in #4210
much less severe, though disabling dependency tracking for optimistic
reads (the previous solution) still seems like a good idea.
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 16, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

8 participants