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

Add bandit assignment cache #120

Merged
merged 10 commits into from
Aug 29, 2024
Merged

Conversation

aarsilv
Copy link
Contributor

@aarsilv aarsilv commented Aug 26, 2024

Eppo Internal:
🎟️ Ticket: FF-3112 - JS Common SDK - Assignment cache should work at the bandit action level
🗨️ Slack: "...are subsequent calls of the SDK assignment function still sending those events to the logger..."

Motivation and Context

Customers may optionally want to exclude repeat bandit assignments from executing the logger callback to reduce the number of entries sent to their data warehouse.

Description

This PR mirrors the flag assignment cache functionality for bandit actions. The difference is that the unique and cached value is a function of the bandit key and action key instead of the allocation key and variation key. The use of the flag key and subject key remains the same.

Bandit action caching can be independently toggled from flag assignment caching. This could be useful when one wants action assignment caching but not bandit action caching, such as when using bandits to display ads and knowing when every impression was useful.

Caching happens at the subject and flag key levels for both--i.e. if a flag results in one action being assigned and then another, the first assignment is evicted from the cache to record the second. This allows for treating changed assignments as new, even if previously assigned.

How has this been tested?

  • Newly added Assignment logging deduplication test case to eppo-client-with-bandits.spec.ts

let actionToReturn: string | null;

// Convenience method for repeatedly making the exact same assignment call
function requestClientBanditAction(): Omit<IAssignmentDetails<string>, 'evaluationDetails'> {
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 find function more descriptive than const, and since I don't need lexical this nor does this being hoisted matter, I'm thus opting to go for it.

subjectKey,
subjectAttributes,
allocationKey: 'mock-allocation',
variation: { key: variationToReturn, value: variationToReturn },
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Where variationToReturn is injected as the assigned variation for mocking Evaluator.evaluateFlag()

flagKey,
subjectKey,
subjectAttributes: { numericAttributes: {}, categoricalAttributes: {} },
actionKey: actionToReturn,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Where actionToReturn is injected as the assigned action for mocking BanditEvaluator.evaluateBandit()

@aarsilv aarsilv marked this pull request as ready for review August 27, 2024 00:20
Copy link
Collaborator

@rasendubi rasendubi left a comment

Choose a reason for hiding this comment

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

A couple of nitpicks but overall looks good!

Comment on lines 660 to 664
// What our bandit assignment cache cares about for avoiding logging duplicate bandit assignments,
// if one is active. Like the flag assignment cache, entries are only stored for a given flag
// and subject. However, Bandit and action keys are also used for determining assignment uniqueness.
// This means that if a flag's bandit assigns one action, and then later a new action, the first
// one will be evicted from the cache. If later assigned again, it will be treated as new.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: this comment seems misplaced. The best place for it is calculation of cache key and value (as these are controlling this behavior). If cache key or value change, this comment could easily become outdated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I was just trying to explain what banditAssignmentCacheProperties is and how it will be used, but agree the comment could get stale. I'll experiment with better spatially colocating it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I would move cache entry construction inside the caching module. Otherwise, it looks like a leaking implementation detail — the caller needs to know what keys are used in caching and copy them into banditAssignmentCacheProperties. It would be simpler if the cache was accepting the event

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 found a good home for this in abstract-assignment-cache.ts after all! 🎉

// Ignore repeat assignment
return;
}

