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

RFC - PromiseRegistry for caching promises that should run only once #1713

Closed
wants to merge 20 commits into from
Closed

RFC - PromiseRegistry for caching promises that should run only once #1713

wants to merge 20 commits into from

Conversation

markfields
Copy link
Member

@markfields markfields commented Apr 4, 2020

When cleaning up promise-function-async disables under the drivers dir, there were two cases I left in place, because the functions were about caching/retrieving Promises, and needed to stay synchronous to discourage accidentally awaiting the promises before the caching was resolved properly. After finding a third similar pattern I played around a bit and came up with a way to refactor the tricky parts of that logic out.

I found 2 bugs in doing so, so that's a good sign that this is a worthwhile abstraction to build. I think there may be many other places in the code that could use this as well, I found a bunch of hits when doing a text search for Map<.*, Promise.

The capabilities of PromiseRegistry meet the needs of the 3 examples I found so far, what do folks think of the API/implementation? I was also considering the name ConcurrentCache, and renaming the functions accordingly. Very open to other naming suggestions. And thank you to @arinwt for pairing with me on it and helping land on the design.

This is a draft PR because I need to check in PromiseRegistry first to get the changes into the published fluid-common-utils package. In that PR I'll write a million tests to make sure it's working properly :)

Copy link
Member Author

@markfields markfields left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving some comments about bugs/potential bugs I found while making this refactor (plus some notes to self for next week...)

common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
packages/drivers/odsp-socket-storage/src/createFile.ts Outdated Show resolved Hide resolved
packages/drivers/odsp-socket-storage/src/odspCache.ts Outdated Show resolved Hide resolved
packages/drivers/odsp-socket-storage/src/odspCache.ts Outdated Show resolved Hide resolved
});
}

cache.sessionStorage.put(joinSessionKey, cachedResultP, 3600000);
Copy link
Member Author

@markfields markfields Apr 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug # 1: This wouldn't actually cancel the existing timeout, so the comment above "we put the same result again with updated time" was not properly implemented. Fixed it in PromiseRegistry.

{ headers });
return response.data;
},
() => false, // unregisterOnError
Copy link
Member Author

@markfields markfields Apr 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug # 2 - This should probably not be left () => false. That means if resolving the URL fails, you'll never be able to get it resolved properly again.

markfields and others added 3 commits April 6, 2020 08:41
and introduce a config type for self-documenting syntax
public async register(
key: string,
asyncFn: () => Promise<T>,
expiryTime?: number,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big fan of integrating time like this. i'd rather leave that up to the caller to schedule the unregister

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it was worthwhile to keep (this concept existed on ISessionCache in odspCache.ts), especially to provide the semantics around refreshExpiryOnReregister which I found deceptively tricky to implement.

@anthony-murphy
Copy link
Contributor

anthony-murphy commented Apr 6, 2020

did you look at LazyPromise? That has delays running, and only runs once. I think that may work in most cases.
Something this seems to encapsulate is map management. Seems like some of these cases could be solve with a function like getOrAdd<TKey,TValue>(map:Map<TKey,TValue>, key: TKey, add:()=>TValue): TValue

Copy link
Member Author

@markfields markfields left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anthony-murphy - Thanks for the review, really appreciate your comments/questions!

As for this:

did you look at LazyPromise? That has delays running, and only runs once. I think that may work in most cases.
Something this seems to encapsulate is map management. Seems like some of these cases could be solve with a function like getOrAdd<TKey,TValue>(map:Map<TKey,TValue>, key: TKey, add:()=>TValue): TValue

I had similar debates with myself - whether to just update these callers to use a wrapper like LazyPromise, or to provide the map implementation too. What swayed me was this bug - It's not just as simple as writing getOrAdd (which btw is basically what I called register in my first draft of this!). And then finding other useful capabilities like expiration that callers can offload to a utility like this felt affirming.

Although, I could see the argument that it's doing too many things and is trying too hard to provide a generic solution, so happy to keep discussing to find a good middle ground.

common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
public async register(
key: string,
asyncFn: () => Promise<T>,
expiryTime?: number,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it was worthwhile to keep (this concept existed on ISessionCache in odspCache.ts), especially to provide the semantics around refreshExpiryOnReregister which I found deceptively tricky to implement.

packages/drivers/odsp-socket-storage/src/createFile.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
Copy link
Contributor

@ChumpChief ChumpChief left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's good to see these multiple implementations getting wrangled together!

common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
}

// Schedule garbage collection if required
if (expiryTime) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this up into the if (handle === undefined), since it doesn't need to be called if the handle already existed? Or invert the conditional and return early if the handle already existed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this needs to stay outside - if the key's already registered we may need to refresh the expiration on it.

packages/drivers/odsp-socket-storage/src/odspCache.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
common/lib/common-utils/src/promises.ts Outdated Show resolved Hide resolved
*/
export interface PromiseRegistryConfig {
refreshExpiryOnReregister?: boolean,
unregisterOnError?: (e: any) => boolean,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need an unregisterOnError? Seems that if we default to false, then users of the registry could elect to do their own pretty easily:

promiseRegistry.register(k, fn)
    .catch(() => { promiseRegistry.unregister(k); });

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think this can be easily done in all cases by callers.
Scenario - we want to coalesce and have only one outstanding request, but once it fails, we want to retry.
We do not want second pending request to be removed by one of the first users who are processing first failed request.


In reply to: 404434391 [](ancestors = 404434391)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vladsud - Is this the case you're describing?

1 - Call register, add .catch. Starts the request.
2 - Call register, add .catch. Request from 1 still in progress
3 - Request fails, two .catch continuations are on the microtask queue
4 - One .catch callback runs: unregister
5 - New call to register - starts a second request which will succeed
6 - Second .catch callback runs: wrongly unregister the 2nd request.

This would require 5 jumping into the microtask queue from some other existing microtask as of 3 - I guess this is possible?

Anyway, if it's this hard to reason about, seems useful to do it the safer way of only adding the .catch internally (and then callers can't forget as in UrlResolver).

@markfields
Copy link
Member Author

Thanks everyone for the great feedback! I've addressed everything here, and just opened a new PR with only the utils changes (#1770). Once I write tests I'll plan on merging that, feel free to add any additional feedback on that PR.

@markfields markfields closed this Apr 13, 2020
@markfields markfields deleted the promise-cache branch April 17, 2020 13:27
@markfields markfields restored the promise-cache branch April 17, 2020 13:27
@markfields markfields deleted the promise-cache branch May 4, 2020 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants