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: buildAndInit for isolated client instances #166

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

Conversation

typotter
Copy link
Collaborator

@typotter typotter commented Jan 31, 2025


labels: mergeable

Eppo Internal
🎟️ Fixes: FF-3888
πŸ“œ Multiple Eppo Clients
Configuration Side-loading

Motivation and Context

The init and getInstance() methods work on a singleton instance of the EppoClient which makes for a generally smooth developer experience given that the Eppo SDK essentially handles dependency management for the dev. This does have its own drawbacks, but those are not particularly relevant here.

There are use cases, however, when a non-singleton instance of the EppoClient is required. One such use case is embedding the Eppo SDK into a library which itself can be included in other applications. If that 3p library shipped with Eppo is integrated into another application that has the Eppo SDK running, there would be undefined behaviour as the SDK cannot handle this use case.

The init method (and class constructors) for EppoClient and subclasses have evolved organically over time, adding new options as new features are added to the clients. The very large options type is beginning to become a little untenable and disorganized so we take the opportunity to clean that up a bit here.

There are other limitations and drawbacks to the current model of instantiating an EppoClient statically and then initializing it when the code calls init including the awkward coupling of needing to wait for init to resolve in order to get a reference to an initialized client. We have an opportunity to decouple initialization and waiting to make for a better DX (in addition to giving the dev full control over managing the EppoClient reference (intrinsically allows for easier mocking in tests and will be consistent with the host applications existing DI approach).

This change must be done without a major version bump and completely preserve the existing singleton API

Description

  • Refactor the initialization options by grouping related options and extracting different types for each.
  • Each option type is then combined back into a type using the existing IClientConfig name (this keeps the change backwards compatible while offering a big win in option clarity)
    - New method: buildAndInit which creates an isolated client instance.
    - new method: waitForConfiguration which resolves when the first config is loaded.
  • Docs

How has this been documented?

How has this been tested?

  • tests

@typotter typotter changed the title Tp/namespace feat: non-singleton EppoJSClient Jan 31, 2025
src/index.ts Outdated
}
}

public waitForReady(): Promise<void> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

new method! πŸŽ‰

Copy link
Member

Choose a reason for hiding this comment

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

what does "ready" mean in this context? adding comments is likely to help, also we should always do it for public APIs anyways

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

renamed to waitForInitialization

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

waitForConfiguration to be in line with multiplatform's analog PollerThread::waitForConfiguration

