-
Notifications
You must be signed in to change notification settings - Fork 0
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
base: main
Are you sure you want to change the base?
Conversation
@@ -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', () => { |
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.
🔥 LOVE that you did this 💪
src/client/eppo-client.ts
Outdated
// noinspection JSUnusedGlobalSymbols | ||
/** | ||
* @deprecated use `setConfigurationStores` instead |
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.
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.
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.
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.
src/client/eppo-client.ts
Outdated
: 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. |
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.
🤨
Why does this need to be run synchronously?
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.
(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)
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.
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 |
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.
This PR or a future one?
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.
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
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.
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
src/client/eppo-client.ts
Outdated
* 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> { |
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.
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.
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 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
.
* 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. |
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.
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, |
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.
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, |
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.
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 { |
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.
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?
export class ConfigurationManager implements IConfigurationManager { | |
export class ConfigurationManager { |
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.
tough, but fair. fixed.
this.configuration = new StoreBackedConfiguration( | ||
this.flagConfigurationStore, | ||
this.banditReferenceConfigurationStore, | ||
this.banditConfigurationStore, | ||
); |
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.
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)
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 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.
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 commonEppoClient
, we need to be able to make reads and writes to the configuration outside of the scope ofConfigurationRequestor
. The format of the bootstrap configuration is a higher order object containing the responses normally received by theConfigurationRequestor
to populate the configuration (IUniversalConfigResponse
andIBanditParametersResponse
). Thus both theConfigurationRequestor
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 underlyingIConfigurationStore
instances be swapped out at basically any time (there are public setters for these stores). This is the final loose end towards supportingonConfigurationChange
.Description
: allows setting the Configuration based on UFC responses, and changing out the
IConfigurationStoreinstances used to "back" the configuration. We let the
EppoClient` 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 fromConfigurationRequestor
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 theConfigurationManager
** internal API change:**ConfigurationRequestor.constructor
EppoClient
set*Store
methods to update the Configuration stores on theConfigurationManager
. This actually fixes a bug whereby the config store could be set after initialization but it wouldn't take effect untilfetchFlagConfigurations
was called.EppoClient
method:setConfigurationStores
to encourage setting all of the stores at once and deprecatedsetStore
methodsEppoClient.configurationManager
, created on instantiation, so it is always available to return aConfiguration
so simplifiedEppoClient.getConfiguration
in turnHow has this been tested?
ConfigurationRequestor
toConfigurationManager
)EppoClient
stack, which is nearly fully exercised by our unit test suite which continues to pass