Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix cache expiration-related issues in Auto Polling mode #106

Merged
merged 7 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/common-js-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
matrix:
node: [ 14, 16, 18, 20 ]
os: [ macos-latest, ubuntu-latest, windows-latest ]
exclude:
- node: 14
os: macos-latest
fail-fast: false
name: Test [${{ matrix.os }}, Node ${{ matrix.node }}]
steps:
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "configcat-common",
"version": "9.3.0",
"version": "9.3.1",
"description": "ConfigCat is a configuration as a service that lets you manage your features and configurations without actually deploying new code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand Down
89 changes: 51 additions & 38 deletions src/AutoPollConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,28 @@ import type { IConfigFetcher } from "./ConfigFetcher";
import type { IConfigService, RefreshResult } from "./ConfigServiceBase";
import { ClientCacheState, ConfigServiceBase } from "./ConfigServiceBase";
import type { ProjectConfig } from "./ProjectConfig";
import { delay } from "./Utils";
import { AbortToken, delay } from "./Utils";

export const POLL_EXPIRATION_TOLERANCE_MS = 500;

export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> implements IConfigService {

private initialized: boolean;
private readonly initializationPromise: Promise<boolean>;
private signalInitialization: () => void = () => { /* Intentional no-op. */ };
private workerTimerId?: ReturnType<typeof setTimeout>;
private stopToken = new AbortToken();
private readonly pollIntervalMs: number;
private readonly pollExpirationMs: number;
readonly readyPromise: Promise<ClientCacheState>;

constructor(configFetcher: IConfigFetcher, options: AutoPollOptions) {

super(configFetcher, options);

this.pollIntervalMs = options.pollIntervalSeconds * 1000;
// Due to the inaccuracy of the timer, some tolerance should be allowed when checking for
// cache expiration in the polling loop, otherwise some fetch operations may be missed.
this.pollExpirationMs = this.pollIntervalMs - POLL_EXPIRATION_TOLERANCE_MS;

const initialCacheSyncUp = this.syncUpWithCache();

Expand Down Expand Up @@ -48,7 +54,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
});

if (!options.offline) {
this.startRefreshWorker(initialCacheSyncUp);
this.startRefreshWorker(initialCacheSyncUp, this.stopToken);
}
}

Expand All @@ -58,12 +64,12 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
return true;
}

const delayCleanup: { clearTimer?: () => void } = {};
const abortToken = new AbortToken();
const success = await Promise.race([
initSignalPromise.then(() => true),
delay(this.options.maxInitWaitTimeSeconds * 1000, delayCleanup).then(() => false)
delay(this.options.maxInitWaitTimeSeconds * 1000, abortToken).then(() => false)
]);
delayCleanup.clearTimer!();
abortToken.abort();
return success;
}

Expand Down Expand Up @@ -105,7 +111,7 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
dispose(): void {
this.options.logger.debug("AutoPollConfigService.dispose() called.");
super.dispose();
if (this.workerTimerId) {
if (!this.stopToken.aborted) {
this.stopRefreshWorker();
}
}
Expand All @@ -116,58 +122,65 @@ export class AutoPollConfigService extends ConfigServiceBase<AutoPollOptions> im
}

protected setOnlineCore(): void {
this.startRefreshWorker();
this.startRefreshWorker(null, this.stopToken);
}

protected setOfflineCore(): void {
this.stopRefreshWorker();
this.stopToken = new AbortToken();
}

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

const delayMs = this.pollIntervalMs;
let isFirstIteration = true;
while (!stopToken.aborted) {
try {
const scheduledNextTimeMs = new Date().getTime() + this.pollIntervalMs;
try {
await this.refreshWorkerLogic(isFirstIteration, initialCacheSyncUp);
}
catch (err) {
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
}

const realNextTimeMs = scheduledNextTimeMs - new Date().getTime();
if (realNextTimeMs > 0) {
await delay(realNextTimeMs, stopToken);
}
}
catch (err) {
this.options.logger.autoPollConfigServiceErrorDuringPolling(err);
}

isFirstIteration = false;
initialCacheSyncUp = null; // allow GC to collect the Promise and its result
}
}

private stopRefreshWorker() {
this.options.logger.debug("AutoPollConfigService.stopRefreshWorker() called.");
this.stopToken.abort();
}

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

