Skip to content

Commit 0426ceb

Browse files
committed
Eliminate race condition between initial and user-initiated cache sync ups
1 parent eafddc6 commit 0426ceb

File tree

4 files changed

+24
-14
lines changed

4 files changed

+24
-14
lines changed

src/AutoPollConfigService.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
8282

8383
let cachedConfig: ProjectConfig;
8484
if (!this.initialized) {
85-
cachedConfig = await this.options.cache.get(this.cacheKey);
85+
cachedConfig = await this.syncUpWithCache();
8686
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
8787
logSuccess(this.options.logger);
8888
return cachedConfig;
@@ -92,7 +92,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
9292
await this.initializationPromise;
9393
}
9494

95-
cachedConfig = await this.options.cache.get(this.cacheKey);
95+
cachedConfig = await this.syncUpWithCache();
9696
if (!cachedConfig.isExpired(this.pollIntervalMs)) {
9797
logSuccess(this.options.logger);
9898
} else {
@@ -160,7 +160,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
160160
private async refreshWorkerLogic(initialCacheSyncUp: ProjectConfig | Promise<ProjectConfig> | null) {
161161
this.options.logger.debug("AutoPollConfigService.refreshWorkerLogic() called.");
162162

163-
const latestConfig = await (initialCacheSyncUp ?? this.options.cache.get(this.cacheKey));
163+
const latestConfig = await (initialCacheSyncUp ?? this.syncUpWithCache());
164164
if (latestConfig.isExpired(this.pollExpirationMs)) {
165165
// Even if the service gets disposed immediately, we allow the first refresh for backward compatibility,
166166
// i.e. to not break usage patterns like this:

src/ConfigServiceBase.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { FetchErrorCauses, IConfigFetcher, IFetchResponse } from "./ConfigF
44
import { FetchError, FetchResult, FetchStatus } from "./ConfigFetcher";
55
import { RedirectMode } from "./ConfigJson";
66
import { Config, ProjectConfig } from "./ProjectConfig";
7+
import { isPromiseLike } from "./Utils";
78

89
/** Contains the result of an `IConfigCatClient.forceRefresh` or `IConfigCatClient.forceRefreshAsync` operation. */
910
export class RefreshResult {
@@ -75,6 +76,7 @@ function nameOfConfigServiceStatus(value: ConfigServiceStatus): string {
7576
export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
7677
private status: ConfigServiceStatus;
7778

79+
private pendingCacheSyncUp: Promise<ProjectConfig> | null = null;
7880
private pendingFetch: Promise<FetchResult> | null = null;
7981

8082
protected readonly cacheKey: string;
@@ -103,7 +105,7 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
103105
abstract getConfig(): Promise<ProjectConfig>;
104106

105107
async refreshConfigAsync(): Promise<[RefreshResult, ProjectConfig]> {
106-
const latestConfig = await this.options.cache.get(this.cacheKey);
108+
const latestConfig = await this.syncUpWithCache();
107109
if (!this.isOffline) {
108110
const [fetchResult, config] = await this.refreshConfigCoreAsync(latestConfig);
109111
return [RefreshResult.from(fetchResult), config];
@@ -147,13 +149,8 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
147149
}
148150

149151
private fetchAsync(lastConfig: ProjectConfig): Promise<FetchResult> {
150-
return this.pendingFetch ??= (async () => {
151-
try {
152-
return await this.fetchLogicAsync(lastConfig);
153-
} finally {
154-
this.pendingFetch = null;
155-
}
156-
})();
152+
return this.pendingFetch ??= this.fetchLogicAsync(lastConfig)
153+
.finally(() => this.pendingFetch = null);
157154
}
158155

159156
private async fetchLogicAsync(lastConfig: ProjectConfig): Promise<FetchResult> {
@@ -305,7 +302,20 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
305302
abstract getCacheState(cachedConfig: ProjectConfig): ClientCacheState;
306303

307304
protected syncUpWithCache(): ProjectConfig | Promise<ProjectConfig> {
308-
return this.options.cache.get(this.cacheKey);
305+
if (this.pendingCacheSyncUp) {
306+
return this.pendingCacheSyncUp;
307+
}
308+
309+
const syncResult = this.options.cache.get(this.cacheKey);
310+
if (!isPromiseLike(syncResult)) {
311+
return syncResult;
312+
}
313+
314+
const syncUpAndFinish = syncResult
315+
.finally(() => this.pendingCacheSyncUp = null);
316+
317+
this.pendingCacheSyncUp = syncResult;
318+
return syncUpAndFinish;
309319
}
310320

311321
protected async waitForReadyAsync(initialCacheSyncUp: ProjectConfig | Promise<ProjectConfig>): Promise<ClientCacheState> {

src/LazyLoadConfigService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export class LazyLoadConfigService extends ConfigServiceBase<LazyLoadOptions> im
2727
logger.debug(`LazyLoadConfigService.getConfig(): cache is empty or expired${appendix}.`);
2828
}
2929

30-
let cachedConfig = await this.options.cache.get(this.cacheKey);
30+
let cachedConfig = await this.syncUpWithCache();
3131

3232
if (cachedConfig.isExpired(this.cacheTimeToLiveMs)) {
3333
if (!this.isOffline) {

src/ManualPollConfigService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class ManualPollConfigService extends ConfigServiceBase<ManualPollOptions
2626

2727
async getConfig(): Promise<ProjectConfig> {
2828
this.options.logger.debug("ManualPollService.getConfig() called.");
29-
return await this.options.cache.get(this.cacheKey);
29+
return await this.syncUpWithCache();
3030
}
3131

3232
refreshConfigAsync(): Promise<[RefreshResult, ProjectConfig]> {

0 commit comments

Comments
 (0)