Merge pull request #31497 from benjidotsh/cache/improved-valkey-support

This commit is contained in:
Fredrik Adelöw
2025-12-15 23:12:33 +01:00
committed by GitHub
6 changed files with 244 additions and 86 deletions
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/backend-defaults': minor
---
**BREAKING** The correct configuration options for Valkey are now being used.
These changes are **required** to `app-config.yaml`:
```diff
backend:
cache:
store: valkey
connection: ...
client:
- namespace: 'my-app'
- keyPrefixSeparator: ':'
+ keyPrefix: 'my-app:'
- clearBatchSize: 1000
- useUnlink: false
```
In comparison to Redis, Valkey requires the full `keyPrefix` including the separator to be specified instead of separate `namespace` and `keyPrefixSeparator` options. Also, Valkey does not support the `clearBatchSize` and `useUnlink` options.
+67 -6
View File
@@ -11,24 +11,84 @@ This service lets your plugin interact with a cache. It is bound to your plugin
The cache service can be configured using the `backend.cache` section in your `app-config.yaml`:
### In-Memory (default)
```yaml
backend:
cache:
store: redis # or 'valkey', 'memcache', 'memory'
store: memory
```
### Memcache
```yaml
backend:
cache:
store: memcache
connection: user:pass@cache.example.com:11211
```
### Redis
```yaml
backend:
cache:
store: redis
connection: redis://localhost:6379
# Store-specific configuration (Redis/Valkey only)
# Store-specific configuration (optional)
redis:
client:
# Optional: Global namespace prefix for all cache keys
# Global namespace prefix for all cache keys
namespace: 'my-app'
# Optional: Separator used between namespace and plugin ID (default: ':')
# Separator used between namespace and plugin ID (default: ':')
keyPrefixSeparator: ':'
# Other Redis-specific options...
clearBatchSize: 1000
useUnlink: false
```
### Valkey
```yaml
backend:
cache:
store: valkey
connection: redis://localhost:6379
# Store-specific configuration (optional)
valkey:
# Global namespace prefix for all cache keys (including separator used between namespace and plugin ID)
keyPrefix: 'my-app:'
```
### Infinispan
```yaml
backend:
cache:
store: infinispan
# Store-specific configuration (optional)
infinispan:
servers:
# IP address or hostname of the server (default: '127.0.0.1')
- host: 127.0.0.1
# Port number of the server (default: '11222')
port: 11222
# Name of the cache (default: 'cache')
cacheName: cache
mediaType: application/json
authentication:
# Whether authentication is enabled (default: 'false')
enabled: true
userName: yourusername
password: yourpassword
saslMechanism: PLAIN
```
A full list of configuration items is available [here](https://docs.jboss.org/infinispan/hotrod-clients/javascript/1.0/apidocs/module-infinispan.html), including support for backup clusters.
### Namespace Configuration
For Redis and Valkey stores, you can configure a global namespace that will be prefixed to all cache keys:
@@ -36,9 +96,10 @@ 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 `:`).
For **Valkey**, you set the full `keyPrefix` including the separator.
**Note**: Memory and Memcache stores do not support namespace configuration and will always use the plugin ID directly.
**Note**: In-memory, Memcache and Infinispan stores do not support namespace configuration and will always use the plugin ID directly.
## Using the service
+2 -20
View File
@@ -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: {
@@ -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
View File
@@ -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;
/**