Skip to content

Commit 40025e8

Browse files
Merge pull request #1529 from RedisInsight/be/feature/RI-3942_rts_recommendation
#RI-3942 - add rts recommendation
2 parents e2c9e88 + 70408ee commit 40025e8

File tree

8 files changed

+153
-4
lines changed

8 files changed

+153
-4
lines changed

redisinsight/api/src/constants/recommendations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export const RECOMMENDATION_NAMES = Object.freeze({
1313
COMPRESSION_FOR_LIST: 'compressionForList',
1414
ZSET_HASHTABLE_TO_ZIPLIST: 'zSetHashtableToZiplist',
1515
SET_PASSWORD: 'setPassword',
16+
RTS: 'RTS',
1617
});

redisinsight/api/src/constants/regex.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export const IS_INTEGER_NUMBER_REGEX = /^\d+$/;
33
export const IS_NON_PRINTABLE_ASCII_CHARACTER = /[^ -~\u0007\b\t\n\r]/;
44
export const IP_ADDRESS_REGEX = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
55
export const PRIVATE_IP_ADDRESS_REGEX = /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/;
6+
export const IS_TIMESTAMP = /^\d{10,}$/;

redisinsight/api/src/modules/database-analysis/database-analysis.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { IFindRedisClientInstanceByOptions } from 'src/modules/redis/redis.servi
33
import { isNull, flatten, uniqBy } from 'lodash';
44
import { RecommendationService } from 'src/modules/recommendation/recommendation.service';
55
import { catchAclError } from 'src/utils';
6+
import { RECOMMENDATION_NAMES } from 'src/constants';
67
import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer';
78
import { plainToClass } from 'class-transformer';
89
import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models';
@@ -58,11 +59,13 @@ export class DatabaseAnalysisService {
5859

5960
const recommendations = DatabaseAnalysisService.getRecommendationsSummary(
6061
flatten(await Promise.all(
61-
scanResults.map(async (nodeResult) => (
62+
scanResults.map(async (nodeResult, idx) => (
6263
await this.recommendationService.getRecommendations({
6364
client: nodeResult.client,
6465
keys: nodeResult.keys,
6566
total: progress.total,
67+
// TODO: create generic solution to exclude recommendations
68+
exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [],
6669
})
6770
)),
6871
)),

redisinsight/api/src/modules/recommendation/providers/recommendation.provider.spec.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import IORedis from 'ioredis';
2-
import { when } from 'jest-when';
2+
import { when, resetAllWhenMocks } from 'jest-when';
33
import { RECOMMENDATION_NAMES } from 'src/constants';
44
import { mockRedisNoAuthError, mockRedisNoPasswordError } from 'src/__mocks__';
55
import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider';
@@ -26,12 +26,20 @@ const mockRedisAclListResponse_1: string[] = [
2626
'user <pass off resetchannels -@all',
2727
'user default on #d74ff0ee8da3b9806b18c877dbf29bbde50b5bd8e4dad7a3a725000feb82e8f1 ~* &* +@all',
2828
];
29-
3029
const mockRedisAclListResponse_2: string[] = [
3130
...mockRedisAclListResponse_1,
3231
'user test_2 on nopass ~* &* +@all',
3332
];
3433

34+
const mockZScanResponse_1 = [
35+
'0',
36+
[123456789, 123456789, 12345678910, 12345678910],
37+
];
38+
const mockZScanResponse_2 = [
39+
'0',
40+
[12345678910, 12345678910, 1, 1],
41+
];
42+
3543
const mockKeys = [
3644
{
3745
name: Buffer.from('name'), type: 'string', length: 10, memory: 10, ttl: -1,
@@ -95,6 +103,12 @@ const mockBigListKey = {
95103
name: Buffer.from('name'), type: 'list', length: 1001, memory: 10, ttl: -1,
96104
};
97105

106+
const mockSortedSets = new Array(101).fill(
107+
{
108+
name: Buffer.from('name'), type: 'zset', length: 10, memory: 10, ttl: -1,
109+
},
110+
);
111+
98112
describe('RecommendationProvider', () => {
99113
const service = new RecommendationProvider();
100114

@@ -455,4 +469,60 @@ describe('RecommendationProvider', () => {
455469
expect(setPasswordRecommendation).toEqual({ name: RECOMMENDATION_NAMES.SET_PASSWORD });
456470
});
457471
});
472+
473+
describe('determineRTSRecommendation', () => {
474+
it('should not return RTS recommendation', async () => {
475+
when(nodeClient.sendCommand)
476+
.calledWith(jasmine.objectContaining({ name: 'zscan' }))
477+
.mockResolvedValue(mockZScanResponse_1);
478+
479+
const RTSRecommendation = await service
480+
.determineRTSRecommendation(nodeClient, mockKeys);
481+
expect(RTSRecommendation).toEqual(null);
482+
});
483+
484+
it('should return RTS recommendation', async () => {
485+
when(nodeClient.sendCommand)
486+
.calledWith(jasmine.objectContaining({ name: 'zscan' }))
487+
.mockResolvedValueOnce(mockZScanResponse_1);
488+
489+
when(nodeClient.sendCommand)
490+
.calledWith(jasmine.objectContaining({ name: 'zscan' }))
491+
.mockResolvedValue(mockZScanResponse_2);
492+
493+
const RTSRecommendation = await service
494+
.determineRTSRecommendation(nodeClient, mockSortedSets);
495+
expect(RTSRecommendation).toEqual({ name: RECOMMENDATION_NAMES.RTS });
496+
});
497+
498+
it('should not return RTS recommendation when only 101 sorted set contain timestamp', async () => {
499+
let counter = 0;
500+
while (counter <= 100) {
501+
when(nodeClient.sendCommand)
502+
.calledWith(jasmine.objectContaining({ name: 'zscan' }))
503+
.mockResolvedValueOnce(mockZScanResponse_1);
504+
counter += 1;
505+
}
506+
507+
when(nodeClient.sendCommand)
508+
.calledWith(jasmine.objectContaining({ name: 'zscan' }))
509+
.mockResolvedValueOnce(mockZScanResponse_2);
510+
511+
const RTSRecommendation = await service
512+
.determineRTSRecommendation(nodeClient, mockSortedSets);
513+
expect(RTSRecommendation).toEqual(null);
514+
});
515+
516+
it('should not return RTS recommendation when zscan command executed with error',
517+
async () => {
518+
resetAllWhenMocks();
519+
when(nodeClient.sendCommand)
520+
.calledWith(jasmine.objectContaining({ name: 'zscan' }))
521+
.mockRejectedValue('some error');
522+
523+
const RTSRecommendation = await service
524+
.determineRTSRecommendation(nodeClient, mockKeys);
525+
expect(RTSRecommendation).toEqual(null);
526+
});
527+
});
458528
});

redisinsight/api/src/modules/recommendation/providers/recommendation.provider.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
22
import { Redis, Cluster, Command } from 'ioredis';
33
import { get } from 'lodash';
44
import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/utils';
5-
import { RECOMMENDATION_NAMES } from 'src/constants';
5+
import { RECOMMENDATION_NAMES, IS_TIMESTAMP } from 'src/constants';
66
import { RedisDataType } from 'src/modules/browser/dto';
77
import { Recommendation } from 'src/modules/database-analysis/models/recommendation';
88
import { Key } from 'src/modules/database-analysis/models';
@@ -16,6 +16,7 @@ const maxListLength = 1000;
1616
const maxSetLength = 5000;
1717
const maxConnectedClients = 100;
1818
const bigStringMemory = 5_000_000;
19+
const sortedSetCountForCheck = 100;
1920

2021
@Injectable()
2122
export class RecommendationProvider {
@@ -307,6 +308,48 @@ export class RecommendationProvider {
307308
}
308309
}
309310

311+
/**
312+
* Check set password recommendation
313+
* @param redisClient
314+
* @param keys
315+
*/
316+
317+
async determineRTSRecommendation(
318+
redisClient: Redis | Cluster,
319+
keys: Key[],
320+
): Promise<Recommendation> {
321+
try {
322+
let processedKeysNumber = 0;
323+
let isTimeSeries = false;
324+
let sortedSetNumber = 0;
325+
while (
326+
processedKeysNumber < keys.length
327+
&& !isTimeSeries
328+
&& sortedSetNumber <= sortedSetCountForCheck
329+
) {
330+
if (keys[processedKeysNumber].type !== RedisDataType.ZSet) {
331+
processedKeysNumber += 1;
332+
} else {
333+
const [, membersArray] = await redisClient.sendCommand(
334+
// get first member-score pair
335+
new Command('zscan', [keys[processedKeysNumber].name, '0', 'COUNT', 2], { replyEncoding: 'utf8' }),
336+
) as string[];
337+
// check is pair member-score is timestamp
338+
if (IS_TIMESTAMP.test(membersArray[0]) && IS_TIMESTAMP.test(membersArray[1])) {
339+
isTimeSeries = true;
340+
}
341+
processedKeysNumber += 1;
342+
sortedSetNumber += 1;
343+
}
344+
}
345+
346+
return isTimeSeries ? { name: RECOMMENDATION_NAMES.RTS } : null;
347+
} catch (err) {
348+
this.logger.error('Can not determine RTS recommendation', err);
349+
return null;
350+
}
351+
}
352+
310353
private async checkAuth(redisClient: Redis | Cluster): Promise<boolean> {
311354
try {
312355
await redisClient.sendCommand(

redisinsight/api/src/modules/recommendation/recommendation.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
22
import { Redis } from 'ioredis';
33
import { RecommendationProvider } from 'src/modules/recommendation/providers/recommendation.provider';
44
import { Recommendation } from 'src/modules/database-analysis/models/recommendation';
5+
import { RECOMMENDATION_NAMES } from 'src/constants';
56
import { RedisString } from 'src/common/constants';
67
import { Key } from 'src/modules/database-analysis/models';
78

@@ -10,6 +11,7 @@ interface RecommendationInput {
1011
keys?: Key[],
1112
info?: RedisString,
1213
total?: number,
14+
exclude?: string[],
1315
}
1416

1517
@Injectable()
@@ -31,6 +33,7 @@ export class RecommendationService {
3133
keys,
3234
info,
3335
total,
36+
exclude,
3437
} = dto;
3538

3639
return (
@@ -49,6 +52,8 @@ export class RecommendationService {
4952
await this.recommendationProvider.determineBigSetsRecommendation(keys),
5053
await this.recommendationProvider.determineConnectionClientsRecommendation(client),
5154
await this.recommendationProvider.determineSetPasswordRecommendation(client),
55+
// TODO rework, need better solution to do not start determine recommendation
56+
exclude.includes(RECOMMENDATION_NAMES.RTS) ? null : await this.recommendationProvider.determineRTSRecommendation(client, keys),
5257
]));
5358
}
5459
}

redisinsight/api/test/api/database-analysis/POST-databases-id-analysis.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,25 @@ describe('POST /databases/:instanceId/analysis', () => {
416416
expect(await repository.count()).to.eq(5);
417417
}
418418
},
419+
{
420+
name: 'Should create new database analysis with RTS recommendation',
421+
data: {
422+
delimiter: '-',
423+
},
424+
statusCode: 201,
425+
responseSchema,
426+
before: async () => {
427+
await rte.data.sendCommand('zadd', [constants.TEST_ZSET_TIMESTAMP_KEY, constants.TEST_ZSET_TIMESTAMP_MEMBER, constants.TEST_ZSET_TIMESTAMP_SCORE]);
428+
},
429+
checkFn: async ({ body }) => {
430+
expect(body.recommendations).to.include.deep.members([
431+
constants.TEST_RTS_RECOMMENDATION,
432+
]);
433+
},
434+
after: async () => {
435+
expect(await repository.count()).to.eq(5);
436+
}
437+
},
419438
].map(mainCheckFn);
420439
});
421440
});