if (!this.banditLogger) {
// No bandit logger set; enqueue the event in case a logger is later set
if (this.queuedBanditEvents.length < MAX_EVENT_QUEUE_SIZE) {
this.queuedBanditEvents.push(banditEvent);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Q: shall we deduplicate events in the queuedBanditEvents?

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 don't think so as these are basically all sent to a logger if one is added, so we want deduping to happen beforehand like we're doing

Copy link
Collaborator

Choose a reason for hiding this comment

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

We're not deduplicating them beforehand here because return on the next line prevents us from setting the entry in the cache.

If we log the same event twice before the user sets the logger, the event will be sent to user's logger twice

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah I see what you are saying now, good catch! Will address

Comment on lines 29 to 40
export function assignmentCacheValueToString(cacheValue: AssignmentCacheValue): string {
const fieldsToHash: string[] = [];

if ('allocationKey' in cacheValue && 'variationKey' in cacheValue) {
fieldsToHash.push(cacheValue.allocationKey, cacheValue.variationKey);
}

if ('banditKey' in cacheValue && 'actionKey' in cacheValue) {
fieldsToHash.push(cacheValue.banditKey, cacheValue.actionKey);
}

return getMD5Hash(fieldsToHash.join(';'));
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: this code looks like it's trying to cram two functions into one which makes it convoluted to follow (e.g., is it possible that both ifs match? none?)

If we add new event types (or decide to add allocation/variation key to bandit action), this is going to get messy.

A cleaner solution would be to make cache type generic over what it caches and/or accept key/value mapping functions as parameters.

Copy link
Member

Choose a reason for hiding this comment

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

+1 to this comment

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 don't think we could do generic because we only want to hash a specific subset of the provided properties. The code is indeed just handling two specific possible code paths

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Update: since the other fields are part of the cache key and we know they will be unique, we can just simply hash the entire value, making this generic 💪

@aarsilv aarsilv assigned aarsilv and unassigned rasendubi Aug 27, 2024
Comment on lines +5 to +10
/**
* Assignment cache keys are only on the subject and flag level, while the entire value is used
* for uniqueness checking. This way that if an assigned variation or bandit action changes for a
* flag, it evicts the old one. Then, if an older assignment is later reassigned, it will be treated
* as new.
*/
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rasendubi moved comment here

export type AssignmentCacheKey = {
subjectKey: string;
flagKey: string;
};

export type AssignmentCacheValue = Record<string, string>;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rasendubi & @felipecsl cache is now generic

Copy link
Member

Choose a reason for hiding this comment

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

We will lose type-safety by using generic Record. Would using generics be possible?

export type CacheKeyPair<T extends string, U extends string> = {
  [K in T]: string;
} & {
  [K in U]: string;
};

export type AssignmentCacheValue = CacheKeyPair<'allocationKey', 'variationKey'>;
export type BanditCacheValue = CacheKeyPair<'banditKey', 'actionKey'>;

// Usage example
const assignmentCache: AssignmentCacheValue = {
  allocationKey: 'someAllocationKey',
  variationKey: 'someVariationKey'
};

const banditCache: BanditCacheValue = {
  banditKey: 'someBanditKey',
  actionKey: 'someActionKey'
};

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh this is an interesting thought! I think this would be possible, but I don't know if we need to be not generic. If they send in something other than bandit key and action key, we'd still hash it and use that to define uniqueness for the entry, which I think is ok. Keeps it simpler and makes it more extensible if we need to cache other stuff going forward.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Paired on this and got 80% the way there; will merge then possibly go for the other 20% later.

@@ -220,6 +223,20 @@ describe('EppoClient Bandits E2E test', () => {
expect(mockLogAssignment).not.toHaveBeenCalled();
expect(mockLogBanditAction).not.toHaveBeenCalled();

const repeatAssignment = client.getBanditAction(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rasendubi adjusted test that flushes events to also ensure they are deduped as well (verified later with the .toHaveBeenCalledTimes(1) check)

this.queuedBanditEvents.push(banditEvent);
}
// Record in the assignment cache, if active, to deduplicate subsequent repeat assignments
this.banditAssignmentCache?.set(banditAssignmentCacheProperties);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rasendubi no early return before setting the cache entry--instead if-else if is used to handle the flow control for calling banditLogger

package.json Outdated Show resolved Hide resolved
export type AssignmentCacheKey = {
subjectKey: string;
flagKey: string;
};

export type AssignmentCacheValue = Record<string, string>;
Copy link
Member

Choose a reason for hiding this comment

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

We will lose type-safety by using generic Record. Would using generics be possible?

export type CacheKeyPair<T extends string, U extends string> = {
  [K in T]: string;
} & {
  [K in U]: string;
};

export type AssignmentCacheValue = CacheKeyPair<'allocationKey', 'variationKey'>;
export type BanditCacheValue = CacheKeyPair<'banditKey', 'actionKey'>;

// Usage example
const assignmentCache: AssignmentCacheValue = {
  allocationKey: 'someAllocationKey',
  variationKey: 'someVariationKey'
};

const banditCache: BanditCacheValue = {
  banditKey: 'someBanditKey',
  actionKey: 'someActionKey'
};

@aarsilv aarsilv merged commit d7945ff into main Aug 29, 2024
2 checks passed
@aarsilv aarsilv deleted the aaron/ff-3112/bandit-assignment-cache branch August 29, 2024 18:51
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