Skip to content
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
1 change: 1 addition & 0 deletions apps/cli/src/server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const SyncTask = z.strictObject({
excludeResourceType: z.array(z.enum(ADCSDK.ResourceType)).optional(),
labelSelector: z.record(z.string(), z.string()).optional(),
cacheKey: z.string(),
bypassCache: z.boolean().optional().default(false),
}),
config: z.looseObject({}),
});
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/src/server/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export const syncHandler: RequestHandler<
httpsAgent: (task.opts as any).tlsSkipVerify ? httpsInsecureAgent : httpsAgent,
Comment thread
bzp2010 marked this conversation as resolved.
});

backend.on('TASK_START', ({ name }) =>
logger.log({ level: 'info', message: name ?? '', requestId: req.requestId }),
);
backend.on('AXIOS_DEBUG', ({ description, response }) =>
logger.log({
level: 'debug',
Expand Down
99 changes: 99 additions & 0 deletions libs/backend-apisix-standalone/e2e/cache.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { gt, lte } from 'semver';
import { BackendAPISIXStandalone } from '../src';
import {
config as configCache,
invalidate as invalidateCache,
latestVersion as latestVersionCache,
rawConfig as rawConfigCache,
} from '../src/cache';
Expand Down Expand Up @@ -441,3 +442,101 @@ describe('Cache - Multiple APISIX (Partial new instances)', () => {
},
);
});

describe('Cache - bypassCache invalidation', () => {
let backend: BackendAPISIXStandalone;
const cacheKey = 'default';

const syncedConfig = {
services: [
{
name: 'service1',
upstream: { nodes: [{ host: '127.0.0.1', port: 9180, weight: 100 }] },
routes: [{ name: 'route1', uris: ['/'] }],
},
],
} as ADCSDK.Configuration;

// Data that does not exist in APISIX, injected directly into the cache to simulate staleness
const staleConfig = {
services: [
{
name: 'stale-service',
upstream: { nodes: [{ host: '1.2.3.4', port: 80, weight: 100 }] },
routes: [{ name: 'stale-route', uris: ['/stale'] }],
},
],
} as ADCSDK.Configuration;

beforeAll(async () => {
await restartAPISIX();
// Previous describe blocks may leave stale entries (e.g. rawConfigCache) that
// would cause duplicate-ID errors when the operator builds the PUT payload.
invalidateCache(cacheKey);
backend = new BackendAPISIXStandalone({
server: server1,
token: token1,
tlsSkipVerify: true,
cacheKey,
...defaultBackendOptions,
});
});

afterAll(() => (configCache.clear(), rawConfigCache.clear()));

it('initialize cache and sync config to APISIX', async () => {
await dumpConfiguration(backend);
mockStableTimestamp.mockReturnValueOnce(Date.now());
const events = DifferV3.diff(syncedConfig, {});
await syncEvents(backend, events);
expect(configCache.get(cacheKey)).toMatchObject(syncedConfig);
});

it('inject stale data into cache', () => {
configCache.set(cacheKey, staleConfig);
expect(configCache.get(cacheKey)).toMatchObject(staleConfig);
});

it('dump without bypassCache returns stale data (no API call)', async () => {
let apiCall = 0;
const sub = backend.on('AXIOS_DEBUG', () => apiCall++);
const result = await dumpConfiguration(backend);
sub.unsubscribe();

expect(apiCall).toEqual(0);
expect(result.services?.[0].name).toEqual('stale-service');
});

it('dump with bypassCache fetches fresh data from APISIX', async () => {
const freshBackend = new BackendAPISIXStandalone({
server: server1,
token: token1,
tlsSkipVerify: true,
cacheKey,
bypassCache: true,
...defaultBackendOptions,
});

let apiCall = 0;
const sub = freshBackend.on('AXIOS_DEBUG', () => apiCall++);
const result = await dumpConfiguration(freshBackend);
sub.unsubscribe();

expect(apiCall).toBeGreaterThan(0);
expect(result).toMatchObject(syncedConfig);
});

it('cache is repopulated with fresh data after bypassCache dump', () => {
expect(configCache.get(cacheKey)).toMatchObject(syncedConfig);
});

it('subsequent dump without bypassCache serves repopulated cache (no API call)', async () => {
let apiCall = 0;
const sub = backend.on('AXIOS_DEBUG', () => apiCall++);
const result = await dumpConfiguration(backend);
sub.unsubscribe();

expect(apiCall).toEqual(0);
expect(result).toMatchObject(syncedConfig);
});
});
9 changes: 8 additions & 1 deletion libs/backend-apisix-standalone/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ export default config([
'{projectRoot}/test/**/*',
'{projectRoot}/e2e/**/*',
],
ignoredDependencies: ['tslib'],
ignoredDependencies: [
'tslib',
// lru-cache v11 does not expose ./package.json via its exports map, and its
// dist/commonjs/package.json lacks name/version fields. Nx's package resolver
// hits that incomplete package.json and returns null instead of walking up to
// the real one, so the import in cache.ts is never recorded as an npm dep.
'lru-cache',
],
},
],
},
Expand Down
1 change: 1 addition & 0 deletions libs/backend-apisix-standalone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"axios": "catalog:",
"rxjs": "catalog:",
"lodash-es": "catalog:",
"lru-cache": "catalog:",
"zod": "catalog:",
"tslib": "catalog:",
"semver": "catalog:"
Expand Down
24 changes: 20 additions & 4 deletions libs/backend-apisix-standalone/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import * as ADCSDK from '@api7/adc-sdk';
import { LRUCache } from 'lru-cache';
import { type SemVer } from 'semver';

