Skip to content

Config service fixes & improvements #14

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

Merged
merged 11 commits into from
Jun 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion samples/node-realtimeupdate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const configcatSdkKey = "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/tiOvFw5gkky9LFu1
const pubnubSubscriberKey = "demo";
const pubnubPublishKey = "demo";

// ConfigCat instance with manual poll. Polls ConfigCat and updates the cache only when forceRefresh() is called.
// ConfigCat instance with manual poll. Polls ConfigCat and updates the cache only when forceRefreshAsync() is called.
var configCatClient = configcat.getClient(configcatSdkKey, configcat.PollingMode.ManualPoll);

// PubNub instance
Expand Down
87 changes: 38 additions & 49 deletions src/AutoPollConfigService.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { AutoPollOptions } from "./ConfigCatClientOptions";
import type { LoggerWrapper } from "./ConfigCatLogger";
import type { IConfigFetcher } from "./ConfigFetcher";
import type { FetchResult, IConfigFetcher } from "./ConfigFetcher";
import type { IConfigService, RefreshResult } from "./ConfigServiceBase";
import { ClientCacheState, ConfigServiceBase } from "./ConfigServiceBase";
import type { ProjectConfig } from "./ProjectConfig";
import { AbortToken, delay } from "./Utils";
import { AbortToken, delay, getMonotonicTimeMs } from "./Utils";

export const POLL_EXPIRATION_TOLERANCE_MS = 500;

Expand Down Expand Up @@ -32,9 +31,10 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
if (options.maxInitWaitTimeSeconds !== 0) {
this.initialized = false;

// This promise will be resolved when
// 1. the cache contains a valid config at startup (see startRefreshWorker) or
// 2. config json is fetched the first time, regardless of success or failure (see onConfigUpdated).
// This promise will be resolved as soon as
// 1. a cache sync operation completes, and the obtained config is up-to-date (see getConfig and refreshWorkerLogic),
// 2. or, in case the client is online and the internal cache is still empty or expired after the initial cache sync-up,
// the first config fetch operation completes, regardless of success or failure (see onConfigFetched).
const initSignalPromise = new Promise<void>(resolve => this.signalInitialization = resolve);

// This promise will be resolved when either initialization ready is signalled by signalInitialization() or maxInitWaitTimeSeconds pass.
Expand All @@ -47,14 +47,9 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
this.initializationPromise = Promise.resolve(false);
}

this.readyPromise = this.getReadyPromise(this.initializationPromise, async initializationPromise => {
await initializationPromise;
return this.getCacheState(this.options.cache.getInMemory());
});
this.readyPromise = this.getReadyPromise(initialCacheSyncUp);

if (!options.offline) {
this.startRefreshWorker(initialCacheSyncUp, this.stopToken);
}
this.startRefreshWorker(initialCacheSyncUp, this.stopToken);
}

private async waitForInitializationAsync(initSignalPromise: Promise<void>): Promise<boolean> {
Expand All @@ -72,32 +67,28 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
return success;
}

protected override async waitForReadyAsync(): Promise<ClientCacheState> {
await this.initializationPromise;
return this.getCacheState(this.options.cache.getInMemory());
}

async getConfig(): Promise<ProjectConfig> {
this.options.logger.debug("AutoPollConfigService.getConfig() called.");

function logSuccess(logger: LoggerWrapper) {
logger.debug("AutoPollConfigService.getConfig() - returning value from cache.");
}

let cachedConfig: ProjectConfig;
if (!this.isOffline && !this.initialized) {
cachedConfig = await this.options.cache.get(this.cacheKey);
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
logSuccess(this.options.logger);
return cachedConfig;
}
let cachedConfig = await this.syncUpWithCache();

if (!cachedConfig.isExpired(this.pollIntervalMs)) {
this.signalInitialization();
} else if (!this.isOffline && !this.initialized) {
this.options.logger.debug("AutoPollConfigService.getConfig() - cache is empty or expired, waiting for initialization.");
await this.initializationPromise;
}

cachedConfig = await this.options.cache.get(this.cacheKey);
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
logSuccess(this.options.logger);
cachedConfig = this.options.cache.getInMemory();
} else {
this.options.logger.debug("AutoPollConfigService.getConfig() - cache is empty or expired.");
return cachedConfig;
}

