Skip to content

Commit 049bfa4

Browse files
committed
Refresh internal cache in offline mode too
1 parent b6f64c7 commit 049bfa4

File tree

7 files changed

+162
-62
lines changed

7 files changed

+162
-62
lines changed

src/AutoPollConfigService.ts

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
3232
if (options.maxInitWaitTimeSeconds !== 0) {
3333
this.initialized = false;
3434

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).
35+
// This promise will be resolved as soon as
36+
// 1. an up-to-date config is obtained from the cache (see startRefreshWorker),
37+
// 2. or a config fetch operation completes, regardless of success or failure (see onConfigUpdated).
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.
@@ -49,9 +49,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
4949

5050
this.readyPromise = this.getReadyPromise(initialCacheSyncUp);
5151

52-
if (!options.offline) {
53-
this.startRefreshWorker(initialCacheSyncUp, this.stopToken);
54-
}
52+
this.startRefreshWorker(initialCacheSyncUp, this.stopToken);
5553
}
5654

5755
private async waitForInitializationAsync(initSignalPromise: Promise<void>): Promise<boolean> {
@@ -82,7 +80,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
8280
}
8381

8482
let cachedConfig: ProjectConfig;
85-
if (!this.isOffline && !this.initialized) {
83+
if (!this.initialized) {
8684
cachedConfig = await this.options.cache.get(this.cacheKey);
8785
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
8886
logSuccess(this.options.logger);
@@ -121,24 +119,22 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
121119
super.onConfigFetched(newConfig);
122120
}
123121

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

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

136-
let isFirstIteration = true;
137133
while (!stopToken.aborted) {
138134
try {
139135
const scheduledNextTimeMs = getMonotonicTimeMs() + this.pollIntervalMs;
140136
try {
141-
await this.refreshWorkerLogic(isFirstIteration, initialCacheSyncUp);
137+
await this.refreshWorkerLogic(initialCacheSyncUp);
142138
} catch (err) {
143139
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
144140
}
@@ -151,7 +147,6 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
151147
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
152148
}
153149

154-
isFirstIteration = false;
155150
initialCacheSyncUp = null; // allow GC to collect the Promise and its result
156151
}
157152
}
@@ -161,22 +156,24 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
161156
this.stopToken.abort();
162157
}
163158

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

167162
const latestConfig = await (initialCacheSyncUp ?? this.options.cache.get(this.cacheKey));
168-
if (latestConfig.isExpired(this.pollExpirationMs)) {
163+
if (!latestConfig.isExpired(this.pollExpirationMs)) {
164+
this.signalInitialization();
165+
return;
166+
}
167+
168+
if (initialCacheSyncUp ? !this.isOfflineExactly : !this.isOffline) {
169169
// Even if the service gets disposed immediately, we allow the first refresh for backward compatibility,
170170
// i.e. to not break usage patterns like this:
171171
// ```
172-
// client.getValueAsync("SOME_KEY", false).then(value => { /* ... */ }, user);
172+
// client.getValueAsync("SOME_KEY", false, user).then(value => { /* ... */ });
173173
// client.dispose();
174174
// ```
175-
if (isFirstIteration ? !this.isOfflineExactly : !this.isOffline) {
176-
await this.refreshConfigCoreAsync(latestConfig);
177-
}
178-
} else if (isFirstIteration) {
179-
this.signalInitialization();
175+
176+
await this.refreshConfigCoreAsync(latestConfig);
180177
}
181178
}
182179

