fix(cache): improve support for Valkey
Signed-off-by: Benjamin Janssens <benji.janssens@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Improved support for Valkey
|
||||
@@ -27,6 +27,9 @@ backend:
|
||||
# Other Redis-specific options...
|
||||
clearBatchSize: 1000
|
||||
useUnlink: false
|
||||
valkey:
|
||||
# Optional: Global namespace prefix for all cache keys (including separator used between namespace and plugin ID)
|
||||
keyPrefix: 'my-app:'
|
||||
```
|
||||
|
||||
### Namespace Configuration
|
||||
@@ -36,7 +39,7 @@ For Redis and Valkey stores, you can configure a global namespace that will be p
|
||||
- **Without namespace**: Cache keys use only the plugin ID (e.g., `catalog:some-key`)
|
||||
- **With namespace**: Cache keys use the format `namespace:pluginId:key` (e.g., `my-app:catalog:some-key`)
|
||||
|
||||
The `keyPrefixSeparator` controls what character is used between the namespace and plugin ID (defaults to `:`).
|
||||
For Redis, `keyPrefixSeparator` controls what character is used between the namespace and plugin ID (defaults to `:`).
|
||||
|
||||
**Note**: Memory and Memcache stores do not support namespace configuration and will always use the plugin ID directly.
|
||||
|
||||
|
||||
+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,8 +411,9 @@ describe('CacheManager store options', () => {
|
||||
expect(result).toBe('testPlugin');
|
||||
});
|
||||
|
||||
it('returns pluginId when store options have no namespace', () => {
|
||||
it('returns pluginId when store options have no namespace (redis)', () => {
|
||||
const storeOptions = {
|
||||
type: 'redis',
|
||||
client: {
|
||||
keyPrefixSeparator: ':',
|
||||
},
|
||||
@@ -421,8 +425,9 @@ describe('CacheManager store options', () => {
|
||||
expect(result).toBe('testPlugin');
|
||||
});
|
||||
|
||||
it('combines namespace and pluginId with default separator', () => {
|
||||
it('combines namespace and pluginId with default separator (redis)', () => {
|
||||
const storeOptions = {
|
||||
type: 'redis',
|
||||
client: {
|
||||
namespace: 'my-app',
|
||||
keyPrefixSeparator: ':',
|
||||
@@ -435,8 +440,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 (redis)', () => {
|
||||
const storeOptions = {
|
||||
type: 'redis',
|
||||
client: {
|
||||
namespace: 'my-app',
|
||||
keyPrefixSeparator: '-',
|
||||
@@ -449,8 +455,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 (redis)', () => {
|
||||
const storeOptions = {
|
||||
type: 'redis',
|
||||
client: {
|
||||
namespace: 'my-app',
|
||||
},
|
||||
@@ -462,6 +469,31 @@ describe('CacheManager store options', () => {
|
||||
expect(result).toBe('my-app:testPlugin');
|
||||
});
|
||||
|
||||
it('returns pluginId when store options have no keyPrefix (valkey)', () => {
|
||||
const storeOptions = {
|
||||
type: 'valkey',
|
||||
};
|
||||
const result = (CacheManager as any).constructNamespace(
|
||||
'testPlugin',
|
||||
storeOptions,
|
||||
);
|
||||
expect(result).toBe('testPlugin');
|
||||
});
|
||||
|
||||
it('uses keyPrefix (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: {
|
||||
|
||||
+90
-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,43 @@ export class CacheManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (store === 'redis' || store === 'valkey') {
|
||||
return CacheManager.parseRedisOptions(
|
||||
store,
|
||||
storeConfigPath,
|
||||
config,
|
||||
logger,
|
||||
);
|
||||
switch (store) {
|
||||
case 'redis':
|
||||
return CacheManager.parseRedisOptions(
|
||||
store,
|
||||
storeConfigPath,
|
||||
config,
|
||||
logger,
|
||||
);
|
||||
case 'valkey':
|
||||
return CacheManager.parseValkeyOptions(
|
||||
store,
|
||||
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,
|
||||
store: 'redis',
|
||||
storeConfigPath: string,
|
||||
config: RootConfigService,
|
||||
logger?: LoggerService,
|
||||
): RedisCacheStoreOptions {
|
||||
const redisOptions: RedisCacheStoreOptions = {
|
||||
type: store as 'redis' | 'valkey',
|
||||
type: store,
|
||||
};
|
||||
|
||||
const redisConfig =
|
||||
@@ -213,6 +220,52 @@ export class CacheManager {
|
||||
return redisOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Valkey-specific options from configuration.
|
||||
*/
|
||||
private static parseValkeyOptions(
|
||||
store: 'valkey',
|
||||
storeConfigPath: string,
|
||||
config: RootConfigService,
|
||||
logger?: LoggerService,
|
||||
): ValkeyCacheStoreOptions {
|
||||
const valkeyOptions: ValkeyCacheStoreOptions = {
|
||||
type: store,
|
||||
};
|
||||
|
||||
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(
|
||||
`Redis 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 +275,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 +380,7 @@ export class CacheManager {
|
||||
|
||||
private createValkeyStoreFactory(): StoreFactory {
|
||||
const KeyvValkey = require('@keyv/valkey').default;
|
||||
const { createCluster } = require('@keyv/valkey');
|
||||
const { createCluster } = require('@keyv/redis');
|
||||
const stores: Record<string, typeof KeyvValkey> = {};
|
||||
|
||||
return (pluginId, defaultTtl) => {
|
||||
@@ -327,9 +390,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