Merge pull request #31497 from benjidotsh/cache/improved-valkey-support
This commit is contained in:
+2
-20
@@ -710,27 +710,9 @@ export interface Config {
|
||||
*/
|
||||
client?: {
|
||||
/**
|
||||
* Namespace for the current instance.
|
||||
* Namespace and separator used for prefixing keys.
|
||||
*/
|
||||
namespace?: string;
|
||||
/**
|
||||
* Separator to use between namespace and key.
|
||||
*/
|
||||
keyPrefixSeparator?: string;
|
||||
/**
|
||||
* Number of keys to delete in a single batch.
|
||||
*/
|
||||
clearBatchSize?: number;
|
||||
/**
|
||||
* Enable Unlink instead of using Del for clearing keys. This is more performant but may not be supported by all Redis versions.
|
||||
*/
|
||||
useUnlink?: boolean;
|
||||
/**
|
||||
* Whether to allow clearing all keys when no namespace is set.
|
||||
* If set to true and no namespace is set, iterate() will return all keys.
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
noNamespaceAffectsAll?: boolean;
|
||||
keyPrefix?: string;
|
||||
};
|
||||
/**
|
||||
* An optional Valkey cluster (redis cluster under the hood) configuration.
|
||||
|
||||
@@ -336,11 +336,17 @@ describe('CacheManager store options', () => {
|
||||
|
||||
it('correctly applies namespace configuration to redis and valkey stores', () => {
|
||||
const testCases = [
|
||||
{ store: 'redis', namespace: 'test1', separator: ':' },
|
||||
{ store: 'valkey', namespace: 'test2', separator: '!' },
|
||||
{
|
||||
store: 'redis',
|
||||
client: {
|
||||
namespace: 'my-app',
|
||||
keyPrefixSeparator: ':',
|
||||
},
|
||||
},
|
||||
{ store: 'valkey', client: { keyPrefix: 'my-app:' } },
|
||||
];
|
||||
|
||||
testCases.forEach(({ store, namespace, separator }) => {
|
||||
testCases.forEach(({ store, client }) => {
|
||||
const manager = CacheManager.fromConfig(
|
||||
mockServices.rootConfig({
|
||||
data: {
|
||||
@@ -349,10 +355,7 @@ describe('CacheManager store options', () => {
|
||||
store,
|
||||
connection: 'redis://localhost:6379',
|
||||
[store]: {
|
||||
client: {
|
||||
namespace,
|
||||
keyPrefixSeparator: separator,
|
||||
},
|
||||
client,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -364,16 +367,16 @@ describe('CacheManager store options', () => {
|
||||
|
||||
if (store === 'redis') {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(KeyvRedis).toHaveBeenCalledWith('redis://localhost:6379', {
|
||||
namespace,
|
||||
keyPrefixSeparator: separator,
|
||||
});
|
||||
expect(KeyvRedis).toHaveBeenCalledWith(
|
||||
'redis://localhost:6379',
|
||||
client,
|
||||
);
|
||||
} else if (store === 'valkey') {
|
||||
// eslint-disable-next-line jest/no-conditional-expect
|
||||
expect(KeyvValkey).toHaveBeenCalledWith('redis://localhost:6379', {
|
||||
namespace,
|
||||
keyPrefixSeparator: separator,
|
||||
});
|
||||
expect(KeyvValkey).toHaveBeenCalledWith(
|
||||
'redis://localhost:6379',
|
||||
client,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -408,21 +411,31 @@ describe('CacheManager store options', () => {
|
||||
expect(result).toBe('testPlugin');
|
||||
});
|
||||
|
||||
it('returns pluginId when store options have no namespace', () => {
|
||||
const storeOptions = {
|
||||
client: {
|
||||
keyPrefixSeparator: ':',
|
||||
},
|
||||
};
|
||||
const result = (CacheManager as any).constructNamespace(
|
||||
'testPlugin',
|
||||
storeOptions,
|
||||
);
|
||||
expect(result).toBe('testPlugin');
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
type: 'redis',
|
||||
field: 'namespace',
|
||||
client: { keyPrefixSeparator: ':' },
|
||||
},
|
||||
{ type: 'valkey', field: 'keyPrefix', client: {} },
|
||||
])(
|
||||
'returns pluginId when store options have no $field for $type',
|
||||
({ type, client }) => {
|
||||
const storeOptions = {
|
||||
type,
|
||||
client,
|
||||
};
|
||||
const result = (CacheManager as any).constructNamespace(
|
||||
'testPlugin',
|
||||
storeOptions,
|
||||
);
|
||||
expect(result).toBe('testPlugin');
|
||||
},
|
||||
);
|
||||
|
||||
it('combines namespace and pluginId with default separator', () => {
|
||||
it('combines namespace and pluginId with default separator for redis', () => {
|
||||
const storeOptions = {
|
||||
type: 'redis',
|
||||
client: {
|
||||
namespace: 'my-app',
|
||||
keyPrefixSeparator: ':',
|
||||
@@ -435,8 +448,9 @@ describe('CacheManager store options', () => {
|
||||
expect(result).toBe('my-app:testPlugin');
|
||||
});
|
||||
|
||||
it('combines namespace and pluginId with custom separator', () => {
|
||||
it('combines namespace and pluginId with custom separator for redis', () => {
|
||||
const storeOptions = {
|
||||
type: 'redis',
|
||||
client: {
|
||||
namespace: 'my-app',
|
||||
keyPrefixSeparator: '-',
|
||||
@@ -449,8 +463,9 @@ describe('CacheManager store options', () => {
|
||||
expect(result).toBe('my-app-testPlugin');
|
||||
});
|
||||
|
||||
it('uses default separator when keyPrefixSeparator is not provided', () => {
|
||||
it('uses default separator when keyPrefixSeparator is not provided for redis', () => {
|
||||
const storeOptions = {
|
||||
type: 'redis',
|
||||
client: {
|
||||
namespace: 'my-app',
|
||||
},
|
||||
@@ -462,6 +477,20 @@ describe('CacheManager store options', () => {
|
||||
expect(result).toBe('my-app:testPlugin');
|
||||
});
|
||||
|
||||
it('uses keyPrefix for valkey', () => {
|
||||
const storeOptions = {
|
||||
type: 'valkey',
|
||||
client: {
|
||||
keyPrefix: 'my-app:',
|
||||
},
|
||||
};
|
||||
const result = (CacheManager as any).constructNamespace(
|
||||
'testPlugin',
|
||||
storeOptions,
|
||||
);
|
||||
expect(result).toBe('my-app:testPlugin');
|
||||
});
|
||||
|
||||
it('handles empty namespace by falling back to pluginId', () => {
|
||||
const storeOptions = {
|
||||
client: {
|
||||
|
||||
+80
-29
@@ -29,6 +29,7 @@ import {
|
||||
RedisCacheStoreOptions,
|
||||
InfinispanClientBehaviorOptions,
|
||||
InfinispanServerConfig,
|
||||
ValkeyCacheStoreOptions,
|
||||
} from './types';
|
||||
import { InfinispanOptionsMapper } from './providers/infinispan/InfinispanOptionsMapper';
|
||||
import { durationToMilliseconds } from '@backstage/types';
|
||||
@@ -140,37 +141,32 @@ export class CacheManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (store === 'redis' || store === 'valkey') {
|
||||
return CacheManager.parseRedisOptions(
|
||||
store,
|
||||
storeConfigPath,
|
||||
config,
|
||||
logger,
|
||||
);
|
||||
switch (store) {
|
||||
case 'redis':
|
||||
return CacheManager.parseRedisOptions(storeConfigPath, config, logger);
|
||||
case 'valkey':
|
||||
return CacheManager.parseValkeyOptions(storeConfigPath, config, logger);
|
||||
case 'infinispan':
|
||||
return InfinispanOptionsMapper.parseInfinispanOptions(
|
||||
storeConfigPath,
|
||||
config,
|
||||
logger,
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (store === 'infinispan') {
|
||||
return InfinispanOptionsMapper.parseInfinispanOptions(
|
||||
storeConfigPath,
|
||||
config,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Redis-specific options from configuration.
|
||||
*/
|
||||
private static parseRedisOptions(
|
||||
store: string,
|
||||
storeConfigPath: string,
|
||||
config: RootConfigService,
|
||||
logger?: LoggerService,
|
||||
): RedisCacheStoreOptions {
|
||||
const redisOptions: RedisCacheStoreOptions = {
|
||||
type: store as 'redis' | 'valkey',
|
||||
type: 'redis',
|
||||
};
|
||||
|
||||
const redisConfig =
|
||||
@@ -213,6 +209,51 @@ export class CacheManager {
|
||||
return redisOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Valkey-specific options from configuration.
|
||||
*/
|
||||
private static parseValkeyOptions(
|
||||
storeConfigPath: string,
|
||||
config: RootConfigService,
|
||||
logger?: LoggerService,
|
||||
): ValkeyCacheStoreOptions {
|
||||
const valkeyOptions: ValkeyCacheStoreOptions = {
|
||||
type: 'valkey',
|
||||
};
|
||||
|
||||
const valkeyConfig =
|
||||
config.getOptionalConfig(storeConfigPath) ?? new ConfigReader({});
|
||||
|
||||
valkeyOptions.client = {
|
||||
keyPrefix: valkeyConfig.getOptionalString('client.keyPrefix'),
|
||||
};
|
||||
|
||||
if (valkeyConfig.has('cluster')) {
|
||||
const clusterConfig = valkeyConfig.getConfig('cluster');
|
||||
|
||||
if (!clusterConfig.has('rootNodes')) {
|
||||
logger?.warn(
|
||||
`Valkey cluster config has no 'rootNodes' key, defaulting to non-clustered mode`,
|
||||
);
|
||||
return valkeyOptions;
|
||||
}
|
||||
|
||||
valkeyOptions.cluster = {
|
||||
rootNodes: clusterConfig.get('rootNodes'),
|
||||
defaults: clusterConfig.getOptional('defaults'),
|
||||
minimizeConnections: clusterConfig.getOptionalBoolean(
|
||||
'minimizeConnections',
|
||||
),
|
||||
useReplicas: clusterConfig.getOptionalBoolean('useReplicas'),
|
||||
maxCommandRedirections: clusterConfig.getOptionalNumber(
|
||||
'maxCommandRedirections',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return valkeyOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the full namespace based on the options and pluginId.
|
||||
*
|
||||
@@ -222,13 +263,23 @@ export class CacheManager {
|
||||
*/
|
||||
private static constructNamespace(
|
||||
pluginId: string,
|
||||
storeOptions: RedisCacheStoreOptions | undefined,
|
||||
storeOptions: RedisCacheStoreOptions | ValkeyCacheStoreOptions | undefined,
|
||||
): string {
|
||||
const prefix = storeOptions?.client?.namespace
|
||||
? `${storeOptions.client.namespace}${
|
||||
storeOptions.client.keyPrefixSeparator ?? ':'
|
||||
}`
|
||||
: '';
|
||||
let prefix: string;
|
||||
switch (storeOptions?.type) {
|
||||
case 'redis':
|
||||
prefix = storeOptions?.client?.namespace
|
||||
? `${storeOptions.client.namespace}${
|
||||
storeOptions.client.keyPrefixSeparator ?? ':'
|
||||
}`
|
||||
: '';
|
||||
break;
|
||||
case 'valkey':
|
||||
prefix = storeOptions.client?.keyPrefix ?? '';
|
||||
break;
|
||||
default:
|
||||
prefix = '';
|
||||
}
|
||||
|
||||
return `${prefix}${pluginId}`;
|
||||
}
|
||||
@@ -317,7 +368,9 @@ export class CacheManager {
|
||||
|
||||
private createValkeyStoreFactory(): StoreFactory {
|
||||
const KeyvValkey = require('@keyv/valkey').default;
|
||||
const { createCluster } = require('@keyv/valkey');
|
||||
// `@keyv/valkey` doesn't export a `createCluster` function, but is compatible with the one from `@keyv/redis`
|
||||
// See https://keyv.org/docs/storage-adapters/valkey
|
||||
const { createCluster } = require('@keyv/redis');
|
||||
const stores: Record<string, typeof KeyvValkey> = {};
|
||||
|
||||
return (pluginId, defaultTtl) => {
|
||||
@@ -327,9 +380,7 @@ export class CacheManager {
|
||||
);
|
||||
}
|
||||
if (!stores[pluginId]) {
|
||||
const valkeyOptions = this.storeOptions?.client || {
|
||||
keyPrefixSeparator: ':',
|
||||
};
|
||||
const valkeyOptions = this.storeOptions?.client;
|
||||
if (this.storeOptions?.cluster) {
|
||||
// Create a Valkey cluster (Redis cluster under the hood)
|
||||
const cluster = createCluster(this.storeOptions?.cluster);
|
||||
|
||||
+14
-1
@@ -17,6 +17,7 @@
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
|
||||
import { RedisClusterOptions, KeyvRedisOptions } from '@keyv/redis';
|
||||
import { KeyvValkeyOptions } from '@keyv/valkey';
|
||||
|
||||
/**
|
||||
* Options for Redis cache store.
|
||||
@@ -24,11 +25,22 @@ import { RedisClusterOptions, KeyvRedisOptions } from '@keyv/redis';
|
||||
* @public
|
||||
*/
|
||||
export type RedisCacheStoreOptions = {
|
||||
type: 'redis' | 'valkey';
|
||||
type: 'redis';
|
||||
client?: KeyvRedisOptions;
|
||||
cluster?: RedisClusterOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for Valkey cache store.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ValkeyCacheStoreOptions = {
|
||||
type: 'valkey';
|
||||
client?: KeyvValkeyOptions;
|
||||
cluster?: RedisClusterOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type of all cache store options.
|
||||
*
|
||||
@@ -36,6 +48,7 @@ export type RedisCacheStoreOptions = {
|
||||
*/
|
||||
export type CacheStoreOptions =
|
||||
| RedisCacheStoreOptions
|
||||
| ValkeyCacheStoreOptions
|
||||
| InfinispanCacheStoreOptions;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user