this.options.logger.debug("AutoPollConfigService.getConfig() - returning value from cache.");
return cachedConfig;
}

Expand All @@ -114,42 +105,39 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
}
}

protected onConfigFetched(newConfig: ProjectConfig): void {
protected onConfigFetched(fetchResult: FetchResult, isInitiatedByUser: boolean): void {
this.signalInitialization();
super.onConfigFetched(newConfig);
}

protected setOnlineCore(): void {
this.startRefreshWorker(null, this.stopToken);
super.onConfigFetched(fetchResult, isInitiatedByUser);
}

protected setOfflineCore(): void {
protected goOnline(): void {
// We need to restart the polling loop because going from offline to online should trigger a refresh operation
// immediately instead of waiting for the next tick (which might not happen until much later).
this.stopRefreshWorker();
this.stopToken = new AbortToken();
this.startRefreshWorker(null, this.stopToken);
}

private async startRefreshWorker(initialCacheSyncUp: ProjectConfig | Promise<ProjectConfig> | null, stopToken: AbortToken) {
this.options.logger.debug("AutoPollConfigService.startRefreshWorker() called.");

let isFirstIteration = true;
while (!stopToken.aborted) {
try {
const scheduledNextTimeMs = new Date().getTime() + this.pollIntervalMs;
const scheduledNextTimeMs = getMonotonicTimeMs() + this.pollIntervalMs;
try {
await this.refreshWorkerLogic(isFirstIteration, initialCacheSyncUp);
await this.refreshWorkerLogic(initialCacheSyncUp);
} catch (err) {
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
}

const realNextTimeMs = scheduledNextTimeMs - new Date().getTime();
const realNextTimeMs = scheduledNextTimeMs - getMonotonicTimeMs();
if (realNextTimeMs > 0) {
await delay(realNextTimeMs, stopToken);
}
} catch (err) {
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
}

isFirstIteration = false;
initialCacheSyncUp = null; // allow GC to collect the Promise and its result
}
}
Expand All @@ -159,23 +147,24 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
this.stopToken.abort();
}

private async refreshWorkerLogic(isFirstIteration: boolean, initialCacheSyncUp: ProjectConfig | Promise<ProjectConfig> | null) {
this.options.logger.debug("AutoPollConfigService.refreshWorkerLogic() - called.");
private async refreshWorkerLogic(initialCacheSyncUp: ProjectConfig | Promise<ProjectConfig> | null) {
this.options.logger.debug("AutoPollConfigService.refreshWorkerLogic() called.");

const latestConfig = await (initialCacheSyncUp ?? this.options.cache.get(this.cacheKey));
const latestConfig = await (initialCacheSyncUp ?? this.syncUpWithCache());
if (latestConfig.isExpired(this.pollExpirationMs)) {
// Even if the service gets disposed immediately, we allow the first refresh for backward compatibility,
// i.e. to not break usage patterns like this:
// ```
// client.getValueAsync("SOME_KEY", false).then(value => { /* ... */ }, user);
// client.getValueAsync("SOME_KEY", false, user).then(value => { /* ... */ });
// client.dispose();
// ```
if (isFirstIteration ? !this.isOfflineExactly : !this.isOffline) {
await this.refreshConfigCoreAsync(latestConfig);
if (initialCacheSyncUp ? !this.isOfflineExactly : !this.isOffline) {
await this.refreshConfigCoreAsync(latestConfig, false);
return; // postpone signalling initialization until `onConfigFetched`
}
} else if (isFirstIteration) {
this.signalInitialization();
}

this.signalInitialization();
}

getCacheState(cachedConfig: ProjectConfig): ClientCacheState {
Expand Down
33 changes: 23 additions & 10 deletions src/ConfigCatCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ export interface IConfigCatCache {
get(key: string): Promise<string | null | undefined> | string | null | undefined;
}

/** @remarks Unchanged config is returned as is, changed config is wrapped in an array so we can distinguish between the two cases. */
export type CacheSyncResult = ProjectConfig | [changedConfig: ProjectConfig];

export interface IConfigCache {
set(key: string, config: ProjectConfig): Promise<void> | void;

get(key: string): Promise<ProjectConfig> | ProjectConfig;
get(key: string): Promise<CacheSyncResult> | CacheSyncResult;

getInMemory(): ProjectConfig;
}
Expand Down Expand Up @@ -58,7 +61,7 @@ export class ExternalConfigCache implements IConfigCache {
this.cachedSerializedConfig = ProjectConfig.serialize(config);
this.cachedConfig = config;
} else {
// We may have empty entries with timestamp > 0 (see the flooding prevention logic in ConfigServiceBase.fetchLogicAsync).
// We may have empty entries with timestamp > 0 (see the flooding prevention logic in ConfigServiceBase.fetchAsync).
// In such cases we want to preserve the timestamp locally but don't want to store those entries into the external cache.
this.cachedSerializedConfig = void 0;
this.cachedConfig = config;
Expand All @@ -71,39 +74,49 @@ export class ExternalConfigCache implements IConfigCache {
}
}

private updateCachedConfig(externalSerializedConfig: string | null | undefined): void {
private updateCachedConfig(externalSerializedConfig: string | null | undefined): CacheSyncResult {
if (externalSerializedConfig == null || externalSerializedConfig === this.cachedSerializedConfig) {
return;
return this.cachedConfig;
}

this.cachedConfig = ProjectConfig.deserialize(externalSerializedConfig);
const externalConfig = ProjectConfig.deserialize(externalSerializedConfig);
const hasChanged = !ProjectConfig.contentEquals(externalConfig, this.cachedConfig);
this.cachedConfig = externalConfig;
this.cachedSerializedConfig = externalSerializedConfig;
return hasChanged ? [this.cachedConfig] : this.cachedConfig;
}

get(key: string): Promise<ProjectConfig> {
get(key: string): Promise<CacheSyncResult> | CacheSyncResult {
let cacheSyncResult: CacheSyncResult;

try {
const cacheGetResult = this.cache.get(key);

// Take the async path only when the IConfigCatCache.get operation is asynchronous.
if (isPromiseLike(cacheGetResult)) {
return (async (cacheGetPromise) => {
let cacheSyncResult: CacheSyncResult;

try {
this.updateCachedConfig(await cacheGetPromise);
cacheSyncResult = this.updateCachedConfig(await cacheGetPromise);
} catch (err) {
cacheSyncResult = this.cachedConfig;
this.logger.configServiceCacheReadError(err);
}
return this.cachedConfig;

return cacheSyncResult;
})(cacheGetResult);
}

// Otherwise, keep the code flow synchronous so the config services can sync up
// with the cache in their ctors synchronously (see ConfigServiceBase.syncUpWithCache).
this.updateCachedConfig(cacheGetResult);
cacheSyncResult = this.updateCachedConfig(cacheGetResult);
} catch (err) {
cacheSyncResult = this.cachedConfig;
this.logger.configServiceCacheReadError(err);
}

return Promise.resolve(this.cachedConfig);
return cacheSyncResult;
}

getInMemory(): ProjectConfig {
Expand Down
31 changes: 25 additions & 6 deletions src/ConfigCatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,38 @@ export interface IConfigCatClient extends IProvidesHooks {
getKeyAndValueAsync(variationId: string): Promise<SettingKeyValue | null>;

/**
* Refreshes the locally cached config by fetching the latest version from the remote server.
* Updates the internally cached config by synchronizing with the external cache (if any),
* then by fetching the latest version from the ConfigCat CDN (provided that the client is online).
* @returns A promise that fulfills with the refresh result.
*/
forceRefreshAsync(): Promise<RefreshResult>;

/**
* Waits for the client initialization.
* @returns A promise that fulfills with the client's initialization state.
* Waits for the client to reach the ready state, i.e. to complete initialization.
*
* @remarks Ready state is reached as soon as the initial sync with the external cache (if any) completes.
* If this does not produce up-to-date config data, and the client is online (i.e. HTTP requests are allowed),
* the first config fetch operation is also awaited in Auto Polling mode before ready state is reported.
*
* That is, reaching the ready state usually means the client is ready to evaluate feature flags and settings.
* However, please note that this is not guaranteed. In case of initialization failure or timeout, the internal cache
* may be empty or expired even after the ready state is reported. You can verify this by checking the return value.
*
* @returns A promise that fulfills with the state of the internal cache at the time initialization was completed.
*/
waitForReady(): Promise<ClientCacheState>;

/**
* Captures the current state of the client.
* The resulting snapshot can be used to synchronously evaluate feature flags and settings based on the captured state.
*
* @remarks The operation captures the internally cached config data. It does not attempt to update it by synchronizing with
* the external cache or by fetching the latest version from the ConfigCat CDN.
*
* Therefore, it is recommended to use snapshots in conjunction with the Auto Polling mode, where the SDK automatically
* updates the internal cache in the background.
*
* For other polling modes, you will need to manually initiate a cache update by invoking `forceRefreshAsync`.
*/
snapshot(): IConfigCatClientSnapshot;

Expand All @@ -118,7 +136,7 @@ export interface IConfigCatClient extends IProvidesHooks {
setOnline(): void;

/**
* Configures the client to not initiate HTTP requests and work using the locally cached config only.
* Configures the client to not initiate HTTP requests but work using the cache only.
*/
setOffline(): void;

Expand All @@ -130,9 +148,10 @@ export interface IConfigCatClient extends IProvidesHooks {

/** Represents the state of `IConfigCatClient` captured at a specific point in time. */
export interface IConfigCatClientSnapshot {
/** The state of the internal cache at the time the snapshot was created. */
readonly cacheState: ClientCacheState;

/** The latest config which has been fetched from the remote server. */
/** The internally cached config at the time the snapshot was created. */
readonly fetchedConfig: IConfig | null;

/**
Expand Down Expand Up @@ -543,7 +562,7 @@ export class ConfigCatClient implements IConfigCatClient {
return RefreshResult.failure(errorToString(err), err);
}
} else {
return RefreshResult.failure("Client is configured to use the LocalOnly override behavior, which prevents making HTTP requests.");
return RefreshResult.failure("Client is configured to use the LocalOnly override behavior, which prevents synchronization with external cache and making HTTP requests.");
}
}

Expand Down
15 changes: 9 additions & 6 deletions src/ConfigCatClientOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import type { IUser } from "./User";

/** Specifies the supported polling modes. */
export const enum PollingMode {
/** The ConfigCat SDK downloads the latest values automatically and stores them in the local cache. */
/** The ConfigCat SDK downloads the latest config data automatically and stores it in the cache. */
AutoPoll = 0,
/** The ConfigCat SDK downloads the latest setting values only if they are not present in the local cache, or if the cache entry has expired. */
/** The ConfigCat SDK downloads the latest config data only if it is not present in the cache, or if it is but has expired. */
LazyLoad = 1,
/** The ConfigCat SDK will not download the config JSON automatically. You need to update the cache manually, by calling `forceRefresh()`. */
/** The ConfigCat SDK will not download the config data automatically. You need to update the cache manually, by calling `forceRefreshAsync()`. */
ManualPoll = 2,
}

Expand Down Expand Up @@ -88,14 +88,15 @@ export interface IOptions {
export interface IAutoPollOptions extends IOptions {
/**
* Config refresh interval.
* Specifies how frequently the locally cached config will be refreshed by fetching the latest version from the remote server.
* Specifies how frequently the internally cached config will be updated by synchronizing with
* the external cache and/or by fetching the latest version from the ConfigCat CDN.
*
* Default value is 60 seconds. Minimum value is 1 second. Maximum value is 2147483 seconds.
*/
pollIntervalSeconds?: number;

/**
* Maximum waiting time between initialization and the first config acquisition.
* Maximum waiting time before reporting the ready state, i.e. emitting the `clientReady` event.
*
* Default value is 5 seconds. Maximum value is 2147483 seconds. Negative values mean infinite waiting.
*/
Expand All @@ -109,7 +110,9 @@ export interface IManualPollOptions extends IOptions {
/** Options used to configure the ConfigCat SDK in the case of Lazy Loading mode. */
export interface ILazyLoadingOptions extends IOptions {
/**
* Cache time to live value. Specifies how long the locally cached config can be used before refreshing it again by fetching the latest version from the remote server.
* Cache time to live value.
* Specifies how long the cached config can be used before updating it again
* by fetching the latest version from the ConfigCat CDN.
*
* Default value is 60 seconds. Minimum value is 1 second. Maximum value is 2147483647 seconds.
*/
Expand Down
Loading
Loading