-
Notifications
You must be signed in to change notification settings - Fork 0
Hotfix: client-side memory issues #1255
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
Conversation
…n the cache to mitigate memory leak instead. test cleanup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see some logical errors and have a suggestion to instead of using utility methods with global variables as cache mechanism to go for a more React-y approach. Maybe a context provider handling the caching and retrieval of images. Also, I read this code and kind of get a sense of what is doing but it feels very technical and not verbose enough to fully tell me what is doing. Maybe we could use this PR for a bit of a refactoring on the variable names, conditions, adding comments, etc... To make it cleaner and more readable 👍
|
||
expect(setTimeOfDayTimeZone).toHaveBeenCalledTimes(1); | ||
// This test may break if someday the IANA standard updates. | ||
expect(setTimeOfDayTimeZone).toHaveBeenCalledWith('America/Guadeloupe'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can keep this test uncommented and having partial coverage if all we do is just remove this line. We would at least make sure that setTimeOfDayTimeZone
is called.
@@ -11,42 +12,83 @@ const imgNeedsHostAppended = url => { | |||
return true; | |||
}; | |||
|
|||
export const imgElFromSrc = (src, width = 30, height = null) => new Promise((resolve, reject) => { | |||
let img = new Image(); | |||
const imageCache = new Map(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having a global variable as a cache store has a smell to me. Maybe it's time to re-work the approach of these methods? Couldn't we have a hook with a state variable with the "cache" that passes down through context the imgElFromSrc
method? Something like that sounds like the React approach, easier to memoize, debug, etc...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's less efficient to do it that way, but we can certainly React-ify it more if you would like. I would prefer just using a factory function to create a nice closure to wrap the cache in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, it shouldn't be less efficient if it's properly memoized. Maybe we are talking about a couple renders and a couple milliseconds, but React re-renders hundreds of times and the cache mechanism will make those milliseconds be negligible. I think the React approach is a better fit four our React application, but I also understand it's out of the scope of a hotfix, just wanted to share the idea.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How often and how much data can/should be held by this cache? Would a regular js map be good enough for a cache? Or should we use like a bounded in-memory cache to avoid another memory leak - the cache can potentially grow indefinitely here.
Also, we could reach for persistent solutions like LocalStorage/SessionStorage.
return `${src}:${w}:${h}`; | ||
}; | ||
|
||
export const imgElFromSrc = (src, baseUnit = null) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use more readable method and variable names? imgElFromSrc
, w
, and h
names aren't very descriptive. And specifically with the method, just by reading the name (even if we use complete words) doesn't really tell what it does, maybe adding a verb to the name could help.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Totally! Maybe in a separate technical debt task? This is a hotfix branch and these are by and large existing function names
const cacheKey = generateImageCacheKey(src, baseUnit, null); | ||
|
||
if (imageCache.has(cacheKey)) { | ||
return imageCache.get(cacheKey); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe I don't have a full understanding of what is going on, but it feels like this is a patch and not the solution to the issue. Why do we need to cache the images? Shouldn't we instead ask ourselves why is this method being called so many times being asked for the same resources?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The render cycle for the map clusters builds the image strings each time, which is normal since that's how the functional programming nature of React componentry works, and then has traditionally relied on browser caching mechanisms to not over-request images which have already been fetched.
Since we're creating object URLs for some images, this causes a memory leak; the object URLs are being created each time, and are held in memory indefinitely. Having our own cache here prevents createObjectURL
from being called more than needed, and thus patches the leak.
src/utils/img.js
Outdated
if (baseUnit && img.naturalWidth && img.naturalHeight) { | ||
const widthIsLarger = img.naturalWidth > img.naturalHeight; | ||
|
||
if (widthIsLarger || !widthIsLarger) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is this? 🤯 This is logically equivalent to if (true)
hahaha. This condition will always be true, it doesn't make sense to have it wrapped.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a WIP typo. This isn't part of the latest change set.
src/utils/img.js
Outdated
@@ -57,5 +99,7 @@ export const calcUrlForImage = imagePath => { | |||
return imagePath; | |||
} | |||
const appendString = !!DAS_HOST ? `${DAS_HOST}/` : ''; | |||
return `${appendString}${imagePath}`.replace(/^http:\/\//i, 'https://').replace('.org//', '.org/'); | |||
const final = `${appendString}${imagePath}`.replace(/^http:\/\//i, 'https://').replace('.org//', '.org/'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally, I don't think this variable is helping a lot. final
doesn't tell me anything about the content of this string. If the intention was to make clearer what is being returned, we could be a bit more verbose.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it
@@ -0,0 +1,214 @@ | |||
import { imgElFromSrc, calcImgIdFromUrlForMapImages, calcUrlForImage, registerActiveURL } from './img'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the tests!!
I agree with 100% of this -- can we do it outside of the scope of this hotfix? I'd like to apply this change in isolation to a couple of prod sites to test if it resolves a memory leak I noticed recently |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall looks like a nice and pragmatic fix to the memory leak that is described in the PR description.
The main question I have is whether a regular js Map is enough as an in-memory cache because it is unbounded. If we know that only a small number of Images can be generated overall, it should be fine, but if bazillions of images are potentially generated we probably want to use something more robust than a Map to avoid another memory-leak.
return Promise.reject('no src provided'); | ||
} | ||
|
||
const cacheKey = generateImageCacheKey(src, baseUnit, null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like baseUnit
is not always the width of the image:
if (baseUnit && img.naturalWidth && img.naturalHeight) {
const widthIsLarger = img.naturalWidth > img.naturalHeight;
const aspectRatio = widthIsLarger ?
img.naturalHeight / img.naturalWidth :
img.naturalWidth / img.naturalHeight;
if (widthIsLarger) {
img.width = baseUnit;
img.height = Math.round(baseUnit * aspectRatio);
} else {
img.height = baseUnit;
img.width = Math.round(baseUnit * aspectRatio);
}
} else if (baseUnit) {
img.width = baseUnit;
img.height = baseUnit * aspectRatio;
} else {
img.height = baseUnit;
img.width = baseUnit * aspectRatio;
}
Does it make sense to update the generateImageCacheKey
function to use baseUnit
instead of width, height parameters?
|
||
img.addEventListener('load', cleanupAndResolve, { once: true }); | ||
|
||
img.onerror = (e) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice cleanup here
@@ -11,42 +12,83 @@ const imgNeedsHostAppended = url => { | |||
return true; | |||
}; | |||
|
|||
export const imgElFromSrc = (src, width = 30, height = null) => new Promise((resolve, reject) => { | |||
let img = new Image(); | |||
const imageCache = new Map(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How often and how much data can/should be held by this cache? Would a regular js map be good enough for a cache? Or should we use like a bounded in-memory cache to avoid another memory leak - the cache can potentially grow indefinitely here.
Also, we could reach for persistent solutions like LocalStorage/SessionStorage.
@Chouffe great question! The reason I used to a |
What does this PR do?
I had a call with some admins from Lewa and SWT this morning about performance issues, and noticed our map image object URLs being recalculated at a high frequency. While looking into memoizing those calculated values for a performance gain, I noticed the object URLs weren't being revoked...in this context, this causes a memory leak. And if called that frequently, it could really pile up. So I fixed that, then added the memoization, and some test coverage to match.
I'd like to hotfix this into the current release as a first test to see if it helps out the folks at Lewa.