coze-studio/frontend/packages/arch/bot-flags/src/pull-feature-flags.ts

226 lines
7.0 KiB
TypeScript

/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { logger } from '@coze-arch/logger';
import { wait, ONE_SEC } from './utils/wait';
import { isObject } from './utils/tools';
import { featureFlagStorage } from './utils/storage';
import { reporter } from './utils/repoter';
import {
readFgValuesFromContext,
readFgPromiseFromContext,
} from './utils/read-from-context';
import { readFromCache, saveToCache } from './utils/persist-cache';
import { type FEATURE_FLAGS, type FetchFeatureGatingFunction } from './types';
import { PACKAGE_NAMESPACE } from './constant';
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const DEFAULT_POLLING_INTERVAL = 5 * ONE_SEC;
// Set 17 as the time sharding size
const TIME_PIECE = 17;
interface PullFeatureFlagsParams {
// value timeout
timeout: number;
// In strict mode, no fallback logic will be inserted, and an error will be reported directly when the value cannot be obtained
strict: boolean;
// Rotation interval, production environment default 60 seconds; development & testing environment default 10 seconds
pollingInterval: number;
fetchFeatureGating: FetchFeatureGatingFunction;
}
interface WorkResult {
values: FEATURE_FLAGS;
source: 'context' | 'remote' | 'bailout' | 'persist' | 'static_context';
}
const runPipeline = async (
context: PullFeatureFlagsParams,
): Promise<WorkResult> => {
try {
const fgValues = readFgValuesFromContext();
if (fgValues) {
saveToCache(fgValues);
return { values: fgValues, source: 'static_context' };
}
} catch (e) {
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: (e as Error).message,
error: e as Error,
});
}
const { timeout: to, strict } = context;
// The timeout should not be less than 1s.
const timeout = Math.max(to, ONE_SEC);
const works: (() => Promise<WorkResult | undefined>)[] = [];
const waitTimeout = wait.bind(null, timeout + ONE_SEC);
// Take value from the online environment
works.push(async () => {
try {
const values = await context.fetchFeatureGating();
if (isObject(values)) {
saveToCache(values);
return { values, source: 'remote' };
}
await waitTimeout();
} catch (e) {
// TODO: Add event tracking here to report interface abnormalities
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Fetch fg by "fetchFeatureGating" failure',
error: e as Error,
});
await waitTimeout();
}
});
// Get value from browser global object
// It needs to be judged here, only the browser environment will execute it.
works.push(async () => {
try {
const values = await readFgPromiseFromContext();
if (values && isObject(values)) {
saveToCache(values);
return { values: values as FEATURE_FLAGS, source: 'context' };
}
logger.persist.info({
namespace: PACKAGE_NAMESPACE,
message: "Can't not read fg from global context",
});
// Force and so on to time out, lest the entire works resolve to the wrong value
await waitTimeout();
} catch (e) {
// TODO: Add event tracking here to report interface abnormalities
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Fetch fg from context failure',
error: e as Error,
});
await waitTimeout();
}
});
// fetch value from cache
works.push(async () => {
try {
const values = await readFromCache();
if (values) {
// Wait for xx ms before reading persist to ensure that values are retrieved from context first
await wait(timeout - TIME_PIECE);
return { values, source: 'persist' };
}
await waitTimeout();
} catch (e) {
// TODO: Add event tracking here to report interface abnormalities
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Fetch fg from persist cache failure',
error: e as Error,
});
await waitTimeout();
}
});
// Bottom line, the value cannot be obtained after timeout, and the default value is returned, that is, all are false.
works.push(async () => {
await wait(timeout + TIME_PIECE);
if (strict) {
throw new Error('Fetch Feature Flags timeout.');
}
return { values: {} as unknown as FEATURE_FLAGS, source: 'bailout' };
});
// It is impossible to return undefined here, so do a cast
const res = (await Promise.race(
works.map(work => work()),
)) as unknown as WorkResult;
return res;
};
const normalize = (
context?: Partial<PullFeatureFlagsParams>,
): PullFeatureFlagsParams => {
const ctx = context || {};
if (!ctx.fetchFeatureGating) {
throw new Error('fetchFeatureGating is required');
}
const DEFAULT_CONTEXT: Partial<PullFeatureFlagsParams> = {
timeout: 2000,
strict: false,
pollingInterval: DEFAULT_POLLING_INTERVAL,
};
const normalizeContext = Object.assign(
DEFAULT_CONTEXT,
Object.keys(ctx)
// Only take things that are not undefined
.filter(k => typeof ctx[k] !== 'undefined')
.reduce((acc, k) => ({ ...acc, [k]: ctx[k] }), {}),
);
return normalizeContext as PullFeatureFlagsParams;
};
const pullFeatureFlags = async (context?: Partial<PullFeatureFlagsParams>) => {
const tracer = reporter.tracer({
eventName: 'load-fg',
});
const normalizeContext = normalize(context);
const { strict, pollingInterval } = normalizeContext;
tracer.trace('start');
const start = performance.now();
const retry = async () => {
// When an error occurs, automatically retry
await wait(pollingInterval);
await pullFeatureFlags(context);
};
try {
const res = await runPipeline(normalizeContext);
const { values, source } = res;
// TODO: The quantity should be reported here, and it should be changed after the subsequent logger provides relevant capabilities.
logger.persist.success({
namespace: PACKAGE_NAMESPACE,
message: `Load FG from ${source} start at ${start}ms and spend ${
performance.now() - start
}ms`,
});
tracer.trace('finish');
featureFlagStorage.setFlags(values);
if (['bailout', 'persist'].includes(source)) {
await retry();
}
} catch (e) {
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Failure to load FG',
error: e as Error,
});
if (!strict) {
featureFlagStorage.setFlags({} as unknown as FEATURE_FLAGS);
await retry();
} else {
throw e;
}
}
};
export { pullFeatureFlags };