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

feat: Bootstrap common EppoClient #251

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open

feat: Bootstrap common EppoClient #251

wants to merge 15 commits into from

Conversation

typotter
Copy link
Collaborator

@typotter typotter commented Mar 17, 2025


labels: mergeable

Towards FF-4076
Towards FF-4147

Motivation and Context

Within the EppoClient the configuration is loaded and written to active memory by the ConfigurationRequestor, however that object is only present in the context of a fetching client. In order to see bootstrapped configuration in the common EppoClient, we need to be able to make reads and writes to the configuration outside of the scope of ConfigurationRequestor. The format of the bootstrap configuration is a higher order object containing the responses normally received by the ConfigurationRequestor to populate the configuration (IUniversalConfigResponse and IBanditParametersResponse). Thus both the ConfigurationRequestor and the bootstrap routine require building a configuration from these responses, a shared functionality.

Additionally, the complexity of actually storing the configuration in the three IConfigurationStore instances should really not be the concern of the ConfigRequestor or the bootstrap routine, as we may (hopefully) change out the architecture of the configuration stores in the future. Recent refactoring work has unified Configuration access, and this work follows suit to extract one more useful layer.

Finally, the architecture of the common EppoClient and its derivatives require that the underlying IConfigurationStore instances be swapped out at basically any time (there are public setters for these stores). This is the final loose end towards supporting onConfigurationChange.

