Skip to content

Commit c30d22a

Browse files
committed
Deduplicate config refresh instead of fetch operation only (wip)
1 parent c128934 commit c30d22a

File tree

3 files changed

+58
-31
lines changed

3 files changed

+58
-31
lines changed

src/ConfigServiceBase.ts

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,16 @@ function nameOfConfigServiceStatus(value: ConfigServiceStatus): string {
7474
return ConfigServiceStatus[value] as string;
7575
}
7676

77+
type ConfigRefreshOperation = {
78+
promise: Promise<[FetchResult, ProjectConfig]>;
79+
latestConfig: ProjectConfig;
80+
};
81+
7782
export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
7883
private status: ConfigServiceStatus;
7984

8085
private pendingCacheSyncUp: Promise<ProjectConfig> | null = null;
81-
private pendingFetch: Promise<FetchResult> | null = null;
86+
private pendingConfigRefresh: ConfigRefreshOperation | null = null;
8287

8388
protected readonly cacheKey: string;
8489

@@ -119,25 +124,57 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
119124
}
120125

121126
protected async refreshConfigCoreAsync(latestConfig: ProjectConfig, isInitiatedByUser: boolean): Promise<[FetchResult, ProjectConfig]> {
122-
const fetchResult = await this.fetchAsync(latestConfig);
123-
124-
let configChanged = false;
125-
const success = fetchResult.status === FetchStatus.Fetched;
126-
if (success
127-
|| fetchResult.config.timestamp > latestConfig.timestamp && (!fetchResult.config.isEmpty || latestConfig.isEmpty)) {
128-
await this.options.cache.set(this.cacheKey, fetchResult.config);
127+
let refreshOperation = this.pendingConfigRefresh;
129128

130-
configChanged = success && !ProjectConfig.equals(fetchResult.config, latestConfig);
131-
latestConfig = fetchResult.config;
129+
if (refreshOperation) {
130+
const { promise, latestConfig: knownLatestConfig } = refreshOperation;
131+
if (latestConfig.timestamp > knownLatestConfig.timestamp && (!latestConfig.isEmpty || knownLatestConfig.isEmpty)) {
132+
refreshOperation.latestConfig = latestConfig;
133+
}
134+
return promise;
132135
}
133136

134-
this.onConfigFetched(fetchResult, isInitiatedByUser);
137+
refreshOperation = { latestConfig } as ConfigRefreshOperation;
138+
refreshOperation.promise = (async (refreshOperation: ConfigRefreshOperation) => {
139+
const fetchResult = await this.fetchAsync(refreshOperation.latestConfig);
140+
141+
// NOTE: Further joiners may obtain more up-to-date configs from the external cache, and update
142+
// operation.latestConfig before the operation completes, but those updates will be ignored.
143+
// In other words, the operation may not return the most recent config obtained during its execution.
144+
// However, this is acceptable, especially if we consider that reading and writing the external cache is
145+
// not synchronized, which means that a more recent config can be overwritten with a stale one.
146+
// (We don't make any effort to synchronize external cache access as that would be extremely hard,
147+
// and we expect the "stuttering" resulting from this race condition to be temporary only.)
148+
let { latestConfig } = refreshOperation;
149+
150+
const success = fetchResult.status === FetchStatus.Fetched;
151+
if (success
152+
|| fetchResult.config.timestamp > latestConfig.timestamp && (!fetchResult.config.isEmpty || latestConfig.isEmpty)) {
153+
await this.options.cache.set(this.cacheKey, fetchResult.config);
154+
155+
latestConfig = fetchResult.config;
156+
}
135157

136-
if (configChanged) {
137-
this.onConfigChanged(fetchResult.config);
138-
}
158+
return [fetchResult, latestConfig];
159+
})(refreshOperation);
160+
161+
const refreshAndFinish = refreshOperation.promise
162+
.finally(() => this.pendingConfigRefresh = null)
163+
.then(refreshResult => {
164+
const [fetchResult] = refreshResult;
139165

140-
return [fetchResult, latestConfig];
166+
this.onConfigFetched(fetchResult, isInitiatedByUser);
167+
168+
if (fetchResult.status === FetchStatus.Fetched) {
169+
this.onConfigChanged(fetchResult.config);
170+
}
171+
172+
return refreshResult;
173+
});
174+
175+
this.pendingConfigRefresh = refreshOperation;
176+
177+
return refreshAndFinish;
141178
}
142179

143180
protected onConfigFetched(fetchResult: FetchResult, isInitiatedByUser: boolean): void {
@@ -150,12 +187,7 @@ export abstract class ConfigServiceBase<TOptions extends OptionsBase> {
150187
this.options.hooks.emit("configChanged", newConfig.config ?? new Config({}));
151188
}
152189

153-
private fetchAsync(lastConfig: ProjectConfig): Promise<FetchResult> {
154-
return this.pendingFetch ??= this.fetchLogicAsync(lastConfig)
155-
.finally(() => this.pendingFetch = null);
156-
}
157-
158-
private async fetchLogicAsync(lastConfig: ProjectConfig): Promise<FetchResult> {
190+
private async fetchAsync(lastConfig: ProjectConfig): Promise<FetchResult> {
159191
const options = this.options;
160192
options.logger.debug("ConfigServiceBase.fetchLogicAsync() called.");
161193

src/ProjectConfig.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,6 @@ export class ProjectConfig {
1414

1515
static readonly empty = new ProjectConfig(void 0, void 0, 0, void 0);
1616

17-
static equals(projectConfig1: ProjectConfig, projectConfig2: ProjectConfig): boolean {
18-
// When both ETags are available, we don't need to check the JSON content.
19-
return projectConfig1.httpETag && projectConfig2.httpETag
20-
? projectConfig1.httpETag === projectConfig2.httpETag
21-
: projectConfig1.configJson === projectConfig2.configJson;
22-
}
23-
2417
constructor(
2518
readonly configJson: string | undefined,
2619
readonly config: Config | undefined,

test/helpers/fakes.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,11 @@ export class FakeConfigFetcherBase implements IConfigFetcher {
146146
this.config = fr.body ?? null;
147147
return fr;
148148
}
149-
: () => this.config !== null
150-
? { statusCode: 200, reasonPhrase: "OK", eTag: this.getEtag(), body: this.config } as IFetchResponse
151-
: { statusCode: 404, reasonPhrase: "Not Found" } as IFetchResponse;
149+
: () => {
150+
return this.config === null ? { statusCode: 404, reasonPhrase: "Not Found" } as IFetchResponse
151+
: this.getEtag() === lastEtag ? { statusCode: 304, reasonPhrase: "Not Modified" } as IFetchResponse
152+
: { statusCode: 200, reasonPhrase: "OK", eTag: this.getEtag(), body: this.config } as IFetchResponse;
153+
};
152154

153155
await delay(this.callbackDelay);
154156

0 commit comments

Comments
 (0)