src/ConfigServiceBase.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ExternalConfigCache } from "./ConfigCatCache";
12
import type { OptionsBase } from "./ConfigCatClientOptions";
23
import type { FetchErrorCauses, IConfigFetcher, IFetchResponse } from "./ConfigFetcher";
34
import { FetchError, FetchResult, FetchStatus } from "./ConfigFetcher";
@@ -106,6 +107,8 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
106107
if (!this.isOffline) {
107108
const [fetchResult, config] = await this.refreshConfigCoreAsync(latestConfig);
108109
return [RefreshResult.from(fetchResult), config];
110+
} else if (this.options.cache instanceof ExternalConfigCache) {
111+
return [RefreshResult.success(), latestConfig];
109112
} else {
110113
const errorMessage = this.options.logger.configServiceCannotInitiateHttpCalls().toString();
111114
return [RefreshResult.failure(errorMessage), latestConfig];
@@ -155,7 +158,7 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
155158

156159
private async fetchLogicAsync(lastConfig: ProjectConfig): Promise<FetchResult> {
157160
const options = this.options;
158-
options.logger.debug("ConfigServiceBase.fetchLogicAsync() - called.");
161+
options.logger.debug("ConfigServiceBase.fetchLogicAsync() called.");
159162

160163
let errorMessage: string;
161164
try {
@@ -207,7 +210,7 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
207210
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
208211
private async fetchRequestAsync(lastETag: string | null, maxRetryCount = 2): Promise<[IFetchResponse, (Config | any)?]> {
209212
const options = this.options;
210-
options.logger.debug("ConfigServiceBase.fetchRequestAsync() - called.");
213+
options.logger.debug("ConfigServiceBase.fetchRequestAsync() called.");
211214

212215
for (let retryNumber = 0; ; retryNumber++) {
213216
options.logger.debug(`ConfigServiceBase.fetchRequestAsync(): calling fetchLogic()${retryNumber > 0 ? `, retry ${retryNumber}/${maxRetryCount}` : ""}`);
@@ -278,23 +281,20 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
278281
return this.status !== ConfigServiceStatus.Online;
279282
}
280283

281-
protected setOnlineCore(): void { /* Intentionally empty. */ }
284+
protected goOnline(): void { /* Intentionally empty. */ }
282285

283286
setOnline(): void {
284287
if (this.status === ConfigServiceStatus.Offline) {
285-
this.setOnlineCore();
288+
this.goOnline();
286289
this.status = ConfigServiceStatus.Online;
287290
this.options.logger.configServiceStatusChanged(nameOfConfigServiceStatus(this.status));
288291
} else if (this.disposed) {
289292
this.options.logger.configServiceMethodHasNoEffectDueToDisposedClient("setOnline");
290293
}
291294
}
292295

293-
protected setOfflineCore(): void { /* Intentionally empty. */ }
294-
295296
setOffline(): void {
296297
if (this.status === ConfigServiceStatus.Online) {
297-
this.setOfflineCore();
298298
this.status = ConfigServiceStatus.Offline;
299299
this.options.logger.configServiceStatusChanged(nameOfConfigServiceStatus(this.status));
300300
} else if (this.disposed) {

test/ConfigCatCacheTests.ts

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { assert } from "chai";
2-
import { createManualPollOptions, FakeLogger } from "./helpers/fakes";
3-
import { ExternalConfigCache, IConfigCache, IConfigCatCache, InMemoryConfigCache } from "#lib/ConfigCatCache";
2+
import { createManualPollOptions, FakeExternalCache, FakeLogger, FaultyFakeExternalCache } from "./helpers/fakes";
3+
import { ExternalConfigCache, IConfigCache, InMemoryConfigCache } from "#lib/ConfigCatCache";
44
import { LoggerWrapper, LogLevel } from "#lib/ConfigCatLogger";
55
import { Config, ProjectConfig } from "#lib/ProjectConfig";
66

@@ -111,23 +111,3 @@ describe("ConfigCatCache", () => {
111111
});
112112
}
113113
});
114-
115-
class FakeExternalCache implements IConfigCatCache {
116-
cachedValue?: string;
117-
118-
set(key: string, value: string): void {
119-
this.cachedValue = value;
120-
}
121-
get(key: string): string | undefined {
122-
return this.cachedValue;
123-
}
124-
}
125-
126-
class FaultyFakeExternalCache implements IConfigCatCache {
127-
set(key: string, value: string): never {
128-
throw new Error("Operation failed :(");
129-
}
130-
get(key: string): never {
131-
throw new Error("Operation failed :(");
132-
}
133-
}

test/ConfigCatClientOptionsTests.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ class FakeCache implements IConfigCache {
415415
}
416416
}
417417

418-
export class FakeLogger implements IConfigCatLogger {
418+
class FakeLogger implements IConfigCatLogger {
419419
level?: LogLevel | undefined;
420420

421421
log(level: LogLevel, eventId: LogEventId, message: LogMessage, exception?: any): void {

test/ConfigCatClientTests.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,7 +1216,7 @@ describe("ConfigCatClient", () => {
12161216
];
12171217

12181218
for (const [pollingMode, optionsFactory] of optionsFactoriesForOfflineModeTests) {
1219-
it(`setOnline() should make a(n) ${PollingMode[pollingMode]} client created in offline mode transition to online mode.`, async () => {
1219+
it(`setOnline() should make a(n) ${PollingMode[pollingMode]} client created in offline mode transition to online mode`, async () => {
12201220

12211221
const configFetcher = new FakeConfigFetcherBase("{}", 100, (lastConfig, lastETag) => ({
12221222
statusCode: 200,
@@ -1283,7 +1283,7 @@ describe("ConfigCatClient", () => {
12831283
}
12841284

12851285
for (const [pollingMode, optionsFactory] of optionsFactoriesForOfflineModeTests) {
1286-
it(`setOffline() should make a(n) ${PollingMode[pollingMode]} client created in online mode transition to offline mode.`, async () => {
1286+
it(`setOffline() should make a(n) ${PollingMode[pollingMode]} client created in online mode transition to offline mode`, async () => {
12871287

12881288
const configFetcher = new FakeConfigFetcherBase("{}", 100, (lastConfig, lastETag) => ({
12891289
statusCode: 200,
@@ -1352,7 +1352,7 @@ describe("ConfigCatClient", () => {
13521352
}
13531353

13541354
for (const addListenersViaOptions of [false, true]) {
1355-
it(`ConfigCatClient should emit events, which listeners added ${addListenersViaOptions ? "via options" : "directly on the client"} should get notified of.`, async () => {
1355+
it(`ConfigCatClient should emit events, which listeners added ${addListenersViaOptions ? "via options" : "directly on the client"} should get notified of`, async () => {
13561356
let clientReadyEventCount = 0;
13571357
const configChangedEvents: IConfig[] = [];
13581358
const flagEvaluatedEvents: IEvaluationDetails[] = [];

test/ConfigServiceBaseTests.ts

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ 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 } from "./helpers/fakes";
6+
import { createAutoPollOptions, createKernel, createLazyLoadOptions, createManualPollOptions, FakeCache, FakeExternalCache, FakeLogger } from "./helpers/fakes";
7+
import { ClientCacheState } from "#lib";
78
import { AutoPollConfigService, POLL_EXPIRATION_TOLERANCE_MS } from "#lib/AutoPollConfigService";
8-
import { IConfigCache, InMemoryConfigCache } from "#lib/ConfigCatCache";
9+
import { ExternalConfigCache, IConfigCache, InMemoryConfigCache } from "#lib/ConfigCatCache";
910
import { OptionsBase } from "#lib/ConfigCatClientOptions";
11+
import { LoggerWrapper } from "#lib/ConfigCatLogger";
1012
import { FetchResult, IConfigFetcher, IFetchResponse } from "#lib/ConfigFetcher";
1113
import { LazyLoadConfigService } from "#lib/LazyLoadConfigService";
1214
import { ManualPollConfigService } from "#lib/ManualPollConfigService";
1315
import { Config, ProjectConfig } from "#lib/ProjectConfig";
14-
import { delay } from "#lib/Utils";
16+
import { AbortToken, delay } from "#lib/Utils";
1517

1618
describe("ConfigServiceBaseTests", () => {
1719

@@ -310,6 +312,118 @@ describe("ConfigServiceBaseTests", () => {
310312
service.dispose();
311313
});
312314

315+
it("AutoPollConfigService - Should wait maxInitWaitTime in offline mode when external cache is expired", async () => {
316+
317+
// Arrange
318+
319+
const pollIntervalSeconds = 1;
320+
const maxInitWaitTimeSeconds = 2.5;
321+
322+
const frOld: FetchResult = createFetchResult("oldEtag");
323+
const projectConfigOld = createConfigFromFetchResult(frOld)
324+
.with(ProjectConfig.generateTimestamp() - (1.5 * pollIntervalSeconds * 1000) + 0.5 * POLL_EXPIRATION_TOLERANCE_MS);
325+
326+
const logger = new LoggerWrapper(new FakeLogger());
327+
const cache = new ExternalConfigCache(new FakeExternalCache(), logger);
328+
329+
const options = createAutoPollOptions(
330+
"APIKEY",
331+
{
332+
pollIntervalSeconds,
333+
maxInitWaitTimeSeconds,
334+
offline: true,
335+
},
336+
createKernel({ defaultCacheFactory: () => cache })
337+
);
338+
339+
cache.set(options.getCacheKey(), projectConfigOld);
340+
341+
const fetcherMock = new Mock<IConfigFetcher>();
342+
343+
// Act
344+
345+
const service: AutoPollConfigService = new AutoPollConfigService(
346+
fetcherMock.object(),
347+
options);
348+
349+
const { readyPromise } = service;
350+
const delayAbortToken = new AbortToken();
351+
const delayPromise = delay(maxInitWaitTimeSeconds * 1000 - 250, delayAbortToken);
352+
const raceResult = await Promise.race([readyPromise, delayPromise]);
353+
delayAbortToken.abort();
354+
355+
// Assert
356+
357+
assert.strictEqual(raceResult, true);
358+
359+
// Cleanup
360+
361+
service.dispose();
362+
});
363+
364+
it("AutoPollConfigService - Should initialize in offline mode when external cache becomes up-to-date", async () => {
365+
366+
// Arrange
367+
368+
const pollIntervalSeconds = 1;
369+
const maxInitWaitTimeSeconds = 2.5;
370+
const cacheSetDelayMs = 0.5 * pollIntervalSeconds * 1000;
371+
372+
const frOld: FetchResult = createFetchResult("oldEtag");
373+
const projectConfigOld = createConfigFromFetchResult(frOld)
374+
.with(ProjectConfig.generateTimestamp() - (1.5 * pollIntervalSeconds * 1000) + 0.5 * POLL_EXPIRATION_TOLERANCE_MS);
375+
376+
const logger = new LoggerWrapper(new FakeLogger());
377+
const cache = new ExternalConfigCache(new FakeExternalCache(), logger);
378+
379+
const options = createAutoPollOptions(
380+
"APIKEY",
381+
{
382+
pollIntervalSeconds,
383+
maxInitWaitTimeSeconds,
384+
offline: true,
385+
},
386+
createKernel({ defaultCacheFactory: () => cache })
387+
);
388+
389+
cache.set(options.getCacheKey(), projectConfigOld);
390+
391+
const fetcherMock = new Mock<IConfigFetcher>();
392+
393+
// Act
394+
395+
const service: AutoPollConfigService = new AutoPollConfigService(
396+
fetcherMock.object(),
397+
options);
398+
399+
const { readyPromise } = service;
400+
const delayAbortToken = new AbortToken();
401+
const delayPromise = delay(maxInitWaitTimeSeconds * 1000 - 250, delayAbortToken);
402+
const racePromise = Promise.race([readyPromise, delayPromise]);
403+
404+
const cacheSetDelayAbortToken = new AbortToken();
405+
const cacheSetDelayPromise = delay(cacheSetDelayMs, cacheSetDelayAbortToken);
406+
const cacheSetRaceResult = await Promise.race([readyPromise, cacheSetDelayPromise]);
407+
cacheSetDelayAbortToken.abort();
408+
assert.strictEqual(cacheSetRaceResult, true);
409+
410+
const frNew: FetchResult = createFetchResult("newEtag");
411+
const projectConfigNew: ProjectConfig = createConfigFromFetchResult(frNew)
412+
.with(ProjectConfig.generateTimestamp() + (pollIntervalSeconds * 1000) - cacheSetDelayMs + 0.5 * POLL_EXPIRATION_TOLERANCE_MS);
413+
cache.set(options.getCacheKey(), projectConfigNew);
414+
415+
const raceResult = await racePromise;
416+
delayAbortToken.abort();
417+
418+
// Assert
419+
420+
assert.strictEqual(raceResult, ClientCacheState.HasUpToDateFlagData);
421+
422+
// Cleanup
423+
424+
service.dispose();
425+
});
426+
313427
it("LazyLoadConfigService - ProjectConfig is different in the cache - should fetch a new config and put into cache", async () => {
314428

315429
// Arrange

test/helpers/fakes.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class FakeCache implements IConfigCache {
7171
}
7272

7373
export class FakeExternalCache implements IConfigCatCache {
74-
private cachedValue: string | undefined;
74+
cachedValue: string | undefined;
7575

7676
set(key: string, value: string): void {
7777
this.cachedValue = value;
@@ -82,8 +82,17 @@ export class FakeExternalCache implements IConfigCatCache {
8282
}
8383
}
8484

85+
export class FaultyFakeExternalCache implements IConfigCatCache {
86+
set(key: string, value: string): never {
87+
throw new Error("Operation failed :(");
88+
}
89+
get(key: string): never {
90+
throw new Error("Operation failed :(");
91+
}
92+
}
93+
8594
export class FakeExternalAsyncCache implements IConfigCatCache {
86-
private cachedValue: string | undefined;
95+
cachedValue: string | undefined;
8796

8897
constructor(private readonly delayMs = 0) {
8998
}

0 commit comments

Comments
 (0)