src/index.ts Outdated
if (!EppoJSClient.initialized) {
private ensureInitialized() {
if (!this.initialized) {
// TODO: check super.isInitialized?
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

EppoClient.initialized also exists, but means something different than it does here.

Copy link
Member

Choose a reason for hiding this comment

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

what does it mean and how is that different than this? consider renaming either if 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.

the super method checks that all the config stores are initialized while this this.initialized is true when all the initialization workload is done so they are pretty much the same.
I think we can move to deprecate EppoJSClient.initialized and defer to super.isInitialized().

src/index.ts Outdated
Comment on lines 339 to 380
/**
* Tracks pending initialization. After an initialization completes, the value is removed from the map.
*/
private static initializationPromise: Promise<EppoJSClient> | null = null;

/**
* This method is part of a bridge from using a singleton to independent instances. More specifically, it exists so
* that the init method can access the private field, `readyResolver`. It should not be called by any
* methods other than the `init` method. There are limited guards in place; the behaviour if called inappropriately
* is undefined.
*
* It also keeps code that relies on internal details of EppoJSClient colocated in the class.
*
* @internal
*
* @param client
* @param config
*/
static async initializeClient(
client: EppoJSClient,
config: IClientConfig,
): Promise<EppoJSClient> {
validation.validateNotBlank(config.apiKey, 'API key required');

// If there is already an init in progress for this apiKey, return that.
if (!EppoJSClient.initializationPromise) {
EppoJSClient.initializationPromise = explicitInit(config, client).then((client) => {
// Resolve the ready promise if it exists
if (client.readyPromiseResolver) {
client.readyPromiseResolver();
client.readyPromiseResolver = null;
}
return client;
});
}

const readyClient = await EppoJSClient.initializationPromise;
EppoJSClient.initializationPromise = null;

return readyClient;
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is the old init method and initialization buffer tracker.
Moved the method to a static member of EppoJSClient so that it can access the readyResolver and notify waitForReady

src/index.ts Outdated
/**
* Initializes the Eppo client with configuration parameters.
* This method should be called once on application startup.
* If an initialization is in process, calling `init` will return the in-progress
* `Promise<EppoClient>`. Once the initialization completes, calling `init` again will kick off the
* initialization routine (if `forceReinitialization` is `true`).
*
*
* @deprecated
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

controversial, I know.
willing to negotiate

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like a minor change for the end user if the argument interface stays identical

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The @deprecated annotation is here to help push devs to using the new construct + wait paradigm. This method will be removed and the argument interface for the new constructor is slightly different sdkKey vs apiKey

@typotter typotter requested a review from rasendubi February 14, 2025 17:09
@typotter
Copy link
Collaborator Author

Several false starts and iterations later, we're ready for another review. Note: this PR is now stacked on #170 where changes are made just to how the singleton is referenced, leaving the focus of this PR on waitForConfiguration and buildAndInit.

@@ -165,4 +189,77 @@ export interface IClientConfig extends IBaseRequestConfig {
*/
maxQueueSize?: number;
};
};

export type IStorageOptions = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Perfect thanks! And now that I think about it if the have the same API it's actually probably not too bad if they are messing with eachother's persistent storage anyways as they should be using same configuration

src/index.ts Outdated

/**
* Resolved when the client is initialized
* @private
Copy link
Contributor

Choose a reason for hiding this comment

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

super minor, but does this annotation really add anything given the private keyword two lines later?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

nope. eager IDE.

src/index.ts Outdated
@@ -620,6 +657,7 @@ export async function init(config: IClientConfig): Promise<EppoJSClient> {
/**
* Used to access a singleton SDK client instance.
* Use the method after calling init() to initialize the client.
* @deprecated
Copy link
Contributor

Choose a reason for hiding this comment

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

consider adding what the user should do instead after @deprecated (e.g., why is this deprecated)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Undeprecated the singleton (for now)

Base automatically changed from typo/no-singleton to main February 18, 2025 17:41
Copy link
Member

@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.

Main feedback is around documentation and providing alternative APIs for what's been deprecated. I'm ready to approve after that, great work!

@@ -4,6 +4,10 @@

## EppoJSClient.initialized property

> Warning: This API is now obsolete.
Copy link
Member

Choose a reason for hiding this comment

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

did you mean deprecated instead of obsolete?

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'm not sure whereyarn docs is getting this text from

@@ -4,6 +4,10 @@

## getInstance() function

> Warning: This API is now obsolete.
Copy link
Member

Choose a reason for hiding this comment

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

same question

Copy link
Member

Choose a reason for hiding this comment

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

also whenever deprecating something, we should always propose an alternative so users are not left guessing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This file is auto-generated, so the text is out of my hands. I have, however, updated all of the deprecated tags with an alternative. thank you

*/
forceReinitialize?: boolean;

export type IEventOptions = {
Copy link
Member

Choose a reason for hiding this comment

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

this needs docs

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

added

@@ -165,4 +189,77 @@ export interface IClientConfig extends IBaseRequestConfig {
*/
maxQueueSize?: number;
};
};

export type IStorageOptions = {
Copy link
Member

Choose a reason for hiding this comment

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

documentation for this type missing

ILoggers &
IEventOptions &
IStorageOptions &
IPollingOptions;
Copy link
Member

Choose a reason for hiding this comment

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

i love this! thanks for organizing

src/index.ts Outdated
@@ -109,10 +111,57 @@ export class EppoJSClient extends EppoClient {
isObfuscated: true,
});

/**
* @deprecated
Copy link
Member

Choose a reason for hiding this comment

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

alternative?

Copy link
Collaborator Author

@typotter typotter Feb 21, 2025

Choose a reason for hiding this comment

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

documented!

src/index.ts Outdated
* Resolved when the client is initialized
* @private
*/
private readonly initializedPromise: Promise<void>;
Copy link
Member

Choose a reason for hiding this comment

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

nit: i'd rename to initializationPromise or simply initPromise

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

good point. It's not an initialized promise, it's a promise signalling when it is initialized.

@typotter typotter changed the title feat: non-singleton EppoJSClient feat: buildAndInit for isolated client instances Feb 21, 2025
@typotter
Copy link
Collaborator Author

typotter commented Feb 21, 2025

Thank you, @rasendubi @felipecsl @aarsilv for you reviews and patience as we explore this change.

I have made a key change - the singleton init and getInstance are no longer deprecated. I still fell that's the right direction to take, but for now, let's ship this feature and continue to polish afterwards.

PTAL

* Initialization happens outside the constructor, so we can't assign `initPromise` to the result
* of initialization. Instead, we call the resolver when `init` is complete.
*/
private initializedPromiseResolver: () => void = () => null;
Copy link
Member

Choose a reason for hiding this comment

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

nit: initPromiseResolver

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.

5 participants