import * as typing from './typing';

export const version = new Map<string, SemVer>();
export const latestVersion = new Map<string, number>();
export const config = new Map<string, ADCSDK.Configuration>();
export const rawConfig = new Map<string, typing.APISIXStandalone>();
const parseEnvInt = (value: string | undefined, defaultVal: number): number => {
const n = Number(value ?? defaultVal);
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : defaultVal;
};

const max = parseEnvInt(process.env.ADC_APISIX_STANDALONE_CACHE_MAX, 16);
const ttl = parseEnvInt(process.env.ADC_APISIX_STANDALONE_CACHE_TTL_MS, 3_600_000);

export const version = new LRUCache<string, SemVer>({ max, ttl });
export const latestVersion = new LRUCache<string, number>({ max, ttl });
export const config = new LRUCache<string, ADCSDK.Configuration>({ max, ttl });
export const rawConfig = new LRUCache<string, typing.APISIXStandalone>({ max, ttl });

export const invalidate = (key: string): void => {
version.delete(key);
latestVersion.delete(key);
config.delete(key);
rawConfig.delete(key);
};
12 changes: 11 additions & 1 deletion libs/backend-apisix-standalone/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import semver, { SemVer, eq as semverEQ } from 'semver';

import {
config as configCache,
invalidate as invalidateCache,
latestVersion as latestVersionCache,
rawConfig as rawConfigCache,
version as versionCache,
Expand All @@ -14,6 +15,8 @@ import { Fetcher } from './fetcher';
import { Operator } from './operator';
import * as typing from './typing';

type BackendOpts = ADCSDK.BackendOptions & { bypassCache?: boolean };

export class BackendAPISIXStandalone implements ADCSDK.Backend {
private static logScope = ['APISIX'];
private readonly client: AxiosInstance;
Expand All @@ -24,7 +27,7 @@ export class BackendAPISIXStandalone implements ADCSDK.Backend {
string
>();

constructor(private readonly opts: ADCSDK.BackendOptions) {
constructor(private readonly opts: BackendOpts) {
const servers = opts.server.split(',');
const tokens = opts.token.split(',');
servers.forEach((server, idx) => {
Expand Down Expand Up @@ -94,6 +97,13 @@ export class BackendAPISIXStandalone implements ADCSDK.Backend {
// 2.2. Find the latest updated server among them and it will be used as the initial cache
// 3. Transform and return that configuration
public dump(): Observable<ADCSDK.Configuration> {
if (this.opts.bypassCache) {
invalidateCache(this.opts.cacheKey!);
this.subject.next({
type: ADCSDK.BackendEventType.TASK_START,
event: { name: `Cache invalidated for key: "${this.opts.cacheKey!}"` },
});
}
return from(this.version()).pipe<ADCSDK.Configuration>(
switchMap((version) => {
const cachedConfig = configCache.get(this.opts.cacheKey!);
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

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

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ catalog:
js-yaml: ^4.1.2
listr2: ^10.0.0
lodash-es: ^4.18.1
lru-cache: ^11.0.0
rxjs: ^7.8.2
semver: ^7.7.4
source-map-support: ^0.5.21
Expand Down
Loading