Skip to content

Commit 0931811

Browse files
authored
Prepare v9.4.0 release (#112)
* Take another approach to avoid errors when options is logged while containing circular references + prevent internals leaking into the log * Fix a minor bug in setting type mismatch check (type mismatch should also be reported for allowed flag override values) * Fix a minor bug in FetchError (name property should be set to the class name) * Fix a minor bug in formatting errors (some JS runtimes doesn't include the error message in the stack trace) * Fix edge case bug in parseFloatStrict * Use monotonic clock for scheduling poll iterations in Auto Poll mode for improved precision and resistance to system/user clock adjustments * Use monotonic clock in tests for measuring elapsed time * Refresh internal cache in offline mode too + don't report failure when refreshing in offline mode if an external cache is configured * Make clientReady consistent with other SDKs in Auto Poll + offline mode * Eliminate redundant sync with external cache in AutoPollConfigService.getConfig + improve clientReady * Eliminate race condition between initial and user-initiated cache sync ups * Also report configCached when new config is synced from external cache * Deduplicate config refresh instead of fetch operation only * Correct intellisense docs of a few config service-related APIs * Include SDK Key in error fetchFailedDueToInvalidSdkKey (1100) + mask SDK Key except for the last 6 characters * npm run lint:fix * Bump version
1 parent b0d5456 commit 0931811

21 files changed

+768
-231
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "configcat-common",
3-
"version": "9.3.1",
3+
"version": "9.4.0",
44
"description": "ConfigCat is a configuration as a service that lets you manage your features and configurations without actually deploying new code.",
55
"main": "lib/index.js",
66
"types": "lib/index.d.ts",

src/AutoPollConfigService.ts

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import type { AutoPollOptions } from "./ConfigCatClientOptions";
2-
import type { LoggerWrapper } from "./ConfigCatLogger";
32
import type { IConfigFetcher } from "./ConfigFetcher";
43
import type { IConfigService, RefreshResult } from "./ConfigServiceBase";
54
import { ClientCacheState, ConfigServiceBase } from "./ConfigServiceBase";
65
import type { ProjectConfig } from "./ProjectConfig";
7-
import { AbortToken, delay } from "./Utils";
6+
import { AbortToken, delay, getMonotonicTimeMs } from "./Utils";
87

98
export const POLL_EXPIRATION_TOLERANCE_MS = 500;
109

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

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

4040
// This promise will be resolved when either initialization ready is signalled by signalInitialization() or maxInitWaitTimeSeconds pass.
@@ -53,9 +53,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
5353
return this.getCacheState(this.options.cache.getInMemory());
5454
});
5555

56-
if (!options.offline) {
57-
this.startRefreshWorker(initialCacheSyncUp, this.stopToken);
58-
}
56+
this.startRefreshWorker(initialCacheSyncUp, this.stopToken);
5957
}
6058

6159
private async waitForInitializationAsync(initSignalPromise: Promise<void>): Promise<boolean> {
@@ -76,30 +74,22 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
7674
async getConfig(): Promise<ProjectConfig> {
7775
this.options.logger.debug("AutoPollConfigService.getConfig() called.");
7876

79-
function logSuccess(logger: LoggerWrapper) {
80-
logger.debug("AutoPollConfigService.getConfig() - returning value from cache.");
81-
}
82-
83-
let cachedConfig: ProjectConfig;
84-
if (!this.isOffline && !this.initialized) {
85-
cachedConfig = await this.options.cache.get(this.cacheKey);
86-
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
87-
logSuccess(this.options.logger);
88-
return cachedConfig;
89-
}
77+
let cachedConfig = await this.syncUpWithCache();
9078

79+
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
80+
this.signalInitialization();
81+
}
82+
else if (!this.isOffline && !this.initialized) {
9183
this.options.logger.debug("AutoPollConfigService.getConfig() - cache is empty or expired, waiting for initialization.");
9284
await this.initializationPromise;
93-
}
94-
95-
cachedConfig = await this.options.cache.get(this.cacheKey);
96-
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
97-
logSuccess(this.options.logger);
85+
cachedConfig = this.options.cache.getInMemory();
9886
}
9987
else {
10088
this.options.logger.debug("AutoPollConfigService.getConfig() - cache is empty or expired.");
89+
return cachedConfig;
10190
}
10291

