fix: use iovalkey Cluster for Valkey cluster mode

Signed-off-by: Shamil Ganiev <ganiev@pm.me>
This commit is contained in:
Shamil Ganiev
2026-04-14 15:30:31 +03:00
parent 7bc057e8b6
commit d00a44bc12
6 changed files with 147 additions and 16 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/backend-defaults': patch
---
Fixed Valkey cluster mode to use `iovalkey`'s `Cluster` class instead of
`createCluster` from `@keyv/redis`. The previous implementation passed a
`@redis/client` `RedisCluster` instance to `@keyv/valkey`, which expects an
`iovalkey` `Cluster` instance. This caused the cluster client to not be
recognized correctly, as the two libraries have incompatible object models.
+1
View File
@@ -170,6 +170,7 @@
"git-url-parse": "^15.0.0",
"helmet": "^6.0.0",
"infinispan": "^0.12.0",
"iovalkey": "^0.3.3",
"is-glob": "^4.0.3",
"jose": "^5.0.0",
"keyv": "^5.2.1",
@@ -18,6 +18,7 @@ import { mockServices, TestCaches } from '@backstage/backend-test-utils';
import KeyvRedis, { createCluster } from '@keyv/redis';
import KeyvValkey from '@keyv/valkey';
import KeyvMemcache from '@keyv/memcache';
import { Cluster as ValkeyCluster } from 'iovalkey';
import { CacheManager } from './CacheManager';
// This test is in a separate file because the main test file uses other mocking
@@ -41,7 +42,13 @@ jest.mock('@keyv/valkey', () => {
...Actual,
__esModule: true,
default: jest.fn((...args: any[]) => new DefaultConstructor(...args)),
createCluster: jest.fn(),
};
});
jest.mock('iovalkey', () => {
const Actual = jest.requireActual('iovalkey');
return {
...Actual,
Cluster: jest.fn(),
};
});
jest.mock('@keyv/memcache', () => {
@@ -211,6 +218,8 @@ describe('CacheManager integration', () => {
});
describe('CacheManager store options', () => {
afterEach(jest.clearAllMocks);
it('uses default options when no store-specific config exists', () => {
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
@@ -307,6 +316,9 @@ describe('CacheManager store options', () => {
});
it('accepts client config for clustered mode', () => {
const clusterInstance = { fake: 'cluster' };
(createCluster as jest.Mock).mockReturnValue(clusterInstance);
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
data: {
@@ -329,11 +341,112 @@ describe('CacheManager store options', () => {
);
manager.forPlugin('p1');
expect(KeyvRedis).toHaveBeenCalledWith(expect.anything(), {
expect(KeyvRedis).toHaveBeenCalledWith(clusterInstance, {
keyPrefixSeparator: '!',
});
});
it('uses iovalkey Cluster for valkey cluster mode', () => {
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'valkey',
connection: 'redis://localhost:6379',
valkey: {
cluster: {
rootNodes: [
{ host: 'localhost', port: 6379 },
{ host: 'localhost', port: 6380 },
],
},
},
},
},
},
}),
);
manager.forPlugin('p1');
expect(ValkeyCluster).toHaveBeenCalledWith(
[
{ host: 'localhost', port: 6379 },
{ host: 'localhost', port: 6380 },
],
{
redisOptions: undefined,
scaleReads: undefined,
maxRedirections: undefined,
lazyConnect: undefined,
},
);
expect(KeyvValkey).toHaveBeenCalledWith(expect.any(Object), {
keyPrefix: undefined,
});
});
it('passes valkey cluster options from config', () => {
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'valkey',
connection: 'redis://localhost:6379',
valkey: {
client: { keyPrefix: 'my-app:' },
cluster: {
rootNodes: [{ host: 'localhost', port: 6379 }],
useReplicas: true,
maxCommandRedirections: 5,
},
},
},
},
},
}),
);
manager.forPlugin('p1');
expect(ValkeyCluster).toHaveBeenCalledWith(
[{ host: 'localhost', port: 6379 }],
{
redisOptions: undefined,
scaleReads: 'slave',
maxRedirections: 5,
lazyConnect: undefined,
},
);
expect(KeyvValkey).toHaveBeenCalledWith(expect.any(Object), {
keyPrefix: 'my-app:',
});
});
it('defaults to non-clustered valkey when cluster config is missing root nodes', () => {
const manager = CacheManager.fromConfig(
mockServices.rootConfig({
data: {
backend: {
cache: {
store: 'valkey',
connection: 'redis://localhost:6379',
valkey: {
cluster: {},
},
},
},
},
}),
);
manager.forPlugin('p1');
expect(ValkeyCluster).not.toHaveBeenCalled();
expect(KeyvValkey).toHaveBeenCalledWith('redis://localhost:6379', {
keyPrefix: undefined,
});
});
it('correctly applies namespace configuration to redis and valkey stores', () => {
const testCases = [
{
@@ -240,14 +240,16 @@ export class CacheManager {
valkeyOptions.cluster = {
rootNodes: clusterConfig.get('rootNodes'),
defaults: clusterConfig.getOptional('defaults'),
minimizeConnections: clusterConfig.getOptionalBoolean(
'minimizeConnections',
),
useReplicas: clusterConfig.getOptionalBoolean('useReplicas'),
maxCommandRedirections: clusterConfig.getOptionalNumber(
'maxCommandRedirections',
),
options: {
redisOptions: clusterConfig.getOptional('defaults'),
scaleReads: clusterConfig.getOptionalBoolean('useReplicas')
? 'slave'
: undefined,
maxRedirections: clusterConfig.getOptionalNumber(
'maxCommandRedirections',
),
lazyConnect: clusterConfig.getOptionalBoolean('minimizeConnections'),
},
};
}
@@ -368,9 +370,7 @@ export class CacheManager {
private createValkeyStoreFactory(): StoreFactory {
const KeyvValkey = require('@keyv/valkey').default;
// `@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 { Cluster } = require('iovalkey');
const stores: Record<string, typeof KeyvValkey> = {};
return (pluginId, defaultTtl) => {
@@ -382,8 +382,11 @@ export class CacheManager {
if (!stores[pluginId]) {
const valkeyOptions = this.storeOptions?.client;
if (this.storeOptions?.cluster) {
// Create a Valkey cluster (Redis cluster under the hood)
const cluster = createCluster(this.storeOptions?.cluster);
// Create an iovalkey Cluster instance, which is the type that @keyv/valkey expects
const cluster = new Cluster(
this.storeOptions.cluster.rootNodes,
this.storeOptions.cluster.options,
);
stores[pluginId] = new KeyvValkey(cluster, valkeyOptions);
} else {
// Create a regular Valkey connection
+5 -1
View File
@@ -18,6 +18,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';
import { ClusterNode, ClusterOptions } from 'iovalkey';
/**
* Options for Redis cache store.
@@ -38,7 +39,10 @@ export type RedisCacheStoreOptions = {
export type ValkeyCacheStoreOptions = {
type: 'valkey';
client?: KeyvValkeyOptions;
cluster?: RedisClusterOptions;
cluster?: {
rootNodes: Array<ClusterNode>;
options?: ClusterOptions;
};
};
/**
+1
View File
@@ -2566,6 +2566,7 @@ __metadata:
helmet: "npm:^6.0.0"
http-errors: "npm:^2.0.0"
infinispan: "npm:^0.12.0"
iovalkey: "npm:^0.3.3"
is-glob: "npm:^4.0.3"
jose: "npm:^5.0.0"
keyv: "npm:^5.2.1"