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
2 changes: 1 addition & 1 deletion redisinsight/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"lodash": "^4.17.20",
"nest-router": "^1.0.9",
"nest-winston": "^1.4.0",
"node-version-compare": "^1.0.3",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.5.6",
"socket.io": "^4.4.0",
Expand Down Expand Up @@ -102,7 +103,6 @@
"mocha": "^8.4.0",
"mocha-junit-reporter": "^2.0.0",
"mocha-multi-reporters": "^1.5.1",
"node-version-compare": "^1.0.3",
"nyc": "^15.1.0",
"object-diff": "^0.0.4",
"rimraf": "^3.0.2",
Expand Down
2 changes: 2 additions & 0 deletions redisinsight/api/src/constants/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ export const RECOMMENDATION_NAMES = Object.freeze({
ZSET_HASHTABLE_TO_ZIPLIST: 'zSetHashtableToZiplist',
SET_PASSWORD: 'setPassword',
RTS: 'RTS',
REDIS_VERSION: 'redisVersion',
REDIS_SEARCH: 'redisSearch',
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const mockRedisConfigResponse = ['name', '512'];
const mockRedisClientsResponse_1: string = '# Clients\r\nconnected_clients:100\r\n';
const mockRedisClientsResponse_2: string = '# Clients\r\nconnected_clients:101\r\n';

const mockRedisServerResponse_1: string = '# Server\r\nredis_version:6.0.0\r\n';
const mockRedisServerResponse_2: string = '# Server\r\nredis_version:5.1.1\r\n';

const mockRedisAclListResponse_1: string[] = [
'user <pass off resetchannels -@all',
'user default on #d74ff0ee8da3b9806b18c877dbf29bbde50b5bd8e4dad7a3a725000feb82e8f1 ~* &* +@all',
Expand All @@ -31,6 +34,9 @@ const mockRedisAclListResponse_2: string[] = [
'user test_2 on nopass ~* &* +@all',
];

const mockFTListResponse_1 = [];
const mockFTListResponse_2 = ['idx'];

const mockZScanResponse_1 = [
'0',
[123456789, 123456789, 12345678910, 12345678910],
Expand Down Expand Up @@ -103,6 +109,18 @@ const mockBigListKey = {
name: Buffer.from('name'), type: 'list', length: 1001, memory: 10, ttl: -1,
};

const mockJSONKey = {
name: Buffer.from('name'), type: 'ReJSON-RL', length: 1, memory: 10, ttl: -1,
};

const mockRediSearchStringKey_1 = {
name: Buffer.from('name'), type: 'string', length: 1, memory: 512 * 1024 + 1, ttl: -1,
};

const mockRediSearchStringKey_2 = {
name: Buffer.from('name'), type: 'string', length: 1, memory: 512 * 1024, ttl: -1,
};

const mockSortedSets = new Array(101).fill(
{
name: Buffer.from('name'), type: 'zset', length: 10, memory: 10, ttl: -1,
Expand Down Expand Up @@ -525,4 +543,100 @@ describe('RecommendationProvider', () => {
expect(RTSRecommendation).toEqual(null);
});
});

describe('determineRediSearchRecommendation', () => {
it('should return rediSearch recommendation when there is JSON key', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT._LIST' }))
.mockResolvedValue(mockFTListResponse_1);

const redisServerRecommendation = await service
.determineRediSearchRecommendation(nodeClient, [mockJSONKey]);
expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH });
});

it('should return rediSearch recommendation when there is huge string key', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT._LIST' }))
.mockResolvedValue(mockFTListResponse_1);

const redisServerRecommendation = await service
.determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_1]);
expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH });
});

it('should not return rediSearch recommendation when there is small string key', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT._LIST' }))
.mockResolvedValue(mockFTListResponse_1);

const redisServerRecommendation = await service
.determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]);
expect(redisServerRecommendation).toEqual(null);
});

it('should not return rediSearch recommendation when there are no indexes', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT._LIST' }))
.mockResolvedValue(mockFTListResponse_2);

const redisServerRecommendation = await service
.determineRediSearchRecommendation(nodeClient, [mockJSONKey]);
expect(redisServerRecommendation).toEqual(null);
});

it('should ignore errors when ft command execute with error', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT._LIST' }))
.mockRejectedValue("some error");

const redisServerRecommendation = await service
.determineRediSearchRecommendation(nodeClient, [mockJSONKey]);
expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_SEARCH });
});

it('should ignore errors when ft command execute with error', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT._LIST' }))
.mockRejectedValue("some error");

const redisServerRecommendation = await service
.determineRediSearchRecommendation(nodeClient, [mockRediSearchStringKey_2]);
expect(redisServerRecommendation).toEqual(null);
});
});

describe('determineRedisVersionRecommendation', () => {
it('should not return redis version recommendation', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'info' }))
.mockResolvedValue(mockRedisServerResponse_1);

const redisServerRecommendation = await service
.determineRedisVersionRecommendation(nodeClient);
expect(redisServerRecommendation).toEqual(null);
});

it('should return redis version recommendation', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'info' }))
.mockResolvedValueOnce(mockRedisServerResponse_2);

const redisServerRecommendation = await service
.determineRedisVersionRecommendation(nodeClient);
expect(redisServerRecommendation).toEqual({ name: RECOMMENDATION_NAMES.REDIS_VERSION });
});

