Skip to content

Conversation

maya-the-mango
Copy link
Contributor


labels: mergeable

Fixes: #issue

Motivation and Context

//: # We want to be able to use a cache for bandits, that will expire after set ttl.

Description

//: # Created a TLRU cache implementation and injected it in EppoClient. Additionaly fixed an old unit test that was failing due to change in test data. Assert expected v123 actual data was 123

How has this been tested?

//: # Unit tests. See tlru-cache.spec.ts, tlru-in-memory-assignment-cache.spec.ts`.

this.cacheEntriesTimoutIds.delete(key);
}

private setCacheEntryTimout(key: string): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

typo: setCacheEntryTimeout

// for eviction, because it is now considered most recently
// accessed thus the timer should start over. Not doing that
// will cause a de-sync that will stop proper eviction
this.clearCacheEntryTimeout(key);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should move the statement above into the if scope below, since we should only clear the timeout if it was a cache hit

}

private setCacheEntryTimout(key: string): void {
const timeOutId = setTimeout(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: timeoutId for consistentency with spelling of timeout in other places

Copy link
Contributor

@felipecsl felipecsl left a comment

Choose a reason for hiding this comment

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

LGTM! just one question


public useLRUInMemoryBanditAssignmentCache(maxSize: number) {
this.banditAssignmentCache = new LRUInMemoryAssignmentCache(maxSize);
this.banditAssignmentCache = new TLRUInMemoryAssignmentCache(maxSize);
Copy link
Contributor

Choose a reason for hiding this comment

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

did you mean to use TLRU in here as well? the method name says LRU

Copy link
Contributor

Choose a reason for hiding this comment

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

while "LRU" has meaning, I wonder if "TLRU" is too vauge and we should be expliti in our naming calling it `ExpiringLRUInMemoryAssignmentCache" or something

Copy link
Contributor

Choose a reason for hiding this comment

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

Also we should allow the timeout to be optionally specified here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was also not conviced by the naming at first. I used ExpiringLRUInMemoryAssignmentCache in the beginning but then while reading about different caching algorithms. stumbled upon this.
https://en.wikipedia.org/wiki/Cache_replacement_policies#Time-aware,_least-recently_used

@aarsilv I will change the name if that doesn't convince you. What do you think?

Copy link
Contributor

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

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

This is a great start and I love the tests! However, I have performance concerns regarding the approach. Imagine they are handling tens of thousands of users we'd have tens of thousands of timer references out there which will have a larger memory footprint than just numbers, plus could do funky things to the event loop. I'm thinking a better approach would be to store the system time at which an entry expires (but not actually bothering to remove it until "just in time").

If that makes the LRU size inaccurate, we also could have a single garbage collecting timer that runs every minute or so.


public useLRUInMemoryBanditAssignmentCache(maxSize: number) {
this.banditAssignmentCache = new LRUInMemoryAssignmentCache(maxSize);
this.banditAssignmentCache = new TLRUInMemoryAssignmentCache(maxSize);
Copy link
Contributor

Choose a reason for hiding this comment

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

while "LRU" has meaning, I wonder if "TLRU" is too vauge and we should be expliti in our naming calling it `ExpiringLRUInMemoryAssignmentCache" or something

// number of logging events that may be queued while waiting for initialization
export const MAX_EVENT_QUEUE_SIZE = 100;
export const BANDIT_ASSIGNMENT_SHARDS = 10000;
export const DEFAULT_TLRU_TTL_MS = 600_000;
Copy link
Contributor

Choose a reason for hiding this comment

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

🔥


public useLRUInMemoryBanditAssignmentCache(maxSize: number) {
this.banditAssignmentCache = new LRUInMemoryAssignmentCache(maxSize);
this.banditAssignmentCache = new TLRUInMemoryAssignmentCache(maxSize);
Copy link
Contributor

Choose a reason for hiding this comment

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

Also we should allow the timeout to be optionally specified here

* @param {number} maxSize - Maximum cache size
* @param {number} timeout - TTL of cache entries
*/
public useTLRUInMemoryAssignmentCache(maxSize: number, timeout?: number) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be axed and timeout added to the previous method. We only want the expiring option for bandits

@aarsilv
Copy link
Contributor

aarsilv commented Nov 13, 2024

Note that for linear to automatically link and follow the ticket you need the hyphen in the branch name FF-3396

Copy link
Contributor

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

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

Getting closer! Evicting all the expired entries is a lot of work so we should try to do it only when needed. I don't think we need to for get(), which can check just in time, and set(), which does not care. These two methods will make up the majority use case of this cache, so having them be fast is helpful.

Copy link
Contributor

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

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

Appologies we keep going back and forth here, but I have some concerns about the behavior of get(). I think it may not apply the "R" of LRU. I suggested a test to add and a possible change/simplification of get(). Also we need has() to check and delete based on expiration as well.

This task sounds so simple, but actually is quite complicated!

cacheKeys.push(key);
}

expect(cacheKeys.length).toBe(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

probably good to leave one non-expired key in here to make sure it's still there

cacheValues.push(value);
}

expect(cacheValues.length).toBe(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

same here

});

/**
This test case might be an overkill but in case Map() changes,
Copy link
Contributor

Choose a reason for hiding this comment

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

probably handled by a test like your "should evict oldest entry when capacity limit is reached" but no harm no foul

this.delete(key);
return undefined;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

otherwise, if valid, do we need to this.cache.set(key, value); to bump it to the bottom for the LRU like we do the parent class?

I wonder if we just check !this.isCacheEntryValid(key) first, if so delete it from the cache, then proceed to just call the parent method's get()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great catch Aaron 🙏 Such a stupid mistake, my spirit with this cache implementation was crushed at the moment of writing this method 🫠 Your concerns saved my ass here!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wonder if we just check !this.isCacheEntryValid(key) first, if so delete it from the cache, then proceed to just call the parent method's get()

I've rewrote it to do just that + reset cache TTL timer if we got a cache hit from parent class

const keys = Array.from(cache.keys());
expect(keys[0]).toBe('b');
expect(keys[1]).toBe('c');
});
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we need another test that does:

cache.set('a', 'apple');
cache.set('b', 'banana');
cache.get('a');
cache.set('c', 'cherry');

Then makes sure its c and a (e.g., a got re-inserted at the end)

Copy link
Contributor

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

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

Thanks for these final changes! I think this is in a pretty good spot now 💪

Go ahead and bump the major patch number (middle number) in package.json in this PR before you merge. Then you, can make a release in GitHub. We can pair on that, or @typotter or @rasendubi could show you how.

Next step: have the upstream (currently: node) SDK use this for bandits!

Comment on lines +66 to +67
expect(cacheKeys.length).toBe(1);
expect(cache.get('c')).toBe('cherry');
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

Comment on lines +86 to +87
expect(cacheValues.length).toBe(1);
expect(cache.get('c')).toBe('cherry');
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

Comment on lines +171 to +173
keys = Array.from(cache.keys());
expect(keys[0]).toBe('a');
expect(keys[1]).toBe('c');
Copy link
Contributor

Choose a reason for hiding this comment

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

💪

@maya-the-mango maya-the-mango merged commit 7f838c2 into main Nov 25, 2024
8 checks passed
@maya-the-mango maya-the-mango deleted the selki/FF3396-expiring-bandit-cache branch November 25, 2024 14: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.

4 participants