Description

  • IConfigurationManager: allows setting the Configuration based on UFC responses, and changing out the IConfigurationStoreinstances used to "back" the configuration. We let theEppoClient` maintain the sources of truth for the config store instances as we to set all three (or the flag store + undefined) all at once.
  • ConfigurationManager code pulled from ConfigurationRequestor concerning config store hydration and populating from UFC. Also method to set the ConfigurationStores.
  • ConfigurationRequestor trimmed down to just the optimized loading logic, delegating storing of the configuration to the ConfigurationManager ** internal API change:** ConfigurationRequestor.constructor
  • Modifed EppoClient set*Store methods to update the Configuration stores on the ConfigurationManager. This actually fixes a bug whereby the config store could be set after initialization but it wouldn't take effect until fetchFlagConfigurations was called.
  • New EppoClient method: setConfigurationStores to encourage setting all of the stores at once and deprecated setStore methods
  • Added EppoClient.configurationManager, created on instantiation, so it is always available to return a Configuration so simplified EppoClient.getConfiguration in turn
  • bootstrap method added to Eppo Client.

How has this been tested?

  • Updates to the existing tests to reflect the refactors responsibility (from ConfigurationRequestor to ConfigurationManager)
  • The configuration is a critical and sensitive part of the EppoClient stack, which is nearly fully exercised by our unit test suite which continues to pass
  • Test suites to run the shared test cases against the bootstrap method

@typotter typotter requested a review from rasendubi March 17, 2025 21:54
@@ -130,6 +134,35 @@ describe('EppoClient Bandits E2E test', () => {
});
// Ensure that this test case correctly checked some test assignments
expect(numAssignmentsChecked).toBeGreaterThan(0);
}

describe('bootstrapped client', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

🔥 LOVE that you did this 💪

// noinspection JSUnusedGlobalSymbols
/**
* @deprecated use `setConfigurationStores` instead
Copy link
Contributor

Choose a reason for hiding this comment

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

This would make us lose the ability to update just one of the stores at one time. Is this something we're ok losing? I imagine so.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

After some thought, this collecting into a single method may not be necessary. I think we need to take another look at the config stores anyway so I'll avoid changes here for now.

: undefined;

// We need to run this method sync, but, because the configuration stores potentially have an async write at the end
// of updating the configuration, the method to do so it also async. Use an IIFE to wrap the async call.
Copy link
Contributor

Choose a reason for hiding this comment

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

🤨
Why does this need to be run synchronously?

Copy link
Collaborator Author

@typotter typotter Mar 19, 2025

Choose a reason for hiding this comment

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

(fixed the comment)

It doesn't. I gave up trying to force bootstrap to be sync. The js-client offlineInit method is sync (in fact, the options type for that method is IClientConfigSync), so I need to understand a little more why that method is so explicitly sync.

We can also just fire and forget the async method to keep bootstrap sync, but that hanging promise feels awkward (and indeed eslint dislikes it)

Copy link
Collaborator

Choose a reason for hiding this comment

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

We can also just fire and forget the async method to keep bootstrap sync, but that hanging promise feels awkward (and indeed eslint dislikes it)

I don't think that's an issue as nobody has a reason to await it, so letting it hang makes a lot of sense

@@ -63,6 +63,8 @@ export class StoreBackedConfiguration implements IConfiguration {
);
}
await Promise.all(promises);

// TODO: notify of config change if `didUpdateFlags` is true
Copy link
Contributor

Choose a reason for hiding this comment

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

This PR or a future one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Future one (this TODO has been moved around a couple of times now).

All of this config refactoring is leading to this line being called every time the configuration changes. Then we can implement the onConfigurationChange and waitForInitialization methods

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.

This looks good but I'm honestly getting lost in all these abstractions. They to try to abstract something but they seem rather shallow—you have to read the code to understand what each of these things is doing

I feel a bit ranty, so here's my general struggles with js codebase (please don't take it personally—I just hope that you can help me resolve some of these):

  • Configuration stores don't store configurations but rather arbitrary key-value entries and some random associated bits that don't always make sense (like salt or format). This directly leads to us having dozens of these stores for all bits of data.
  • Store-backed configuration is mutable, so might be changing under me
  • Stores mix two concerns: source of truth for currently active configuration and configuration persistence. It would help to untangle the two.
  • Configuration manager seems rather shallow and I can't name what is it's purpose. Most of method seem like their should either belong to configuration or would belong to a central configuration store

My current thinking around configuration handling (partially implemented in core) is as follows (not saying that we should follow it but I gave it quite a bit of thought, so if it makes sense we can use this thinking across SDKs):

  • "Configuration" is all pieces of data that client needs for evaluation (flags, bandits, etc.). Note that it's just data—it's passive, it doesn't do anything and doesn't change. If we fetch new flags from the server, that's a new configuration.
  • "Configuration store" is here to answer "what's the currently active Configuration" and coordinate other components. Conceptually, it's just a global variable with watcher: getConfiguration()/setConfiguration()/onConfigurationChange()—that's it.
  • Other components interact with the central configuration store and either get configuration and do something with it (e.g., evaluation, persistence) or produce new configurations (configuration poller, get/set configuration api in client)
  • Persistence is just another component with two features: (1) race to load initial configuration, and (2) on configuration change, persist configuration in background

Kind of like this:
image

* accommodate writing to a persistent store. For fastest initialization, (at the cost of persisting configuration),
* use `bootstrap` in conjunction with `MemoryOnlyConfigurationStore` instances which won't do an async write.
*/
async bootstrap(configuration: IConfigurationWire): Promise<void> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

We call this method setConfiguration in other SDKs as it's also useful beside the initial configuration (e.g., when user wants to manually control how configuration is refreshed). Would that name work here?

For bootstrap specifically, other SDKs also have initialConfiguration initialization option, so SDK is instantly available after init.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think setConfiguration works as a method name here.
In the downstream SDKs, we typically have init and offlineInit, but the desire is to move away from "offline" and instead use the term, bootstrap.

Comment on lines +343 to +345
* Initializes the `EppoClient` from the provided configuration. This method is async only to
* accommodate writing to a persistent store. For fastest initialization, (at the cost of persisting configuration),
* use `bootstrap` in conjunction with `MemoryOnlyConfigurationStore` instances which won't do an async write.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we have to wait for persistent storage? (I imagine we can start serving assignment immediately without waiting for storage)

this.banditVariationConfigurationStore ?? null,
this.banditModelConfigurationStore ?? null,
this.configurationManager,
!!this.banditModelConfigurationStore && !!this.banditVariationConfigurationStore,
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: as with most boolean flags, it took me a second to figure out what's going on here (I guess it's a flag for whether it need to fetch bandits). Consider having an object with named options instead

null,
null,
new ConfigurationManager(configurationStore),
false,
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: even worse here—I have no idea what this false means from reading this the code

import { IConfigurationStore } from './configuration-store';
import { IConfigurationManager } from './i-configuration-manager';

export class ConfigurationManager implements IConfigurationManager {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: having a separate interface look like an oop cargo cult—I don't think we're going to have multiple implementations of configuration managers?

Suggested change
export class ConfigurationManager implements IConfigurationManager {
export class ConfigurationManager {

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tough, but fair. fixed.

Comment on lines +26 to +30
this.configuration = new StoreBackedConfiguration(
this.flagConfigurationStore,
this.banditReferenceConfigurationStore,
this.banditConfigurationStore,
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

minor: haven't seen the previous PR but this kind of "configuration" is internally mutable and may cause troubles down the line when it changes on user unexpectedly. I like to treat configurations as immutable/constant data as that simplifies many things and avoids surprises (like flags disappearing from under me)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I actually have a PR to produce an immutable config (though a better implementation is forthcoming) each time the getConfiguration method is called. Thus an assignment or evaluation step can run with an unchanging config.

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.

3 participants