92+
this.options.logger.debug("AutoPollConfigService.getConfig() - returning value from cache.");
10393
return cachedConfig;
10494
}
10595

@@ -121,30 +111,28 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
121111
this.signalInitialization();
122112
}
123113

124-
protected setOnlineCore(): void {
125-
this.startRefreshWorker(null, this.stopToken);
126-
}
127-
128-
protected setOfflineCore(): void {
114+
protected goOnline(): void {
115+
// We need to restart the polling loop because going from offline to online should trigger a refresh operation
116+
// immediately instead of waiting for the next tick (which might not happen until much later).
129117
this.stopRefreshWorker();
130118
this.stopToken = new AbortToken();
119+
this.startRefreshWorker(null, this.stopToken);
131120
}
132121

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

136-
let isFirstIteration = true;
137125
while (!stopToken.aborted) {
138126
try {
139-
const scheduledNextTimeMs = new Date().getTime() + this.pollIntervalMs;
127+
const scheduledNextTimeMs = getMonotonicTimeMs() + this.pollIntervalMs;
140128
try {
141-
await this.refreshWorkerLogic(isFirstIteration, initialCacheSyncUp);
129+
await this.refreshWorkerLogic(initialCacheSyncUp);
142130
}
143131
catch (err) {
144132
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
145133
}
146134

147-
const realNextTimeMs = scheduledNextTimeMs - new Date().getTime();
135+
const realNextTimeMs = scheduledNextTimeMs - getMonotonicTimeMs();
148136
if (realNextTimeMs > 0) {
149137
await delay(realNextTimeMs, stopToken);
150138
}
@@ -153,7 +141,6 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
153141
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
154142
}
155143

156-
isFirstIteration = false;
157144
initialCacheSyncUp = null; // allow GC to collect the Promise and its result
158145
}
159146
}
@@ -163,24 +150,24 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
163150
this.stopToken.abort();
164151
}
165152

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

169-
const latestConfig = await (initialCacheSyncUp ?? this.options.cache.get(this.cacheKey));
156+
const latestConfig = await (initialCacheSyncUp ?? this.syncUpWithCache());
170157
if (latestConfig.isExpired(this.pollExpirationMs)) {
171158
// Even if the service gets disposed immediately, we allow the first refresh for backward compatibility,
172159
// i.e. to not break usage patterns like this:
173160
// ```
174-
// client.getValueAsync("SOME_KEY", false).then(value => { /* ... */ }, user);
161+
// client.getValueAsync("SOME_KEY", false, user).then(value => { /* ... */ });
175162
// client.dispose();
176163
// ```
177-
if (isFirstIteration ? !this.isOfflineExactly : !this.isOffline) {
164+
if (initialCacheSyncUp ? !this.isOfflineExactly : !this.isOffline) {
178165
await this.refreshConfigCoreAsync(latestConfig);
166+
return; // postpone signalling initialization until `onConfigFetched`
179167
}
180168
}
181-
else if (isFirstIteration) {
182-
this.signalInitialization();
183-
}
169+
170+
this.signalInitialization();
184171
}
185172