const latestConfig = await (initialCacheSyncUp ?? this.options.cache.get(this.cacheKey));
if (latestConfig.isExpired(this.pollIntervalMs)) {
if (latestConfig.isExpired(this.pollExpirationMs)) {
// Even if the service gets disposed immediately, we allow the first refresh for backward compatibility,
// i.e. to not break usage patterns like this:
// ```
// client.getValueAsync("SOME_KEY", false).then(value => { /* ... */ }, user);
// client.dispose();
// ```
if (!this.isOfflineExactly) {
if (isFirstIteration ? !this.isOfflineExactly : !this.isOffline) {
await this.refreshConfigCoreAsync(latestConfig);
}
}
else {
else if (isFirstIteration) {
this.signalInitialization();
}

this.options.logger.debug("AutoPollConfigService.startRefreshWorker() - calling refreshWorkerLogic()'s setTimeout.");
this.workerTimerId = setTimeout(d => this.refreshWorkerLogic(d), delayMs, delayMs);
}

private stopRefreshWorker() {
this.options.logger.debug("AutoPollConfigService.stopRefreshWorker() - clearing setTimeout.");
clearTimeout(this.workerTimerId);
}

private async refreshWorkerLogic(delayMs: number) {
if (this.disposed) {
this.options.logger.debug("AutoPollConfigService.refreshWorkerLogic() - called on a disposed client.");
return;
}

this.options.logger.debug("AutoPollConfigService.refreshWorkerLogic() - called.");

if (!this.isOffline) {
const latestConfig = await this.options.cache.get(this.cacheKey);
await this.refreshConfigCoreAsync(latestConfig);
}

this.options.logger.debug("AutoPollConfigService.refreshWorkerLogic() - calling refreshWorkerLogic()'s setTimeout.");
this.workerTimerId = setTimeout(d => this.refreshWorkerLogic(d), delayMs, delayMs);
}

getCacheState(cachedConfig: ProjectConfig): ClientCacheState {
Expand Down
8 changes: 8 additions & 0 deletions src/ConfigCatLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@ export class LoggerWrapper implements IConfigCatLogger {
);
}

autoPollConfigServiceErrorDuringPolling(ex: any): LogMessage {
return this.log(
LogLevel.Error, 1200,
"Error occurred during auto polling.",
ex
);
}

/* SDK-specific error messages (2000-2999) */

settingForVariationIdIsNotPresent(variationId: string): LogMessage {
Expand Down
51 changes: 45 additions & 6 deletions src/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,49 @@
export function delay(delayMs: number, delayCleanup?: { clearTimer?: () => void } | null): Promise<void> {
let timerId: ReturnType<typeof setTimeout>;
const promise = new Promise<void>(resolve => timerId = setTimeout(resolve, delayMs));
if (delayCleanup) {
delayCleanup.clearTimer = () => clearTimeout(timerId);
// NOTE: Normally, we'd just use AbortController/AbortSignal, however that may not be available on all platforms,
// and we don't want to include a complete polyfill. So we implement a simplified version that fits our use case.
export class AbortToken {
private callbacks: (() => void)[] | null = [];
get aborted(): boolean { return !this.callbacks; }

abort(): void {
if (!this.aborted) {
const callbacks = this.callbacks!;
this.callbacks = null;
for (const callback of callbacks) {
callback();
}
}
}
return promise;

registerCallback(callback: () => void): () => void {
if (this.aborted) {
callback();
return () => { };
}

this.callbacks!.push(callback);
return () => {
const callbacks = this.callbacks;
let index: number;
if (callbacks && (index = callbacks.indexOf(callback)) >= 0) {
callbacks.splice(index, 1);
}
};
}
}

export function delay(delayMs: number, abortToken?: AbortToken | null): Promise<boolean> {
let timerId: ReturnType<typeof setTimeout>;
return new Promise<boolean>(resolve => {
const unregisterAbortCallback = abortToken?.registerCallback(() => {
clearTimeout(timerId);
resolve(false);
});

timerId = setTimeout(() => {
unregisterAbortCallback?.();
resolve(true);
}, delayMs);
});
}

export function errorToString(err: any, includeStackTrace = false): string {
Expand Down
20 changes: 12 additions & 8 deletions test/ConfigCatClientTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,11 +526,13 @@ describe("ConfigCatClient", () => {
setupHooks: hooks => hooks.on("configChanged", () => configChangedEventCount++)
};
const options: AutoPollOptions = new AutoPollOptions("APIKEY", "common", "1.0.0", userOptions, null);
new ConfigCatClient(options, configCatKernel);

await delay(2.5 * pollIntervalSeconds * 1000);
const client = new ConfigCatClient(options, configCatKernel);
try {
await delay(2.5 * pollIntervalSeconds * 1000);

assert.equal(configChangedEventCount, 3);
assert.equal(configChangedEventCount, 3);
}
finally { client.dispose(); }
});

it("Initialization With AutoPollOptions - config doesn't change - should fire configChanged only once", async () => {
Expand All @@ -544,11 +546,13 @@ describe("ConfigCatClient", () => {
setupHooks: hooks => hooks.on("configChanged", () => configChangedEventCount++)
};
const options: AutoPollOptions = new AutoPollOptions("APIKEY", "common", "1.0.0", userOptions, null);
new ConfigCatClient(options, configCatKernel);

await delay(2.5 * pollIntervalSeconds * 1000);
const client = new ConfigCatClient(options, configCatKernel);
try {
await delay(2.5 * pollIntervalSeconds * 1000);

assert.equal(configChangedEventCount, 1);
assert.equal(configChangedEventCount, 1);
}
finally { client.dispose(); }
});

it("Initialization With AutoPollOptions - with maxInitWaitTimeSeconds - getValueAsync should wait", async () => {
Expand Down
Loading
Loading