redisinsight/api/test/helpers/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ export const constants = {
186186
TEST_ZSET_HUGE_KEY: 'big zset 1M',
187187
TEST_ZSET_HUGE_MEMBER: ' 356897',
188188
TEST_ZSET_HUGE_SCORE: 356897,
189+
TEST_ZSET_TIMESTAMP_KEY: TEST_RUN_ID + '_zset_timestamp' + CLUSTER_HASH_SLOT,
190+
TEST_ZSET_TIMESTAMP_MEMBER: '12345678910',
191+
TEST_ZSET_TIMESTAMP_SCORE: 12345678910,
189192
TEST_ZSET_KEY_BIN_BUFFER_1: Buffer.concat([Buffer.from(TEST_RUN_ID), Buffer.from('zsetk'), unprintableBuf]),
190193
get TEST_ZSET_KEY_BIN_BUF_OBJ_1() { return { type: 'Buffer', data: [...this.TEST_ZSET_KEY_BIN_BUFFER_1] } },
191194
get TEST_ZSET_KEY_BIN_ASCII_1() { return getASCIISafeStringFromBuffer(this.TEST_ZSET_KEY_BIN_BUFFER_1) },
@@ -492,5 +495,9 @@ export const constants = {
492495
name: RECOMMENDATION_NAMES.SET_PASSWORD,
493496
},
494497

498+
TEST_RTS_RECOMMENDATION: {
499+
name: RECOMMENDATION_NAMES.RTS,
500+
},
501+
495502
// etc...
496503
}

0 commit comments

Comments
 (0)