186173
getCacheState(cachedConfig: ProjectConfig): ClientCacheState {

src/ConfigCatCache.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ export interface IConfigCatCache {
1919
get(key: string): Promise<string | null | undefined> | string | null | undefined;
2020
}
2121

22+
/** @remarks Unchanged config is returned as is, changed config is wrapped in an array so we can distinguish between the two cases. */
23+
export type CacheSyncResult = ProjectConfig | [changedConfig: ProjectConfig];
24+
2225
export interface IConfigCache {
2326
set(key: string, config: ProjectConfig): Promise<void> | void;
2427

25-
get(key: string): Promise<ProjectConfig> | ProjectConfig;
28+
get(key: string): Promise<CacheSyncResult> | CacheSyncResult;
2629

2730
getInMemory(): ProjectConfig;
2831
}
@@ -59,7 +62,7 @@ export class ExternalConfigCache implements IConfigCache {
5962
this.cachedConfig = config;
6063
}
6164
else {
62-
// We may have empty entries with timestamp > 0 (see the flooding prevention logic in ConfigServiceBase.fetchLogicAsync).
65+
// We may have empty entries with timestamp > 0 (see the flooding prevention logic in ConfigServiceBase.fetchAsync).
6366
// In such cases we want to preserve the timestamp locally but don't want to store those entries into the external cache.
6467
this.cachedSerializedConfig = void 0;
6568
this.cachedConfig = config;
@@ -73,41 +76,51 @@ export class ExternalConfigCache implements IConfigCache {
7376
}
7477
}
7578

76-
private updateCachedConfig(externalSerializedConfig: string | null | undefined): void {
79+
private updateCachedConfig(externalSerializedConfig: string | null | undefined): CacheSyncResult {
7780
if (externalSerializedConfig == null || externalSerializedConfig === this.cachedSerializedConfig) {
78-
return;
81+
return this.cachedConfig;
7982
}
8083

81-
this.cachedConfig = ProjectConfig.deserialize(externalSerializedConfig);
84+
const externalConfig = ProjectConfig.deserialize(externalSerializedConfig);
85+
const hasChanged = !ProjectConfig.contentEquals(externalConfig, this.cachedConfig);
86+
this.cachedConfig = externalConfig;
8287
this.cachedSerializedConfig = externalSerializedConfig;
88+
return hasChanged ? [this.cachedConfig] : this.cachedConfig;
8389
}
8490

85-
get(key: string): Promise<ProjectConfig> {
91+
get(key: string): Promise<CacheSyncResult> | CacheSyncResult {
92+
let cacheSyncResult: CacheSyncResult;
93+
8694
try {
8795
const cacheGetResult = this.cache.get(key);
8896

8997
// Take the async path only when the IConfigCatCache.get operation is asynchronous.
9098
if (isPromiseLike(cacheGetResult)) {
9199
return (async (cacheGetPromise) => {
100+
let cacheSyncResult: CacheSyncResult;
101+
92102
try {
93-
this.updateCachedConfig(await cacheGetPromise);
103+
cacheSyncResult = this.updateCachedConfig(await cacheGetPromise);
94104
}
95105
catch (err) {
106+
cacheSyncResult = this.cachedConfig;
96107
this.logger.configServiceCacheReadError(err);
97108
}
98-
return this.cachedConfig;
109+
110+
return cacheSyncResult;
99111
})(cacheGetResult);
100112
}
101113

102114
// Otherwise, keep the code flow synchronous so the config services can sync up
103115
// with the cache in their ctors synchronously (see ConfigServiceBase.syncUpWithCache).
104-
this.updateCachedConfig(cacheGetResult);
116+
cacheSyncResult = this.updateCachedConfig(cacheGetResult);
105117
}
106118
catch (err) {
119+
cacheSyncResult = this.cachedConfig;
107120
this.logger.configServiceCacheReadError(err);
108121
}
109122

110-
return Promise.resolve(this.cachedConfig);
123+
return cacheSyncResult;
111124
}
112125

113126
getInMemory(): ProjectConfig {

src/ConfigCatClient.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import type { IConfigCache } from "./ConfigCatCache";
33
import type { ConfigCatClientOptions, OptionsBase, OptionsForPollingMode } from "./ConfigCatClientOptions";
44
import { AutoPollOptions, LazyLoadOptions, ManualPollOptions, PollingMode } from "./ConfigCatClientOptions";
55
import type { LoggerWrapper } from "./ConfigCatLogger";
6+
import { LogLevel } from "./ConfigCatLogger";
67
import type { IConfigFetcher } from "./ConfigFetcher";
78
import type { IConfigService } from "./ConfigServiceBase";
89
import { ClientCacheState, RefreshResult } from "./ConfigServiceBase";
910
import type { IEventEmitter } from "./EventEmitter";
11+
import type { FlagOverrides } from "./FlagOverrides";
1012
import { OverrideBehaviour } from "./FlagOverrides";
1113
import type { HookEvents, Hooks, IProvidesHooks } from "./Hooks";
1214
import { LazyLoadConfigService } from "./LazyLoadConfigService";
@@ -16,7 +18,8 @@ import type { IConfig, PercentageOption, ProjectConfig, Setting, SettingValue }
1618
import type { IEvaluationDetails, IRolloutEvaluator, SettingTypeOf } from "./RolloutEvaluator";
1719
import { RolloutEvaluator, checkSettingsAvailable, evaluate, evaluateAll, evaluationDetailsFromDefaultValue, getTimestampAsDate, handleInvalidReturnValue, isAllowedValue } from "./RolloutEvaluator";
1820
import type { User } from "./User";
19-
import { errorToString, isArray, stringifyCircularJSON, throwError } from "./Utils";
21+
import { getUserAttributes } from "./User";
22+
import { errorToString, isArray, isObject, shallowClone, throwError } from "./Utils";
2023

2124
/** ConfigCat SDK client. */
2225
export interface IConfigCatClient extends IProvidesHooks {
@@ -79,20 +82,38 @@ export interface IConfigCatClient extends IProvidesHooks {
7982
getKeyAndValueAsync(variationId: string): Promise<SettingKeyValue | null>;
8083

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

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

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

@@ -118,7 +139,7 @@ export interface IConfigCatClient extends IProvidesHooks {
118139
setOnline(): void;
119140

120141
/**
121-
* Configures the client to not initiate HTTP requests and work using the locally cached config only.
142+
* Configures the client to not initiate HTTP requests but work using the cache only.
122143
*/
123144
setOffline(): void;
124145

@@ -130,9 +151,10 @@ export interface IConfigCatClient extends IProvidesHooks {
130151

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

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

138160
/**
@@ -280,7 +302,9 @@ export class ConfigCatClient implements IConfigCatClient {
280302

281303
this.options = options;
282304

283-
this.options.logger.debug("Initializing ConfigCatClient. Options: " + stringifyCircularJSON(this.options));
305+
if (options.logger.isEnabled(LogLevel.Debug)) {
306+
options.logger.debug("Initializing ConfigCatClient. Options: " + JSON.stringify(getSerializableOptions(options)));
307+
}
284308

285309
if (!configCatKernel) {
286310
throw new Error("Invalid 'configCatKernel' value");
@@ -551,7 +575,7 @@ export class ConfigCatClient implements IConfigCatClient {
551575
}
552576
}
553577
else {
554-
return RefreshResult.failure("Client is configured to use the LocalOnly override behavior, which prevents making HTTP requests.");
578+
return RefreshResult.failure("Client is configured to use the LocalOnly override behavior, which prevents synchronization with external cache and making HTTP requests.");
555579
}
556580
}
557581

@@ -798,6 +822,19 @@ function ensureAllowedValue(value: NonNullable<SettingValue>): NonNullable<Setti
798822
return isAllowedValue(value) ? value : handleInvalidReturnValue(value);
799823
}
800824

825+
export function getSerializableOptions(options: ConfigCatClientOptions): Record<string, unknown> {
826+
return shallowClone(options, (key, value) => {
827+
if (key === "defaultUser") {
828+
return getUserAttributes(value as User);
829+
}
830+
if (key === "flagOverrides") {
831+
return shallowClone(value as FlagOverrides, (_, value) => isObject(value) ? value.toString() : value);
832+
}
833+
// NOTE: Prevent internals from leaking into logs and avoid errors because of circular references in user-provided objects.
834+
return isObject(value) ? value.toString() : value;
835+
});
836+
}
837+
801838
/* GC finalization support */
802839

803840
// Defines the interface of the held value which is passed to ConfigCatClient.finalize by FinalizationRegistry.

0 commit comments

Comments
 (0)