Skip to content

Commit 6144ba0

Browse files
Merge pull request #1563 from RedisInsight/feature/RI-3942_rts_recommendation
Feature/ri 3942 rts recommendation
2 parents 599a10e + b984ce1 commit 6144ba0

File tree

12 files changed

+298
-17
lines changed

12 files changed

+298
-17
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
@@ -2,6 +2,7 @@ import { HttpException, Injectable, Logger } from '@nestjs/common';
22
import { isNull, flatten, uniqBy } from 'lodash';
33
import { RecommendationService } from 'src/modules/recommendation/recommendation.service';
44
import { catchAclError } from 'src/utils';
5+
import { RECOMMENDATION_NAMES } from 'src/constants';
56
import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/database-analyzer';
67
import { plainToClass } from 'class-transformer';
78
import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models';
@@ -56,11 +57,13 @@ export class DatabaseAnalysisService {
5657

5758
const recommendations = DatabaseAnalysisService.getRecommendationsSummary(
5859
flatten(await Promise.all(
59-
scanResults.map(async (nodeResult) => (
60+
scanResults.map(async (nodeResult, idx) => (
6061
await this.recommendationService.getRecommendations({
6162
client: nodeResult.client,
6263
keys: nodeResult.keys,
6364
total: progress.total,
65+
// TODO: create generic solution to exclude recommendations
66+
exclude: idx !== 0 ? [RECOMMENDATION_NAMES.RTS] : [],
6467
})
6568
)),
6669
)),

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
@@ -193,6 +193,9 @@ export const constants = {
193193
TEST_ZSET_HUGE_KEY: 'big zset 1M',
194194
TEST_ZSET_HUGE_MEMBER: ' 356897',
195195
TEST_ZSET_HUGE_SCORE: 356897,
196+
TEST_ZSET_TIMESTAMP_KEY: TEST_RUN_ID + '_zset_timestamp' + CLUSTER_HASH_SLOT,
197+
TEST_ZSET_TIMESTAMP_MEMBER: '12345678910',
198+
TEST_ZSET_TIMESTAMP_SCORE: 12345678910,
196199
TEST_ZSET_KEY_BIN_BUFFER_1: Buffer.concat([Buffer.from(TEST_RUN_ID), Buffer.from('zsetk'), unprintableBuf]),
197200
get TEST_ZSET_KEY_BIN_BUF_OBJ_1() { return { type: 'Buffer', data: [...this.TEST_ZSET_KEY_BIN_BUFFER_1] } },
198201
get TEST_ZSET_KEY_BIN_ASCII_1() { return getASCIISafeStringFromBuffer(this.TEST_ZSET_KEY_BIN_BUFFER_1) },
@@ -499,5 +502,9 @@ export const constants = {
499502
name: RECOMMENDATION_NAMES.SET_PASSWORD,
500503
},
501504

505+
TEST_RTS_RECOMMENDATION: {
506+
name: RECOMMENDATION_NAMES.RTS,
507+
},
508+
502509
// etc...
503510
}

redisinsight/ui/src/constants/dbAnalysisRecommendations.json

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@
282282
"bigSets": {
283283
"id": "bigSets",
284284
"title": "Switch to Bloom filter, cuckoo filter, or HyperLogLog",
285+
"redisStack": true,
285286
"content": [
286287
{
287288
"id": "1",
@@ -409,6 +410,29 @@
409410
"href": "https://docs.redis.com/latest/ri/memory-optimizations/",
410411
"name": "Read more"
411412
}
413+
},
414+
{
415+
"id": "11",
416+
"type": "spacer",
417+
"value": "l"
418+
},
419+
{
420+
"id": "12",
421+
"type": "span",
422+
"value": "Create a "
423+
},
424+
{
425+
"id": "13",
426+
"type": "link",
427+
"value": {
428+
"href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/",
429+
"name": "free Redis Stack database"
430+
}
431+
},
432+
{
433+
"id": "14",
434+
"type": "span",
435+
"value": " to use modern data models and processing engines."
412436
}
413437
],
414438
"badges": ["configuration_changes"]
@@ -467,5 +491,41 @@
467491
}
468492
],
469493
"badges": ["configuration_changes"]
494+
},
495+
"RTS": {
496+
"id": "RTS",
497+
"title":"Optimize the use of time series",
498+
"redisStack": true,
499+
"content": [
500+
{
501+
"id": "1",
502+
"type": "paragraph",
503+
"value": "If you are using sorted sets to work with time series data, consider using RedisTimeSeries to optimize the memory usage while having extraordinary query performance and small overhead during ingestion."
504+
},
505+
{
506+
"id": "2",
507+
"type": "spacer",
508+
"value": "l"
509+
},
510+
{
511+
"id": "3",
512+
"type": "span",
513+
"value": "Create a "
514+
},
515+
{
516+
"id": "4",
517+
"type": "link",
518+
"value": {
519+
"href": "https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight/",
520+
"name": "free Redis Stack database"
521+
}
522+
},
523+
{
524+
"id": "5",
525+
"type": "span",
526+
"value": " to use modern data models and processing engines."
527+
}
528+
],
529+
"badges": ["configuration_changes"]
470530
}
471531
}

redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.spec.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,4 +330,18 @@ describe('Recommendations', () => {
330330

331331
expect(screen.queryByTestId('badges-legend')).toBeInTheDocument()
332332
})
333+
334+
it('should render redisstack link', () => {
335+
(dbAnalysisSelector as jest.Mock).mockImplementation(() => ({
336+
...mockdbAnalysisSelector,
337+
data: {
338+
recommendations: [{ name: 'bigSets' }]
339+
}
340+
}))
341+
342+
render(<Recommendations />)
343+
344+
expect(screen.queryByTestId('bigSets-redis-stack-link')).toBeInTheDocument()
345+
expect(screen.queryByTestId('bigSets-redis-stack-link')).toHaveAttribute('href', 'https://redis.io/docs/stack/')
346+
})
333347
})

0 commit comments

Comments
 (0)