diff --git a/src/AutoPollConfigService.ts b/src/AutoPollConfigService.ts index 6f74b65..e6e83ca 100644 --- a/src/AutoPollConfigService.ts +++ b/src/AutoPollConfigService.ts @@ -1,10 +1,10 @@ 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; @@ -32,9 +32,10 @@ export class AutoPollConfigService extends ConfigServiceBase 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. the initial sync with the external cache completes (see startRefreshWorker), + // 2. and, in case the client is online and the local cache is still empty or expired, + // the first config fetch operation completes, regardless of success or failure (see onConfigFetched). const initSignalPromise = new Promise(resolve => this.signalInitialization = resolve); // This promise will be resolved when either initialization ready is signalled by signalInitialization() or maxInitWaitTimeSeconds pass. @@ -47,14 +48,9 @@ export class AutoPollConfigService extends ConfigServiceBase 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): Promise { @@ -72,6 +68,11 @@ export class AutoPollConfigService extends ConfigServiceBase im return success; } + protected override async waitForReadyAsync(): Promise { + await this.initializationPromise; + return this.getCacheState(this.options.cache.getInMemory()); + } + async getConfig(): Promise { this.options.logger.debug("AutoPollConfigService.getConfig() called."); @@ -80,8 +81,8 @@ export class AutoPollConfigService extends ConfigServiceBase im } let cachedConfig: ProjectConfig; - if (!this.isOffline && !this.initialized) { - cachedConfig = await this.options.cache.get(this.cacheKey); + if (!this.initialized) { + cachedConfig = await this.syncUpWithCache(); if (!cachedConfig.isExpired(this.pollIntervalMs)) { logSuccess(this.options.logger); return cachedConfig; @@ -91,7 +92,7 @@ export class AutoPollConfigService extends ConfigServiceBase im await this.initializationPromise; } - cachedConfig = await this.options.cache.get(this.cacheKey); + cachedConfig = await this.syncUpWithCache(); if (!cachedConfig.isExpired(this.pollIntervalMs)) { logSuccess(this.options.logger); } else { @@ -114,34 +115,32 @@ export class AutoPollConfigService extends ConfigServiceBase im } } - protected onConfigFetched(newConfig: ProjectConfig): void { + protected onConfigFetched(fetchResult: FetchResult, isInitiatedByUser: boolean): void { this.signalInitialization(); - super.onConfigFetched(newConfig); + super.onConfigFetched(fetchResult, isInitiatedByUser); } - protected setOnlineCore(): void { - this.startRefreshWorker(null, this.stopToken); - } - - 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 | 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); } @@ -149,7 +148,6 @@ export class AutoPollConfigService extends ConfigServiceBase im this.options.logger.autoPollConfigServiceErrorDuringPolling(err); } - isFirstIteration = false; initialCacheSyncUp = null; // allow GC to collect the Promise and its result } } @@ -159,23 +157,24 @@ export class AutoPollConfigService extends ConfigServiceBase im this.stopToken.abort(); } - private async refreshWorkerLogic(isFirstIteration: boolean, initialCacheSyncUp: ProjectConfig | Promise | null) { - this.options.logger.debug("AutoPollConfigService.refreshWorkerLogic() - called."); + private async refreshWorkerLogic(initialCacheSyncUp: ProjectConfig | Promise | 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 { diff --git a/src/ConfigCatCache.ts b/src/ConfigCatCache.ts index 8345cf4..6ba01c9 100644 --- a/src/ConfigCatCache.ts +++ b/src/ConfigCatCache.ts @@ -19,10 +19,13 @@ export interface IConfigCatCache { get(key: string): Promise | 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; - get(key: string): Promise | ProjectConfig; + get(key: string): Promise | CacheSyncResult; getInMemory(): ProjectConfig; } @@ -71,39 +74,47 @@ 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); this.cachedSerializedConfig = externalSerializedConfig; + return [this.cachedConfig]; } - get(key: string): Promise { + get(key: string): Promise | 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 { diff --git a/src/ConfigCatClient.ts b/src/ConfigCatClient.ts index a3c5670..ae9a870 100644 --- a/src/ConfigCatClient.ts +++ b/src/ConfigCatClient.ts @@ -85,8 +85,16 @@ export interface IConfigCatClient extends IProvidesHooks { forceRefreshAsync(): Promise; /** - * 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 provide 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 Poll mode before ready state is reported. + * + * That is, reaching the ready state usually means that the client is ready to evaluate feature flags. + * However, please note that it is not guaranteed. You can determine this by checking the return value. + * + * @returns A promise that fulfills with the state of the local cache at the time initialization was completed. */ waitForReady(): Promise; diff --git a/src/ConfigServiceBase.ts b/src/ConfigServiceBase.ts index 8b376c8..ba19eda 100644 --- a/src/ConfigServiceBase.ts +++ b/src/ConfigServiceBase.ts @@ -1,8 +1,11 @@ +import type { CacheSyncResult } from "./ConfigCatCache"; +import { ExternalConfigCache } from "./ConfigCatCache"; import type { OptionsBase } from "./ConfigCatClientOptions"; import type { FetchErrorCauses, IConfigFetcher, IFetchResponse } from "./ConfigFetcher"; import { FetchError, FetchResult, FetchStatus } from "./ConfigFetcher"; import { RedirectMode } from "./ConfigJson"; import { Config, ProjectConfig } from "./ProjectConfig"; +import { isPromiseLike } from "./Utils"; /** Contains the result of an `IConfigCatClient.forceRefresh` or `IConfigCatClient.forceRefreshAsync` operation. */ export class RefreshResult { @@ -71,10 +74,16 @@ function nameOfConfigServiceStatus(value: ConfigServiceStatus): string { return ConfigServiceStatus[value] as string; } +type ConfigRefreshOperation = { + promise: Promise<[FetchResult, ProjectConfig]>; + latestConfig: ProjectConfig; +}; + export abstract class ConfigServiceBase { private status: ConfigServiceStatus; - private pendingFetch: Promise | null = null; + private pendingCacheSyncUp: Promise | null = null; + private pendingConfigRefresh: ConfigRefreshOperation | null = null; protected readonly cacheKey: string; @@ -102,40 +111,75 @@ export abstract class ConfigServiceBase { abstract getConfig(): Promise; async refreshConfigAsync(): Promise<[RefreshResult, ProjectConfig]> { - const latestConfig = await this.options.cache.get(this.cacheKey); + const latestConfig = await this.syncUpWithCache(); if (!this.isOffline) { - const [fetchResult, config] = await this.refreshConfigCoreAsync(latestConfig); + const [fetchResult, config] = await this.refreshConfigCoreAsync(latestConfig, true); return [RefreshResult.from(fetchResult), config]; + } else if (this.options.cache instanceof ExternalConfigCache) { + return [RefreshResult.success(), latestConfig]; } else { const errorMessage = this.options.logger.configServiceCannotInitiateHttpCalls().toString(); return [RefreshResult.failure(errorMessage), latestConfig]; } } - protected async refreshConfigCoreAsync(latestConfig: ProjectConfig): Promise<[FetchResult, ProjectConfig]> { - const fetchResult = await this.fetchAsync(latestConfig); - - let configChanged = false; - const success = fetchResult.status === FetchStatus.Fetched; - if (success - || fetchResult.config.timestamp > latestConfig.timestamp && (!fetchResult.config.isEmpty || latestConfig.isEmpty)) { - await this.options.cache.set(this.cacheKey, fetchResult.config); + protected async refreshConfigCoreAsync(latestConfig: ProjectConfig, isInitiatedByUser: boolean): Promise<[FetchResult, ProjectConfig]> { + let refreshOperation = this.pendingConfigRefresh; - configChanged = success && !ProjectConfig.equals(fetchResult.config, latestConfig); - latestConfig = fetchResult.config; + if (refreshOperation) { + const { promise, latestConfig: knownLatestConfig } = refreshOperation; + if (latestConfig.timestamp > knownLatestConfig.timestamp && (!latestConfig.isEmpty || knownLatestConfig.isEmpty)) { + refreshOperation.latestConfig = latestConfig; + } + return promise; } - this.onConfigFetched(fetchResult.config); + refreshOperation = { latestConfig } as ConfigRefreshOperation; + refreshOperation.promise = (async (refreshOperation: ConfigRefreshOperation) => { + const fetchResult = await this.fetchAsync(refreshOperation.latestConfig); + + // NOTE: Further joiners may obtain more up-to-date configs from the external cache, and update + // operation.latestConfig before the operation completes, but those updates will be ignored. + // In other words, the operation may not return the most recent config obtained during its execution. + // However, this is acceptable, especially if we consider that reading and writing the external cache is + // not synchronized, which means that a more recent config can be overwritten with a stale one. + // (We don't make any effort to synchronize external cache access as that would be extremely hard, + // and we expect the "stuttering" resulting from this race condition to be temporary only.) + let { latestConfig } = refreshOperation; + + const success = fetchResult.status === FetchStatus.Fetched; + if (success + || fetchResult.config.timestamp > latestConfig.timestamp && (!fetchResult.config.isEmpty || latestConfig.isEmpty)) { + await this.options.cache.set(this.cacheKey, fetchResult.config); + + latestConfig = fetchResult.config; + } - if (configChanged) { - this.onConfigChanged(fetchResult.config); - } + return [fetchResult, latestConfig]; + })(refreshOperation); + + const refreshAndFinish = refreshOperation.promise + .finally(() => this.pendingConfigRefresh = null) + .then(refreshResult => { + const [fetchResult] = refreshResult; + + this.onConfigFetched(fetchResult, isInitiatedByUser); - return [fetchResult, latestConfig]; + if (fetchResult.status === FetchStatus.Fetched) { + this.onConfigChanged(fetchResult.config); + } + + return refreshResult; + }); + + this.pendingConfigRefresh = refreshOperation; + + return refreshAndFinish; } - protected onConfigFetched(newConfig: ProjectConfig): void { + protected onConfigFetched(fetchResult: FetchResult, isInitiatedByUser: boolean): void { this.options.logger.debug("config fetched"); + this.options.hooks.emit("configFetched", RefreshResult.from(fetchResult), isInitiatedByUser); } protected onConfigChanged(newConfig: ProjectConfig): void { @@ -143,19 +187,9 @@ export abstract class ConfigServiceBase { this.options.hooks.emit("configChanged", newConfig.config ?? new Config({})); } - private fetchAsync(lastConfig: ProjectConfig): Promise { - return this.pendingFetch ??= (async () => { - try { - return await this.fetchLogicAsync(lastConfig); - } finally { - this.pendingFetch = null; - } - })(); - } - - private async fetchLogicAsync(lastConfig: ProjectConfig): Promise { + private async fetchAsync(lastConfig: ProjectConfig): Promise { const options = this.options; - options.logger.debug("ConfigServiceBase.fetchLogicAsync() - called."); + options.logger.debug("ConfigServiceBase.fetchLogicAsync() called."); let errorMessage: string; try { @@ -207,7 +241,7 @@ export abstract class ConfigServiceBase { // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents private async fetchRequestAsync(lastETag: string | null, maxRetryCount = 2): Promise<[IFetchResponse, (Config | any)?]> { const options = this.options; - options.logger.debug("ConfigServiceBase.fetchRequestAsync() - called."); + options.logger.debug("ConfigServiceBase.fetchRequestAsync() called."); for (let retryNumber = 0; ; retryNumber++) { options.logger.debug(`ConfigServiceBase.fetchRequestAsync(): calling fetchLogic()${retryNumber > 0 ? `, retry ${retryNumber}/${maxRetryCount}` : ""}`); @@ -278,11 +312,11 @@ export abstract class ConfigServiceBase { return this.status !== ConfigServiceStatus.Online; } - protected setOnlineCore(): void { /* Intentionally empty. */ } + protected goOnline(): void { /* Intentionally empty. */ } setOnline(): void { if (this.status === ConfigServiceStatus.Offline) { - this.setOnlineCore(); + this.goOnline(); this.status = ConfigServiceStatus.Online; this.options.logger.configServiceStatusChanged(nameOfConfigServiceStatus(this.status)); } else if (this.disposed) { @@ -290,11 +324,8 @@ export abstract class ConfigServiceBase { } } - protected setOfflineCore(): void { /* Intentionally empty. */ } - setOffline(): void { if (this.status === ConfigServiceStatus.Online) { - this.setOfflineCore(); this.status = ConfigServiceStatus.Offline; this.options.logger.configServiceStatusChanged(nameOfConfigServiceStatus(this.status)); } else if (this.disposed) { @@ -305,12 +336,45 @@ export abstract class ConfigServiceBase { abstract getCacheState(cachedConfig: ProjectConfig): ClientCacheState; protected syncUpWithCache(): ProjectConfig | Promise { - return this.options.cache.get(this.cacheKey); + if (this.pendingCacheSyncUp) { + return this.pendingCacheSyncUp; + } + + const syncResult = this.options.cache.get(this.cacheKey); + if (!isPromiseLike(syncResult)) { + return this.onCacheSynced(syncResult); + } + + const syncUpAndFinish = syncResult + .finally(() => this.pendingCacheSyncUp = null) + .then(syncResult => this.onCacheSynced(syncResult)); + + this.pendingCacheSyncUp = syncResult + .then(syncResult => !Array.isArray(syncResult) ? syncResult : syncResult[0]); + + return syncUpAndFinish; + } + + private onCacheSynced(syncResult: CacheSyncResult): ProjectConfig { + if (!Array.isArray(syncResult)) { + return syncResult; + } + + const [newConfig] = syncResult; + if (!newConfig.isEmpty) { + this.onConfigChanged(newConfig); + } + return newConfig; + } + + protected async waitForReadyAsync(initialCacheSyncUp: ProjectConfig | Promise): Promise { + return this.getCacheState(await initialCacheSyncUp); } - protected async getReadyPromise(state: TState, waitForReadyAsync: (state: TState) => Promise): Promise { - const cacheState = await waitForReadyAsync(state); - this.options.hooks.emit("clientReady", cacheState); - return cacheState; + protected getReadyPromise(initialCacheSyncUp: ProjectConfig | Promise): Promise { + return this.waitForReadyAsync(initialCacheSyncUp).then(cacheState => { + this.options.hooks.emit("clientReady", cacheState); + return cacheState; + }); } } diff --git a/src/Hooks.ts b/src/Hooks.ts index 4867913..3bceb6c 100644 --- a/src/Hooks.ts +++ b/src/Hooks.ts @@ -1,4 +1,4 @@ -import type { ClientCacheState } from "./ConfigServiceBase"; +import type { ClientCacheState, RefreshResult } from "./ConfigServiceBase"; import type { IEventEmitter, IEventProvider } from "./EventEmitter"; import { NullEventEmitter } from "./EventEmitter"; import type { IConfig } from "./ProjectConfig"; @@ -6,11 +6,27 @@ import type { IEvaluationDetails } from "./RolloutEvaluator"; /** Hooks (events) that can be emitted by `ConfigCatClient`. */ export type HookEvents = { - /** Occurs when the client is ready to provide the actual value of feature flags or settings. */ + /** + * Occurs when the client reaches the ready state, i.e. completes 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 Poll mode before ready state is reported. + * + * That is, reaching the ready state usually means that the client is ready to evaluate feature flags. + * However, please note that it is not guaranteed. You can determine this by checking the `cacheState` parameter. + */ clientReady: [cacheState: ClientCacheState]; /** Occurs after the value of a feature flag of setting has been evaluated. */ flagEvaluated: [evaluationDetails: IEvaluationDetails]; - /** Occurs after the locally cached config has been updated. */ + /** + * Occurs after attempting to refresh the locally cached config by fetching the latest version from the remote server. + */ + configFetched: [result: RefreshResult, isInitiatedByUser: boolean]; + /** + * Occurs after the locally cached config has been updated to a newer version, either as a result of synchronization + * with the external cache, or as a result of fetching a newer version from the remote server. + */ configChanged: [newConfig: IConfig]; /** Occurs in the case of a failure in the client. */ clientError: [message: string, exception?: any]; diff --git a/src/LazyLoadConfigService.ts b/src/LazyLoadConfigService.ts index fb6a14c..d3b70bc 100644 --- a/src/LazyLoadConfigService.ts +++ b/src/LazyLoadConfigService.ts @@ -17,7 +17,7 @@ export class LazyLoadConfigService extends ConfigServiceBase im this.cacheTimeToLiveMs = options.cacheTimeToLiveSeconds * 1000; const initialCacheSyncUp = this.syncUpWithCache(); - this.readyPromise = this.getReadyPromise(initialCacheSyncUp, async initialCacheSyncUp => this.getCacheState(await initialCacheSyncUp)); + this.readyPromise = this.getReadyPromise(initialCacheSyncUp); } async getConfig(): Promise { @@ -27,12 +27,12 @@ export class LazyLoadConfigService extends ConfigServiceBase im logger.debug(`LazyLoadConfigService.getConfig(): cache is empty or expired${appendix}.`); } - let cachedConfig = await this.options.cache.get(this.cacheKey); + let cachedConfig = await this.syncUpWithCache(); if (cachedConfig.isExpired(this.cacheTimeToLiveMs)) { if (!this.isOffline) { logExpired(this.options.logger, ", calling refreshConfigCoreAsync()"); - [, cachedConfig] = await this.refreshConfigCoreAsync(cachedConfig); + [, cachedConfig] = await this.refreshConfigCoreAsync(cachedConfig, false); } else { logExpired(this.options.logger); } diff --git a/src/ManualPollConfigService.ts b/src/ManualPollConfigService.ts index 0988112..08d79cb 100644 --- a/src/ManualPollConfigService.ts +++ b/src/ManualPollConfigService.ts @@ -13,7 +13,7 @@ export class ManualPollConfigService extends ConfigServiceBase this.getCacheState(await initialCacheSyncUp)); + this.readyPromise = this.getReadyPromise(initialCacheSyncUp); } getCacheState(cachedConfig: ProjectConfig): ClientCacheState { @@ -26,7 +26,7 @@ export class ManualPollConfigService extends ConfigServiceBase { this.options.logger.debug("ManualPollService.getConfig() called."); - return await this.options.cache.get(this.cacheKey); + return await this.syncUpWithCache(); } refreshConfigAsync(): Promise<[RefreshResult, ProjectConfig]> { diff --git a/src/ProjectConfig.ts b/src/ProjectConfig.ts index a7d9eab..08430c8 100644 --- a/src/ProjectConfig.ts +++ b/src/ProjectConfig.ts @@ -14,13 +14,6 @@ export class ProjectConfig { static readonly empty = new ProjectConfig(void 0, void 0, 0, void 0); - static equals(projectConfig1: ProjectConfig, projectConfig2: ProjectConfig): boolean { - // When both ETags are available, we don't need to check the JSON content. - return projectConfig1.httpETag && projectConfig2.httpETag - ? projectConfig1.httpETag === projectConfig2.httpETag - : projectConfig1.configJson === projectConfig2.configJson; - } - constructor( readonly configJson: string | undefined, readonly config: Config | undefined, diff --git a/src/Utils.ts b/src/Utils.ts index e1eb999..96fdcda 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -46,6 +46,10 @@ export function delay(delayMs: number, abortToken?: AbortToken | null): Promise< }); } +export const getMonotonicTimeMs = typeof performance !== "undefined" && typeof performance.now === "function" + ? () => performance.now() + : () => new Date().getTime(); + export function errorToString(err: any, includeStackTrace = false): string { return err instanceof Error ? includeStackTrace && err.stack ? err.stack : err.toString() diff --git a/test/ConfigCatCacheTests.ts b/test/ConfigCatCacheTests.ts index 3d00ee1..0b9dc39 100644 --- a/test/ConfigCatCacheTests.ts +++ b/test/ConfigCatCacheTests.ts @@ -1,6 +1,6 @@ import { assert } from "chai"; -import { createManualPollOptions, FakeLogger } from "./helpers/fakes"; -import { ExternalConfigCache, IConfigCache, IConfigCatCache, InMemoryConfigCache } from "#lib/ConfigCatCache"; +import { createManualPollOptions, FakeExternalCache, FakeLogger, FaultyFakeExternalCache } from "./helpers/fakes"; +import { ExternalConfigCache, IConfigCache, InMemoryConfigCache } from "#lib/ConfigCatCache"; import { LoggerWrapper, LogLevel } from "#lib/ConfigCatLogger"; import { Config, ProjectConfig } from "#lib/ProjectConfig"; @@ -111,23 +111,3 @@ describe("ConfigCatCache", () => { }); } }); - -class FakeExternalCache implements IConfigCatCache { - cachedValue?: string; - - set(key: string, value: string): void { - this.cachedValue = value; - } - get(key: string): string | undefined { - return this.cachedValue; - } -} - -class FaultyFakeExternalCache implements IConfigCatCache { - set(key: string, value: string): never { - throw new Error("Operation failed :("); - } - get(key: string): never { - throw new Error("Operation failed :("); - } -} diff --git a/test/ConfigCatClientOptionsTests.ts b/test/ConfigCatClientOptionsTests.ts index ee2ef4f..70ea94f 100644 --- a/test/ConfigCatClientOptionsTests.ts +++ b/test/ConfigCatClientOptionsTests.ts @@ -415,7 +415,7 @@ class FakeCache implements IConfigCache { } } -export class FakeLogger implements IConfigCatLogger { +class FakeLogger implements IConfigCatLogger { level?: LogLevel | undefined; log(level: LogLevel, eventId: LogEventId, message: LogMessage, exception?: any): void { diff --git a/test/ConfigCatClientTests.ts b/test/ConfigCatClientTests.ts index 61d1162..0d8b366 100644 --- a/test/ConfigCatClientTests.ts +++ b/test/ConfigCatClientTests.ts @@ -15,7 +15,7 @@ import { isWeakRefAvailable, setupPolyfills } from "#lib/Polyfills"; import { Config, IConfig, ProjectConfig, SettingValue, SettingValueContainer } from "#lib/ProjectConfig"; import { EvaluateContext, IEvaluateResult, IEvaluationDetails, IRolloutEvaluator } from "#lib/RolloutEvaluator"; import { User } from "#lib/User"; -import { delay } from "#lib/Utils"; +import { delay, getMonotonicTimeMs } from "#lib/Utils"; import "./helpers/ConfigCatClientCacheExtensions"; describe("ConfigCatClient", () => { @@ -195,7 +195,7 @@ describe("ConfigCatClient", () => { const key = "notexists"; const defaultValue = false; - const timestamp = new Date().getTime(); + const timestamp = ProjectConfig.generateTimestamp(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); @@ -238,7 +238,7 @@ describe("ConfigCatClient", () => { const key = "debug"; const defaultValue = false; - const timestamp = new Date().getTime(); + const timestamp = ProjectConfig.generateTimestamp(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); @@ -281,7 +281,7 @@ describe("ConfigCatClient", () => { const key = "debug"; const defaultValue = "N/A"; - const timestamp = new Date().getTime(); + const timestamp = ProjectConfig.generateTimestamp(); const configFetcherClass = FakeConfigFetcherWithRules; const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); @@ -327,7 +327,7 @@ describe("ConfigCatClient", () => { const key = "string25Cat25Dog25Falcon25Horse"; const defaultValue = "N/A"; - const timestamp = new Date().getTime(); + const timestamp = ProjectConfig.generateTimestamp(); const configFetcherClass = FakeConfigFetcherWithPercentageOptions; const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); @@ -372,7 +372,7 @@ describe("ConfigCatClient", () => { const key = "debug"; const defaultValue = false; - const timestamp = new Date().getTime(); + const timestamp = ProjectConfig.generateTimestamp(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); @@ -427,7 +427,7 @@ describe("ConfigCatClient", () => { // Arrange - const timestamp = new Date().getTime(); + const timestamp = ProjectConfig.generateTimestamp(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); @@ -479,7 +479,7 @@ describe("ConfigCatClient", () => { // Arrange - const timestamp = new Date().getTime(); + const timestamp = ProjectConfig.generateTimestamp(); const configFetcherClass = FakeConfigFetcherWithTwoKeys; const cachedPc = new ProjectConfig(configFetcherClass.configJson, Config.deserialize(configFetcherClass.configJson), timestamp, "etag"); @@ -585,13 +585,13 @@ describe("ConfigCatClient", () => { const configCatKernel = createKernel({ configFetcher: new FakeConfigFetcher(500) }); const options: AutoPollOptions = createAutoPollOptions("APIKEY", { maxInitWaitTimeSeconds }, configCatKernel); - const startDate: number = new Date().getTime(); + const startTime: number = getMonotonicTimeMs(); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); const actualValue = await client.getValueAsync("debug", false); - const elapsedMilliseconds: number = new Date().getTime() - startDate; + const elapsedMilliseconds: number = getMonotonicTimeMs() - startTime; assert.isAtLeast(elapsedMilliseconds, 500 - 25); // 25 ms for tolerance - assert.isAtMost(elapsedMilliseconds, maxInitWaitTimeSeconds * 1000 + 75); // 75 ms for tolerance + assert.isAtMost(elapsedMilliseconds, maxInitWaitTimeSeconds * 1000 + 100); // 100 ms for tolerance assert.equal(actualValue, true); client.dispose(); @@ -609,13 +609,13 @@ describe("ConfigCatClient", () => { const configCatKernel = createKernel({ configFetcher }); const options: AutoPollOptions = createAutoPollOptions("APIKEY", { maxInitWaitTimeSeconds }, configCatKernel); - const startDate: number = new Date().getTime(); + const startTime: number = getMonotonicTimeMs(); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); const actualDetails = await client.getValueDetailsAsync("debug", false); - const elapsedMilliseconds: number = new Date().getTime() - startDate; + const elapsedMilliseconds: number = getMonotonicTimeMs() - startTime; assert.isAtLeast(elapsedMilliseconds, 500 - 25); // 25 ms for tolerance - assert.isAtMost(elapsedMilliseconds, configFetchDelay * 2 + 75); // 75 ms for tolerance + assert.isAtMost(elapsedMilliseconds, configFetchDelay * 2 + 100); // 100 ms for tolerance assert.equal(actualDetails.isDefaultValue, true); assert.equal(actualDetails.value, false); @@ -630,13 +630,13 @@ describe("ConfigCatClient", () => { const configCatKernel = createKernel({ configFetcher: new FakeConfigFetcherWithNullNewConfig(10000) }); const options: AutoPollOptions = createAutoPollOptions("APIKEY", { maxInitWaitTimeSeconds }, configCatKernel); - const startDate: number = new Date().getTime(); + const startTime: number = getMonotonicTimeMs(); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); const actualValue = await client.getValueAsync("debug", false); - const elapsedMilliseconds: number = new Date().getTime() - startDate; + const elapsedMilliseconds: number = getMonotonicTimeMs() - startTime; assert.isAtLeast(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) - 25); // 25 ms for tolerance - assert.isAtMost(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) + 75); // 75 ms for tolerance + assert.isAtMost(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) + 100); // 100 ms for tolerance assert.equal(actualValue, false); client.dispose(); @@ -671,13 +671,13 @@ describe("ConfigCatClient", () => { const configCatKernel = createKernel({ configFetcher }); const options: AutoPollOptions = createAutoPollOptions("APIKEY", { maxInitWaitTimeSeconds }, configCatKernel); - const startDate: number = new Date().getTime(); + const startTime: number = getMonotonicTimeMs(); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); const state = await client.waitForReady(); - const elapsedMilliseconds: number = new Date().getTime() - startDate; + const elapsedMilliseconds: number = getMonotonicTimeMs() - startTime; assert.isAtLeast(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) - 25); // 25 ms for tolerance - assert.isAtMost(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) + 75); // 75 ms for tolerance + assert.isAtMost(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) + 100); // 100 ms for tolerance assert.equal(state, ClientCacheState.NoFlagData); @@ -704,13 +704,13 @@ describe("ConfigCatClient", () => { cache: new FakeExternalCacheWithInitialData(120_000), }, configCatKernel); - const startDate: number = new Date().getTime(); + const startTime: number = getMonotonicTimeMs(); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); const state = await client.waitForReady(); - const elapsedMilliseconds: number = new Date().getTime() - startDate; + const elapsedMilliseconds: number = getMonotonicTimeMs() - startTime; assert.isAtLeast(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) - 25); // 25 ms for tolerance - assert.isAtMost(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) + 75); // 75 ms for tolerance + assert.isAtMost(elapsedMilliseconds, (maxInitWaitTimeSeconds * 1000) + 100); // 100 ms for tolerance assert.equal(state, ClientCacheState.HasCachedFlagDataOnly); @@ -948,7 +948,7 @@ describe("ConfigCatClient", () => { const configFetcher = new FakeConfigFetcher(500); const configJson = "{\"f\": { \"debug\": { \"v\": { \"b\": false }, \"i\": \"abcdefgh\", \"t\": 0, \"p\": [], \"r\": [] } } }"; - const configCache = new FakeCache(new ProjectConfig(configJson, Config.deserialize(configJson), new Date().getTime() - 10000000, "etag2")); + const configCache = new FakeCache(new ProjectConfig(configJson, Config.deserialize(configJson), ProjectConfig.generateTimestamp() - 10000000, "etag2")); const configCatKernel = createKernel({ configFetcher, defaultCacheFactory: () => configCache }); const options: AutoPollOptions = createAutoPollOptions("APIKEY", { maxInitWaitTimeSeconds: 10 }, configCatKernel); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); @@ -963,7 +963,7 @@ describe("ConfigCatClient", () => { const configFetcher = new FakeConfigFetcher(500); const configJson = "{\"f\": { \"debug\": { \"v\": { \"b\": false }, \"i\": \"abcdefgh\", \"t\": 0, \"p\": [], \"r\": [] } } }"; - const configCache = new FakeCache(new ProjectConfig(configJson, Config.deserialize(configJson), new Date().getTime() - 10000000, "etag2")); + const configCache = new FakeCache(new ProjectConfig(configJson, Config.deserialize(configJson), ProjectConfig.generateTimestamp() - 10000000, "etag2")); const configCatKernel = createKernel({ configFetcher, defaultCacheFactory: () => configCache }); const options: AutoPollOptions = createAutoPollOptions("APIKEY", { maxInitWaitTimeSeconds: 10 }, configCatKernel); const client: IConfigCatClient = new ConfigCatClient(options, configCatKernel); @@ -1216,9 +1216,10 @@ describe("ConfigCatClient", () => { ]; for (const [pollingMode, optionsFactory] of optionsFactoriesForOfflineModeTests) { - it(`setOnline() should make a(n) ${PollingMode[pollingMode]} client created in offline mode transition to online mode.`, async () => { + it(`setOnline() should make a(n) ${PollingMode[pollingMode]} client created in offline mode transition to online mode`, async () => { + const configFetcherDelayMs = 100; - const configFetcher = new FakeConfigFetcherBase("{}", 100, (lastConfig, lastETag) => ({ + const configFetcher = new FakeConfigFetcherBase("{}", configFetcherDelayMs, (lastConfig, lastETag) => ({ statusCode: 200, reasonPhrase: "OK", eTag: (lastETag as any | 0) + 1 + "", @@ -1246,7 +1247,7 @@ describe("ConfigCatClient", () => { client.setOnline(); if (configService instanceof AutoPollConfigService) { - assert.isTrue(await configService["initializationPromise"]); + await delay(configFetcherDelayMs + 50); expectedFetchTimes++; } @@ -1283,7 +1284,7 @@ describe("ConfigCatClient", () => { } for (const [pollingMode, optionsFactory] of optionsFactoriesForOfflineModeTests) { - it(`setOffline() should make a(n) ${PollingMode[pollingMode]} client created in online mode transition to offline mode.`, async () => { + it(`setOffline() should make a(n) ${PollingMode[pollingMode]} client created in online mode transition to offline mode`, async () => { const configFetcher = new FakeConfigFetcherBase("{}", 100, (lastConfig, lastETag) => ({ statusCode: 200, @@ -1352,19 +1353,22 @@ describe("ConfigCatClient", () => { } for (const addListenersViaOptions of [false, true]) { - it(`ConfigCatClient should emit events, which listeners added ${addListenersViaOptions ? "via options" : "directly on the client"} should get notified of.`, async () => { + it(`ConfigCatClient should emit events, which listeners added ${addListenersViaOptions ? "via options" : "directly on the client"} should get notified of`, async () => { let clientReadyEventCount = 0; + const configFetchedEvents: [RefreshResult, boolean][] = []; const configChangedEvents: IConfig[] = []; const flagEvaluatedEvents: IEvaluationDetails[] = []; const errorEvents: [string, any][] = []; const handleClientReady = () => clientReadyEventCount++; + const handleConfigFetched = (result: RefreshResult, isInitiatedByUser: boolean) => configFetchedEvents.push([result, isInitiatedByUser]); const handleConfigChanged = (pc: IConfig) => configChangedEvents.push(pc); const handleFlagEvaluated = (ed: IEvaluationDetails) => flagEvaluatedEvents.push(ed); const handleClientError = (msg: string, err: any) => errorEvents.push([msg, err]); function setupHooks(hooks: IProvidesHooks) { hooks.on("clientReady", handleClientReady); + hooks.on("configFetched", handleConfigFetched); hooks.on("configChanged", handleConfigChanged); hooks.on("flagEvaluated", handleFlagEvaluated); hooks.on("clientError", handleClientError); @@ -1390,6 +1394,7 @@ describe("ConfigCatClient", () => { assert.equal(state, ClientCacheState.NoFlagData); assert.equal(clientReadyEventCount, 1); + assert.equal(configFetchedEvents.length, 0); assert.equal(configChangedEvents.length, 0); assert.equal(flagEvaluatedEvents.length, 0); assert.equal(errorEvents.length, 0); @@ -1409,6 +1414,7 @@ describe("ConfigCatClient", () => { await client.forceRefreshAsync(); + assert.equal(configFetchedEvents.length, 0); assert.equal(configChangedEvents.length, 0); assert.equal(errorEvents.length, 1); const [actualErrorMessage, actualErrorException] = errorEvents[0]; @@ -1421,6 +1427,10 @@ describe("ConfigCatClient", () => { await client.forceRefreshAsync(); const cachedPc = await configCache.get(""); + assert.equal(configFetchedEvents.length, 1); + const [refreshResult, isInitiatedByUser] = configFetchedEvents[0]; + assert.isTrue(isInitiatedByUser); + assert.isTrue(refreshResult.isSuccess); assert.equal(configChangedEvents.length, 1); assert.strictEqual(configChangedEvents[0], cachedPc.config); @@ -1437,6 +1447,7 @@ describe("ConfigCatClient", () => { // 5. Client gets disposed client.dispose(); + assert.equal(configFetchedEvents.length, 1); assert.equal(clientReadyEventCount, 1); assert.equal(configChangedEvents.length, 1); assert.equal(evaluationDetails.length, flagEvaluatedEvents.length); diff --git a/test/ConfigServiceBaseTests.ts b/test/ConfigServiceBaseTests.ts index 197d0d0..56c27f2 100644 --- a/test/ConfigServiceBaseTests.ts +++ b/test/ConfigServiceBaseTests.ts @@ -3,15 +3,17 @@ import { assert } from "chai"; import { EqualMatchingInjectorConfig, It, Mock, RejectedPromiseFactory, ResolvedPromiseFactory, Times } from "moq.ts"; import { MimicsRejectedAsyncPresetFactory, MimicsResolvedAsyncPresetFactory, Presets, ReturnsAsyncPresetFactory, RootMockProvider, ThrowsAsyncPresetFactory } from "moq.ts/internal"; /* eslint-enable import/no-duplicates */ -import { createAutoPollOptions, createKernel, createLazyLoadOptions, createManualPollOptions, FakeCache } from "./helpers/fakes"; +import { createAutoPollOptions, createKernel, createLazyLoadOptions, createManualPollOptions, FakeCache, FakeExternalAsyncCache, FakeExternalCache, FakeLogger } from "./helpers/fakes"; +import { ClientCacheState } from "#lib"; import { AutoPollConfigService, POLL_EXPIRATION_TOLERANCE_MS } from "#lib/AutoPollConfigService"; -import { IConfigCache, InMemoryConfigCache } from "#lib/ConfigCatCache"; +import { ExternalConfigCache, IConfigCache, InMemoryConfigCache } from "#lib/ConfigCatCache"; import { OptionsBase } from "#lib/ConfigCatClientOptions"; +import { LoggerWrapper } from "#lib/ConfigCatLogger"; import { FetchResult, IConfigFetcher, IFetchResponse } from "#lib/ConfigFetcher"; import { LazyLoadConfigService } from "#lib/LazyLoadConfigService"; import { ManualPollConfigService } from "#lib/ManualPollConfigService"; -import { Config, ProjectConfig } from "#lib/ProjectConfig"; -import { delay } from "#lib/Utils"; +import { Config, IConfig, ProjectConfig } from "#lib/ProjectConfig"; +import { AbortToken, delay } from "#lib/Utils"; describe("ConfigServiceBaseTests", () => { @@ -176,7 +178,7 @@ describe("ConfigServiceBaseTests", () => { const projectConfigNew: ProjectConfig = createConfigFromFetchResult(frNew); - const time: number = new Date().getTime(); + const time: number = ProjectConfig.generateTimestamp(); const projectConfigOld: ProjectConfig = createConfigFromFetchResult(frOld).with(time - (1.5 * pollInterval * 1000) + 0.5 * POLL_EXPIRATION_TOLERANCE_MS); const cache = new InMemoryConfigCache(); @@ -224,7 +226,7 @@ describe("ConfigServiceBaseTests", () => { const pollInterval = 10; - const time: number = new Date().getTime(); + const time: number = ProjectConfig.generateTimestamp(); const projectConfigOld = createConfigFromFetchResult(frOld).with(time - (pollInterval * 1000) + 0.5 * POLL_EXPIRATION_TOLERANCE_MS); const cache = new InMemoryConfigCache(); @@ -310,12 +312,156 @@ describe("ConfigServiceBaseTests", () => { service.dispose(); }); + for (const expectedCacheState of [ClientCacheState.NoFlagData, ClientCacheState.HasCachedFlagDataOnly, ClientCacheState.HasUpToDateFlagData]) { + it(`AutoPollConfigService - Should emit clientReady in offline mode when sync with external cache is completed - expectedCacheState: ${expectedCacheState}`, async () => { + + // Arrange + + const pollIntervalSeconds = 1; + + let projectConfig: ProjectConfig | undefined; + if (expectedCacheState !== ClientCacheState.NoFlagData) { + const fr: FetchResult = createFetchResult("oldEtag"); + projectConfig = createConfigFromFetchResult(fr); + + if (expectedCacheState === ClientCacheState.HasCachedFlagDataOnly) { + projectConfig = projectConfig + .with(ProjectConfig.generateTimestamp() - (1.5 * pollIntervalSeconds * 1000) + 0.5 * POLL_EXPIRATION_TOLERANCE_MS); + } + } + + const logger = new LoggerWrapper(new FakeLogger()); + const cache = new ExternalConfigCache(new FakeExternalCache(), logger); + + const options = createAutoPollOptions( + "APIKEY", + { + pollIntervalSeconds, + offline: true, + }, + createKernel({ defaultCacheFactory: () => cache }) + ); + + if (projectConfig) { + cache.set(options.getCacheKey(), projectConfig); + } + + const fetcherMock = new Mock(); + + // Act + + const service: AutoPollConfigService = new AutoPollConfigService( + fetcherMock.object(), + options); + + const { readyPromise } = service; + const delayAbortToken = new AbortToken(); + const delayPromise = delay(pollIntervalSeconds * 1000 - 250, delayAbortToken); + const raceResult = await Promise.race([readyPromise, delayPromise]); + + // Assert + + assert.strictEqual(raceResult, expectedCacheState); + + // Cleanup + + service.dispose(); + }); + } + + for (const useSyncCache of [false, true]) { + it(`AutoPollConfigService - Should refresh local cache in offline mode and report configChanged when new config is synced from external cache - useSyncCache: ${useSyncCache}`, async () => { + + // Arrange + + const pollIntervalSeconds = 1; + + const logger = new LoggerWrapper(new FakeLogger()); + const fakeExternalCache = useSyncCache ? new FakeExternalCache() : new FakeExternalAsyncCache(50); + const cache = new ExternalConfigCache(fakeExternalCache, logger); + + const clientReadyEvents: ClientCacheState[] = []; + const configChangedEvents: IConfig[] = []; + + const options = createAutoPollOptions( + "APIKEY", + { + pollIntervalSeconds, + setupHooks: hooks => { + hooks.on("clientReady", cacheState => clientReadyEvents.push(cacheState)); + hooks.on("configChanged", config => configChangedEvents.push(config)); + }, + }, + createKernel({ defaultCacheFactory: () => cache }) + ); + + const fr: FetchResult = createFetchResult(); + const fetcherMock = new Mock() + .setup(m => m.fetchLogic(It.IsAny(), It.IsAny())) + .returnsAsync({ statusCode: 200, reasonPhrase: "OK", eTag: fr.config.httpETag, body: fr.config.configJson }); + + // Act + + const service: AutoPollConfigService = new AutoPollConfigService( + fetcherMock.object(), + options); + + assert.isUndefined(fakeExternalCache.cachedValue); + + assert.isEmpty(clientReadyEvents); + assert.isEmpty(configChangedEvents); + + await service.readyPromise; + + const getConfigPromise = service.getConfig(); + await service.getConfig(); // simulate concurrent cache sync up + await getConfigPromise; + + await delay(100); // allow a little time for the client to raise ConfigChanged + + assert.isDefined(fakeExternalCache.cachedValue); + + assert.strictEqual(1, clientReadyEvents.length); + assert.strictEqual(ClientCacheState.HasUpToDateFlagData, clientReadyEvents[0]); + assert.strictEqual(1, configChangedEvents.length); + assert.strictEqual(JSON.stringify(fr.config.config), JSON.stringify(configChangedEvents[0])); + + fetcherMock.verify(m => m.fetchLogic(It.IsAny(), It.IsAny()), Times.Once()); + + service.setOffline(); // no HTTP fetching from this point on + + await delay(pollIntervalSeconds * 1000 + 50); + + assert.strictEqual(1, clientReadyEvents.length); + assert.strictEqual(1, configChangedEvents.length); + + const fr2: FetchResult = createFetchResult("etag2", "{}"); + const projectConfig2 = createConfigFromFetchResult(fr2); + fakeExternalCache.cachedValue = ProjectConfig.serialize(projectConfig2); + + const [refreshResult, projectConfigFromRefresh] = await service.refreshConfigAsync(); + + // Assert + + assert.isTrue(refreshResult.isSuccess); + + assert.strictEqual(1, clientReadyEvents.length); + assert.strictEqual(2, configChangedEvents.length); + assert.strictEqual(JSON.stringify(fr2.config.config), JSON.stringify(configChangedEvents[1])); + assert.strictEqual(configChangedEvents[1], projectConfigFromRefresh.config); + + // Cleanup + + service.dispose(); + }); + } + it("LazyLoadConfigService - ProjectConfig is different in the cache - should fetch a new config and put into cache", async () => { // Arrange const cacheTimeToLiveSeconds = 10; - const oldConfig: ProjectConfig = createProjectConfig("oldConfig").with(new Date().getTime() - (cacheTimeToLiveSeconds * 1000) - 1000); + const oldConfig: ProjectConfig = createProjectConfig("oldConfig").with(ProjectConfig.generateTimestamp() - (cacheTimeToLiveSeconds * 1000) - 1000); const fr: FetchResult = createFetchResult("newConfig"); @@ -358,7 +504,7 @@ describe("ConfigServiceBaseTests", () => { // Arrange - const config: ProjectConfig = createProjectConfig().with(new Date().getTime()); + const config: ProjectConfig = createProjectConfig().with(ProjectConfig.generateTimestamp()); const fetcherMock = new Mock(); @@ -392,7 +538,7 @@ describe("ConfigServiceBaseTests", () => { // Arrange - const config: ProjectConfig = createProjectConfig().with(new Date().getTime() - 1000); + const config: ProjectConfig = createProjectConfig().with(ProjectConfig.generateTimestamp() - 1000); const fr: FetchResult = createFetchResult(); @@ -433,7 +579,7 @@ describe("ConfigServiceBaseTests", () => { // Arrange - const config: ProjectConfig = createProjectConfig().with(new Date().getTime() - 1000); + const config: ProjectConfig = createProjectConfig().with(ProjectConfig.generateTimestamp() - 1000); const fr: FetchResult = createFetchResult(); @@ -777,8 +923,10 @@ describe("ConfigServiceBaseTests", () => { }); }); -function createProjectConfig(eTag = "etag"): ProjectConfig { - const configJson = "{\"f\": { \"debug\": { \"v\": { \"b\": true }, \"i\": \"abcdefgh\", \"t\": 0, \"p\": [], \"r\": [] } } }"; +const DEFAULT_ETAG = "etag"; +const DEFAULT_CONFIG_JSON = '{"f": { "debug": { "v": { "b": true }, "i": "abcdefgh", "t": 0, "p": [], "r": [] } } }'; + +function createProjectConfig(eTag = DEFAULT_ETAG, configJson = DEFAULT_CONFIG_JSON): ProjectConfig { return new ProjectConfig( configJson, Config.deserialize(configJson), @@ -786,8 +934,8 @@ function createProjectConfig(eTag = "etag"): ProjectConfig { eTag); } -function createFetchResult(eTag = "etag"): FetchResult { - return FetchResult.success(createProjectConfig(eTag)); +function createFetchResult(eTag = DEFAULT_ETAG, configJson = DEFAULT_CONFIG_JSON): FetchResult { + return FetchResult.success(createProjectConfig(eTag, configJson)); } function createConfigFromFetchResult(result: FetchResult): ProjectConfig { diff --git a/test/DataGovernanceTests.ts b/test/DataGovernanceTests.ts index f58093d..332049d 100644 --- a/test/DataGovernanceTests.ts +++ b/test/DataGovernanceTests.ts @@ -293,7 +293,7 @@ export class FakeConfigServiceBase extends ConfigServiceBase { getConfig(): Promise { return Promise.resolve(ProjectConfig.empty); } refreshLogicAsync(): Promise<[FetchResult, ProjectConfig]> { - return this.refreshConfigCoreAsync(ProjectConfig.empty); + return this.refreshConfigCoreAsync(ProjectConfig.empty, false); } prepareResponse(baseUrl: string, jsonBaseUrl: string, jsonRedirect: number, jsonFeatureFlags: any): void { diff --git a/test/browser/HttpTests.ts b/test/browser/HttpTests.ts index f3b9359..bd8a30a 100644 --- a/test/browser/HttpTests.ts +++ b/test/browser/HttpTests.ts @@ -3,6 +3,7 @@ import * as mockxmlhttprequest from "mock-xmlhttprequest"; import { FakeLogger } from "../helpers/fakes"; import { platform } from "."; import { LogLevel } from "#lib"; +import { getMonotonicTimeMs } from "#lib/Utils"; describe("HTTP tests", () => { const sdkKey = "configcat-sdk-1/PKDVCLf-Hq-h-kCzMp-L7Q/AG6C1ngVb0CvM07un6JisQ"; @@ -25,9 +26,9 @@ describe("HTTP tests", () => { baseUrl, logger, }); - const startTime = new Date().getTime(); + const startTime = getMonotonicTimeMs(); await client.forceRefreshAsync(); - const duration = new Date().getTime() - startTime; + const duration = getMonotonicTimeMs() - startTime; assert.isTrue(duration > 1000 && duration < 2000); const defaultValue = "NOT_CAT"; diff --git a/test/chromium-extension/HttpTests.ts b/test/chromium-extension/HttpTests.ts index beb76e6..8ca50a8 100644 --- a/test/chromium-extension/HttpTests.ts +++ b/test/chromium-extension/HttpTests.ts @@ -3,6 +3,7 @@ import fetchMock from "fetch-mock"; import { FakeLogger } from "../helpers/fakes"; import { platform } from "."; import { LogLevel } from "#lib"; +import { getMonotonicTimeMs } from "#lib/Utils"; describe("HTTP tests", () => { const sdkKey = "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A"; @@ -23,9 +24,9 @@ describe("HTTP tests", () => { baseUrl, logger, }); - const startTime = new Date().getTime(); + const startTime = getMonotonicTimeMs(); await client.forceRefreshAsync(); - const duration = new Date().getTime() - startTime; + const duration = getMonotonicTimeMs() - startTime; assert.isTrue(duration > 1000 && duration < 2000); const defaultValue = "NOT_CAT"; diff --git a/test/helpers/fakes.ts b/test/helpers/fakes.ts index 04d046e..e49978e 100644 --- a/test/helpers/fakes.ts +++ b/test/helpers/fakes.ts @@ -71,7 +71,7 @@ export class FakeCache implements IConfigCache { } export class FakeExternalCache implements IConfigCatCache { - private cachedValue: string | undefined; + cachedValue: string | undefined; set(key: string, value: string): void { this.cachedValue = value; @@ -82,8 +82,17 @@ export class FakeExternalCache implements IConfigCatCache { } } +export class FaultyFakeExternalCache implements IConfigCatCache { + set(key: string, value: string): never { + throw new Error("Operation failed :("); + } + get(key: string): never { + throw new Error("Operation failed :("); + } +} + export class FakeExternalAsyncCache implements IConfigCatCache { - private cachedValue: string | undefined; + cachedValue: string | undefined; constructor(private readonly delayMs = 0) { } @@ -111,7 +120,7 @@ export class FakeExternalCacheWithInitialData implements IConfigCatCache { } get(key: string): string | Promise | null | undefined { const cachedJson = '{"f":{"debug":{"t":0,"v":{"b":true},"i":"abcdefgh"}}}'; - const config = new ProjectConfig(cachedJson, JSON.parse(cachedJson), (new Date().getTime()) - this.expirationDelta, "\"ETAG\""); + const config = new ProjectConfig(cachedJson, JSON.parse(cachedJson), ProjectConfig.generateTimestamp() - this.expirationDelta, "\"ETAG\""); return ProjectConfig.serialize(config); } @@ -137,9 +146,11 @@ export class FakeConfigFetcherBase implements IConfigFetcher { this.config = fr.body ?? null; return fr; } - : () => this.config !== null - ? { statusCode: 200, reasonPhrase: "OK", eTag: this.getEtag(), body: this.config } as IFetchResponse - : { statusCode: 404, reasonPhrase: "Not Found" } as IFetchResponse; + : () => { + return this.config === null ? { statusCode: 404, reasonPhrase: "Not Found" } as IFetchResponse + : this.getEtag() === lastEtag ? { statusCode: 304, reasonPhrase: "Not Modified" } as IFetchResponse + : { statusCode: 200, reasonPhrase: "OK", eTag: this.getEtag(), body: this.config } as IFetchResponse; + }; await delay(this.callbackDelay); diff --git a/test/node/HttpTests.ts b/test/node/HttpTests.ts index c73cbaf..abdf95d 100644 --- a/test/node/HttpTests.ts +++ b/test/node/HttpTests.ts @@ -3,6 +3,7 @@ import * as mockttp from "mockttp"; import { FakeLogger } from "../helpers/fakes"; import { platform } from "."; import { LogLevel } from "#lib"; +import { getMonotonicTimeMs } from "#lib/Utils"; // If the tests are failing with strange https or proxy errors, it is most likely that the local .key and .pem files are expired. // You can regenerate them anytime (./test/cert/regenerate.md). @@ -31,9 +32,9 @@ describe("HTTP tests", () => { baseUrl: server.url, logger, }); - const startTime = new Date().getTime(); + const startTime = getMonotonicTimeMs(); await client.forceRefreshAsync(); - const duration = new Date().getTime() - startTime; + const duration = getMonotonicTimeMs() - startTime; assert.isTrue(duration > 1000 && duration < 2000); const defaultValue = "NOT_CAT";