it('should not return redis version recommendation when info command executed with error',
async () => {
resetAllWhenMocks();
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'info' }))
.mockRejectedValue('some error');

const redisServerRecommendation = await service
.determineRedisVersionRecommendation(nodeClient);
expect(redisServerRecommendation).toEqual(null);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Redis, Cluster, Command } from 'ioredis';
import { get } from 'lodash';
import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils';
import * as semverCompare from 'node-version-compare';
import { convertRedisInfoReplyToObject, convertBulkStringsToObject, convertStringsArrayToObject } from 'src/utils';
import { RECOMMENDATION_NAMES, IS_TIMESTAMP } from 'src/constants';
import { RedisDataType } from 'src/modules/browser/dto';
import { Recommendation } from 'src/modules/database-analysis/models/recommendation';
Expand All @@ -15,8 +16,10 @@ const maxCompressHashLength = 1000;
const maxListLength = 1000;
const maxSetLength = 5000;
const maxConnectedClients = 100;
const maxRediSearchStringMemory = 512 * 1024;
const bigStringMemory = 5_000_000;
const sortedSetCountForCheck = 100;
const minRedisVersion = '6';

@Injectable()
export class RecommendationProvider {
Expand Down Expand Up @@ -283,33 +286,7 @@ export class RecommendationProvider {
}

/**
* Check set password recommendation
* @param redisClient
*/

async determineSetPasswordRecommendation(
redisClient: Redis | Cluster,
): Promise<Recommendation> {
if (await this.checkAuth(redisClient)) {
return { name: RECOMMENDATION_NAMES.SET_PASSWORD };
}

try {
const users = await redisClient.sendCommand(
new Command('acl', ['list'], { replyEncoding: 'utf8' }),
) as string[];

const nopassUser = users.some((user) => user.split(' ')[3] === 'nopass');

return nopassUser ? { name: RECOMMENDATION_NAMES.SET_PASSWORD } : null;
} catch (err) {
this.logger.error('Can not determine set password recommendation', err);
return null;
}
}

/**
* Check set password recommendation
* Check RTS recommendation
* @param redisClient
* @param keys
*/
Expand Down Expand Up @@ -350,6 +327,88 @@ export class RecommendationProvider {
}
}

/**
* Check redis search recommendation
* @param redisClient
* @param keys
*/

async determineRediSearchRecommendation(
redisClient: Redis | Cluster,
keys: Key[],
): Promise<Recommendation> {
try {
try {
const indexes = await redisClient.sendCommand(
new Command('FT._LIST', [], { replyEncoding: 'utf8' }),
) as any[];
if (indexes.length) {
return null;
}
} catch (err) {
// Ignore errors
}

const isBigStringOrJSON = keys.some((key) => (
key.type === RedisDataType.String && key.memory > maxRediSearchStringMemory
)
|| key.type === RedisDataType.JSON);

return isBigStringOrJSON ? { name: RECOMMENDATION_NAMES.REDIS_SEARCH } : null;
} catch (err) {
this.logger.error('Can not determine redis search recommendation', err);
return null;
}
}

/**
* Check redis version recommendation
* @param redisClient
*/

async determineRedisVersionRecommendation(
redisClient: Redis | Cluster,
): Promise<Recommendation> {
try {
const info = convertRedisInfoReplyToObject(
await redisClient.sendCommand(
new Command('info', ['server'], { replyEncoding: 'utf8' }),
) as string,
);
const version = get(info, 'server.redis_version');
return semverCompare(version, minRedisVersion) >= 0 ? null : { name: RECOMMENDATION_NAMES.REDIS_VERSION };
} catch (err) {
this.logger.error('Can not determine redis version recommendation', err);
return null;
}
}

/**
* Check set password recommendation
* @param redisClient
*/

async determineSetPasswordRecommendation(
redisClient: Redis | Cluster,
): Promise<Recommendation> {
if (await this.checkAuth(redisClient)) {
return { name: RECOMMENDATION_NAMES.SET_PASSWORD };
}

try {
const users = await redisClient.sendCommand(
new Command('acl', ['list'], { replyEncoding: 'utf8' }),
) as string[];

const nopassUser = users.some((user) => user.split(' ')[3] === 'nopass');

return nopassUser ? { name: RECOMMENDATION_NAMES.SET_PASSWORD } : null;
} catch (err) {
this.logger.error('Can not determine set password recommendation', err);
return null;
}
}

private async checkAuth(redisClient: Redis | Cluster): Promise<boolean> {
try {
await redisClient.sendCommand(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ export class RecommendationService {
await this.recommendationProvider.determineZSetHashtableToZiplistRecommendation(client, keys),
await this.recommendationProvider.determineBigSetsRecommendation(keys),
await this.recommendationProvider.determineConnectionClientsRecommendation(client),
await this.recommendationProvider.determineSetPasswordRecommendation(client),
// TODO rework, need better solution to do not start determine recommendation
exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys),
await this.recommendationProvider.determineRediSearchRecommendation(client, keys),
await this.recommendationProvider.determineRedisVersionRecommendation(client),
await this.recommendationProvider.determineSetPasswordRecommendation(client),
]));
}
}
Loading