Skip to content

Commit 18edcbd

Browse files
committed
Also report configCached when new config is synced from external cache
1 parent 0426ceb commit 18edcbd

File tree

4 files changed

+137
-18
lines changed

4 files changed

+137
-18
lines changed

src/ConfigCatCache.ts

Lines changed: 19 additions & 8 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
}
@@ -71,39 +74,47 @@ export class ExternalConfigCache implements IConfigCache {
7174
}
7275
}
7376

74-
private updateCachedConfig(externalSerializedConfig: string | null | undefined): void {
77+
private updateCachedConfig(externalSerializedConfig: string | null | undefined): CacheSyncResult {
7578
if (externalSerializedConfig == null || externalSerializedConfig === this.cachedSerializedConfig) {
76-
return;
79+
return this.cachedConfig;
7780
}
7881

7982
this.cachedConfig = ProjectConfig.deserialize(externalSerializedConfig);
8083
this.cachedSerializedConfig = externalSerializedConfig;
84+
return [this.cachedConfig];
8185
}
8286

83-
get(key: string): Promise<ProjectConfig> {
87+
get(key: string): Promise<CacheSyncResult> | CacheSyncResult {
88+
let cacheSyncResult: CacheSyncResult;
89+
8490
try {
8591
const cacheGetResult = this.cache.get(key);
8692

8793
// Take the async path only when the IConfigCatCache.get operation is asynchronous.
8894
if (isPromiseLike(cacheGetResult)) {
8995
return (async (cacheGetPromise) => {
96+
let cacheSyncResult: CacheSyncResult;
97+
9098
try {
91-
this.updateCachedConfig(await cacheGetPromise);
99+
cacheSyncResult = this.updateCachedConfig(await cacheGetPromise);
92100
} catch (err) {
101+
cacheSyncResult = this.cachedConfig;
93102
this.logger.configServiceCacheReadError(err);
94103
}
95-
return this.cachedConfig;
104+
105+
return cacheSyncResult;
96106
})(cacheGetResult);
97107
}
98108

99109
// Otherwise, keep the code flow synchronous so the config services can sync up
100110
// with the cache in their ctors synchronously (see ConfigServiceBase.syncUpWithCache).
101-
this.updateCachedConfig(cacheGetResult);
111+
cacheSyncResult = this.updateCachedConfig(cacheGetResult);
102112
} catch (err) {
113+
cacheSyncResult = this.cachedConfig;
103114
this.logger.configServiceCacheReadError(err);
104115
}
105116

106-
return Promise.resolve(this.cachedConfig);
117+
return cacheSyncResult;
107118
}
108119

109120
getInMemory(): ProjectConfig {

src/ConfigServiceBase.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CacheSyncResult } from "./ConfigCatCache";
12
import { ExternalConfigCache } from "./ConfigCatCache";
23
import type { OptionsBase } from "./ConfigCatClientOptions";
34
import type { FetchErrorCauses, IConfigFetcher, IFetchResponse } from "./ConfigFetcher";
@@ -308,16 +309,31 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
308309

309310
const syncResult = this.options.cache.get(this.cacheKey);
310311
if (!isPromiseLike(syncResult)) {
311-
return syncResult;
312+
return this.onCacheSynced(syncResult);
312313
}
313314

314315
const syncUpAndFinish = syncResult
315-
.finally(() => this.pendingCacheSyncUp = null);
316+
.finally(() => this.pendingCacheSyncUp = null)
317+
.then(syncResult => this.onCacheSynced(syncResult));
318+
319+
this.pendingCacheSyncUp = syncResult
320+
.then(syncResult => !Array.isArray(syncResult) ? syncResult : syncResult[0]);
316321

317-
this.pendingCacheSyncUp = syncResult;
318322
return syncUpAndFinish;
319323
}
320324

325+
private onCacheSynced(syncResult: CacheSyncResult): ProjectConfig {
326+
if (!Array.isArray(syncResult)) {
327+
return syncResult;
328+
}
329+
330+
const [newConfig] = syncResult;
331+
if (!newConfig.isEmpty) {
332+
this.onConfigChanged(newConfig);
333+
}
334+
return newConfig;
335+
}
336+
321337
protected async waitForReadyAsync(initialCacheSyncUp: ProjectConfig | Promise<ProjectConfig>): Promise<ClientCacheState> {
322338
return this.getCacheState(await initialCacheSyncUp);
323339
}

src/Hooks.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ export type HookEvents = {
1919
clientReady: [cacheState: ClientCacheState];
2020
/** Occurs after the value of a feature flag of setting has been evaluated. */
2121
flagEvaluated: [evaluationDetails: IEvaluationDetails];
22-
/** Occurs after the locally cached config has been updated. */
22+
/**
23+
* Occurs after the locally cached config has been updated to a newer version, either as a result of synchronization
24+
* with the external cache, or as a result of fetching a newer version from the remote server.
25+
*/
2326
configChanged: [newConfig: IConfig];
2427
/** Occurs in the case of a failure in the client. */
2528
clientError: [message: string, exception?: any];

test/ConfigServiceBaseTests.ts

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { assert } from "chai";
33
import { EqualMatchingInjectorConfig, It, Mock, RejectedPromiseFactory, ResolvedPromiseFactory, Times } from "moq.ts";
44
import { MimicsRejectedAsyncPresetFactory, MimicsResolvedAsyncPresetFactory, Presets, ReturnsAsyncPresetFactory, RootMockProvider, ThrowsAsyncPresetFactory } from "moq.ts/internal";
55
/* eslint-enable import/no-duplicates */
6-
import { createAutoPollOptions, createKernel, createLazyLoadOptions, createManualPollOptions, FakeCache, FakeExternalCache, FakeLogger } from "./helpers/fakes";
6+
import { createAutoPollOptions, createKernel, createLazyLoadOptions, createManualPollOptions, FakeCache, FakeExternalAsyncCache, FakeExternalCache, FakeLogger } from "./helpers/fakes";
77
import { ClientCacheState } from "#lib";
88
import { AutoPollConfigService, POLL_EXPIRATION_TOLERANCE_MS } from "#lib/AutoPollConfigService";
99
import { ExternalConfigCache, IConfigCache, InMemoryConfigCache } from "#lib/ConfigCatCache";
@@ -12,7 +12,7 @@ import { LoggerWrapper } from "#lib/ConfigCatLogger";
1212
import { FetchResult, IConfigFetcher, IFetchResponse } from "#lib/ConfigFetcher";
1313
import { LazyLoadConfigService } from "#lib/LazyLoadConfigService";
1414
import { ManualPollConfigService } from "#lib/ManualPollConfigService";
15-
import { Config, ProjectConfig } from "#lib/ProjectConfig";
15+
import { Config, IConfig, ProjectConfig } from "#lib/ProjectConfig";
1616
import { AbortToken, delay } from "#lib/Utils";
1717

1818
describe("ConfigServiceBaseTests", () => {
@@ -369,6 +369,93 @@ describe("ConfigServiceBaseTests", () => {
369369
});
370370
}
371371

372+
for (const useSyncCache of [false, true]) {
373+
it(`AutoPollConfigService - Should refresh local cache in offline mode and report configChanged when new config is synced from external cache - useSyncCache: ${useSyncCache}`, async () => {
374+
375+
// Arrange
376+
377+
const pollIntervalSeconds = 1;
378+
379+
const logger = new LoggerWrapper(new FakeLogger());
380+
const fakeExternalCache = useSyncCache ? new FakeExternalCache() : new FakeExternalAsyncCache(50);
381+
const cache = new ExternalConfigCache(fakeExternalCache, logger);
382+
383+
const clientReadyEvents: ClientCacheState[] = [];
384+
const configChangedEvents: IConfig[] = [];
385+
386+
const options = createAutoPollOptions(
387+
"APIKEY",
388+
{
389+
pollIntervalSeconds,
390+
setupHooks: hooks => {
391+
hooks.on("clientReady", cacheState => clientReadyEvents.push(cacheState));
392+
hooks.on("configChanged", config => configChangedEvents.push(config));
393+
},
394+
},
395+
createKernel({ defaultCacheFactory: () => cache })
396+
);
397+
398+
const fr: FetchResult = createFetchResult();
399+
const fetcherMock = new Mock<IConfigFetcher>()
400+
.setup(m => m.fetchLogic(It.IsAny<OptionsBase>(), It.IsAny<string>()))
401+
.returnsAsync({ statusCode: 200, reasonPhrase: "OK", eTag: fr.config.httpETag, body: fr.config.configJson });
402+
403+
// Act
404+
405+
const service: AutoPollConfigService = new AutoPollConfigService(
406+
fetcherMock.object(),
407+
options);
408+
409+
assert.isUndefined(fakeExternalCache.cachedValue);
410+
411+
assert.isEmpty(clientReadyEvents);
412+
assert.isEmpty(configChangedEvents);
413+
414+
await service.readyPromise;
415+
416+
const getConfigPromise = service.getConfig();
417+
await service.getConfig(); // simulate concurrent cache sync up
418+
await getConfigPromise;
419+
420+
await delay(100); // allow a little time for the client to raise ConfigChanged
421+
422+
assert.isDefined(fakeExternalCache.cachedValue);
423+
424+
assert.strictEqual(1, clientReadyEvents.length);
425+
assert.strictEqual(ClientCacheState.HasUpToDateFlagData, clientReadyEvents[0]);
426+
assert.strictEqual(1, configChangedEvents.length);
427+
assert.strictEqual(JSON.stringify(fr.config.config), JSON.stringify(configChangedEvents[0]));
428+
429+
fetcherMock.verify(m => m.fetchLogic(It.IsAny<OptionsBase>(), It.IsAny<string>()), Times.Once());
430+
431+
service.setOffline(); // no HTTP fetching from this point on
432+
433+
await delay(pollIntervalSeconds * 1000 + 50);
434+
435+
assert.strictEqual(1, clientReadyEvents.length);
436+
assert.strictEqual(1, configChangedEvents.length);
437+
438+
const fr2: FetchResult = createFetchResult("etag2", "{}");
439+
const projectConfig2 = createConfigFromFetchResult(fr2);
440+
fakeExternalCache.cachedValue = ProjectConfig.serialize(projectConfig2);
441+
442+
const [refreshResult, projectConfigFromRefresh] = await service.refreshConfigAsync();
443+
444+
// Assert
445+
446+
assert.isTrue(refreshResult.isSuccess);
447+
448+
assert.strictEqual(1, clientReadyEvents.length);
449+
assert.strictEqual(2, configChangedEvents.length);
450+
assert.strictEqual(JSON.stringify(fr2.config.config), JSON.stringify(configChangedEvents[1]));
451+
assert.strictEqual(configChangedEvents[1], projectConfigFromRefresh.config);
452+
453+
// Cleanup
454+
455+
service.dispose();
456+
});
457+
}
458+
372459
it("LazyLoadConfigService - ProjectConfig is different in the cache - should fetch a new config and put into cache", async () => {
373460

374461
// Arrange
@@ -836,17 +923,19 @@ describe("ConfigServiceBaseTests", () => {
836923
});
837924
});
838925

839-
function createProjectConfig(eTag = "etag"): ProjectConfig {
840-
const configJson = "{\"f\": { \"debug\": { \"v\": { \"b\": true }, \"i\": \"abcdefgh\", \"t\": 0, \"p\": [], \"r\": [] } } }";
926+
const DEFAULT_ETAG = "etag";
927+
const DEFAULT_CONFIG_JSON = '{"f": { "debug": { "v": { "b": true }, "i": "abcdefgh", "t": 0, "p": [], "r": [] } } }';
928+
929+
function createProjectConfig(eTag = DEFAULT_ETAG, configJson = DEFAULT_CONFIG_JSON): ProjectConfig {
841930
return new ProjectConfig(
842931
configJson,
843932
Config.deserialize(configJson),
844933
1,
845934
eTag);
846935
}
847936

848-
function createFetchResult(eTag = "etag"): FetchResult {
849-
return FetchResult.success(createProjectConfig(eTag));
937+
function createFetchResult(eTag = DEFAULT_ETAG, configJson = DEFAULT_CONFIG_JSON): FetchResult {
938+
return FetchResult.success(createProjectConfig(eTag, configJson));
850939
}
851940

852941
function createConfigFromFetchResult(result: FetchResult): ProjectConfig {

0 commit comments

Comments
 (0)