Skip to content

Commit dafb4cb

Browse files
committed
feat: add Redis TimeSeries helper with safe increment and auto-creation
1 parent 366a7e7 commit dafb4cb

File tree

6 files changed

+222
-3
lines changed

6 files changed

+222
-3
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"mongodb": "^3.7.3",
8282
"morgan": "^1.10.1",
8383
"prom-client": "^15.1.3",
84+
"redis": "^4.7.0",
8485
"safe-regex": "^2.1.0",
8586
"ts-node-dev": "^2.0.0",
8687
"uuid": "^8.3.2"

src/models/eventsFactory.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates';
22
import safe from 'safe-regex';
33
import { createProjectEventsByIdLoader } from '../dataLoaders';
4+
import { Effect, sgr } from '../utils/ansi';
45

56
const Factory = require('./modelFactory');
67
const mongo = require('../mongo');
78
const Event = require('../models/event');
89
const { ObjectID } = require('mongodb');
10+
import RedisHelper from '../redisHelper';
911
const { composeEventPayloadByRepetition } = require('../utils/merge');
1012

1113
const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
@@ -69,6 +71,12 @@ const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
6971
* Factory Class for Event's Model
7072
*/
7173
class EventsFactory extends Factory {
74+
/**
75+
/**
76+
* Redis helper instance for modifying data through redis
77+
*/
78+
redis = new RedisHelper();
79+
7280
/**
7381
* Event types with collections where they stored
7482
* @return {{EVENTS: string, DAILY_EVENTS: string, REPETITIONS: string, RELEASES: string}}
@@ -94,6 +102,8 @@ class EventsFactory extends Factory {
94102
throw new Error('Can not construct Event model, because projectId is not provided');
95103
}
96104

105+
this.redis.initialize();
106+
97107
this.projectId = projectId;
98108
this.eventsDataLoader = createProjectEventsByIdLoader(mongo.databases.events, this.projectId);
99109
}
@@ -392,6 +402,20 @@ class EventsFactory extends Factory {
392402
};
393403
}
394404

405+
async getChartData(hours = 24, timezoneOffset = 0, projectId = '', groupHash = '') {
406+
try {
407+
const redisData = await this.redis.getChartDataFromRedis(hours, timezoneOffset, projectId, groupHash);
408+
409+
if (redisData && redisData.length > 0) {
410+
return redisData;
411+
}
412+
413+
return this.findChartData(days = hours, timezoneOffset, groupHash);
414+
} catch (err) {
415+
return this.findChartData(days = hours, timezoneOffset, groupHash);
416+
}
417+
}
418+
395419
/**
396420
* Fetch timestamps and total count of errors (or target error) for each day since
397421
*

src/redisHelper.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import HawkCatcher from '@hawk.so/nodejs';
2+
import { createClient, RedisClientType } from 'redis';
3+
import { Effect, sgr } from './utils/ansi';
4+
5+
/**
6+
* Helper class for working with Redis
7+
*/
8+
export default class RedisHelper {
9+
/**
10+
* TTL for lock records in Redis (in seconds)
11+
*/
12+
private static readonly LOCK_TTL = 10;
13+
14+
/**
15+
* Redis client instance
16+
*/
17+
private readonly redisClient!: RedisClientType;
18+
19+
/**
20+
* Constructor
21+
* Initializes the Redis client and sets up error handling
22+
*/
23+
constructor() {
24+
try {
25+
this.redisClient = createClient({ url: process.env.REDIS_URL });
26+
27+
this.redisClient.on('error', (error) => {
28+
console.error('[Redis] Client error:', error);
29+
if (error) {
30+
HawkCatcher.send(error);
31+
}
32+
});
33+
} catch (error) {
34+
console.error('[Redis] Error creating client:', error);
35+
}
36+
}
37+
38+
/**
39+
* Connect to Redis
40+
*/
41+
public async initialize(): Promise<void> {
42+
try {
43+
await this.redisClient.connect();
44+
console.log('[Redis] Connected successfully');
45+
} catch (error) {
46+
console.error('[Redis] Connection failed:', error);
47+
HawkCatcher.send(error as Error);
48+
}
49+
}
50+
51+
/**
52+
* Close Redis client
53+
*/
54+
public async close(): Promise<void> {
55+
if (this.redisClient.isOpen) {
56+
await this.redisClient.quit();
57+
console.log('[Redis] Connection closed');
58+
}
59+
}
60+
61+
public async getChartDataFromRedis(
62+
hours: number, // количество интервалов (часов или дней)
63+
timezoneOffset = 0,
64+
projectId = '',
65+
groupHash = ''
66+
): Promise<{ timestamp: number; count: number }[]> {
67+
if (!this.redisClient.isOpen) {
68+
throw new Error('Redis client not connected');
69+
}
70+
71+
const key = groupHash
72+
? `ts:events:${groupHash}:hourly`
73+
: projectId
74+
? `ts:events:${projectId}:hourly`
75+
: `ts:events:hourly`;
76+
77+
const now = Date.now();
78+
79+
// определяем начало выборки
80+
const fromDate = new Date(now);
81+
fromDate.setMinutes(0, 0, 0);
82+
fromDate.setMilliseconds(fromDate.getMilliseconds() - (hours * 60 * 60 * 1000));
83+
const from = fromDate.getTime();
84+
85+
let result: [string, string][] = [];
86+
try {
87+
result = (await this.redisClient.sendCommand([
88+
'TS.RANGE',
89+
key,
90+
from.toString(),
91+
now.toString(),
92+
])) as [string, string][] | [];
93+
} catch (err: any) {
94+
if (err.message.includes('TSDB: the key does not exist')) {
95+
console.warn(`[Redis] Key ${key} does not exist, returning zeroed data`);
96+
result = [];
97+
} else {
98+
throw err;
99+
}
100+
}
101+
102+
console.log(groupHash, result)
103+
104+
// агрегируем события по интервалу
105+
const dataPoints: { [ts: number]: number } = {};
106+
for (const [tsStr] of result) {
107+
const tsMs = Number(tsStr);
108+
const date = new Date(tsMs);
109+
110+
let intervalStart: number;
111+
date.setMinutes(0, 0, 0);
112+
intervalStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
113+
114+
const intervalWithOffset = intervalStart + timezoneOffset * 60 * 1000;
115+
116+
dataPoints[intervalWithOffset] = (dataPoints[intervalWithOffset] || 0) + 1;
117+
}
118+
119+
// заполняем пропущенные интервалы нулями
120+
const filled: { timestamp: number; count: number }[] = [];
121+
const nowDate = new Date(now);
122+
123+
for (let i = 0; i < hours; i++) {
124+
const date = new Date(nowDate);
125+
126+
date.setHours(date.getHours() - i, 0, 0, 0);
127+
var intervalStart = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours());
128+
129+
const intervalWithOffset = intervalStart + timezoneOffset * 60 * 1000;
130+
filled.push({
131+
timestamp: Math.floor(intervalWithOffset / 1000),
132+
count: dataPoints[intervalWithOffset] || 0,
133+
});
134+
}
135+
136+
return filled.sort((a, b) => a.timestamp - b.timestamp);
137+
}
138+
}

src/resolvers/event.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ module.exports = {
8686
async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) {
8787
const factory = getEventsFactory(context, projectId);
8888

89-
return factory.findChartData(days, timezoneOffset, groupHash);
89+
return factory.getChartData(days, timezoneOffset, projectId, groupHash);
9090
},
9191

9292
/**

src/resolvers/project.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ module.exports = {
468468
async chartData(project, { days, timezoneOffset }, context) {
469469
const factory = getEventsFactory(context, project._id);
470470

471-
return factory.findChartData(days, timezoneOffset);
471+
return factory.getChartData(days, timezoneOffset, project._id);
472472
},
473473

474474
/**

yarn.lock

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,40 @@
788788
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
789789
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
790790

791+
"@redis/bloom@1.2.0":
792+
version "1.2.0"
793+
resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71"
794+
integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==
795+
796+
"@redis/client@1.6.1":
797+
version "1.6.1"
798+
resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.6.1.tgz#c4636b7cb34e96008a988409b7e787364ae761a2"
799+
integrity sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==
800+
dependencies:
801+
cluster-key-slot "1.1.2"
802+
generic-pool "3.9.0"
803+
yallist "4.0.0"
804+
805+
"@redis/graph@1.1.1":
806+
version "1.1.1"
807+
resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.1.tgz#8c10df2df7f7d02741866751764031a957a170ea"
808+
integrity sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==
809+
810+
"@redis/json@1.0.7":
811+
version "1.0.7"
812+
resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.7.tgz#016257fcd933c4cbcb9c49cde8a0961375c6893b"
813+
integrity sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==
814+
815+
"@redis/search@1.2.0":
816+
version "1.2.0"
817+
resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.2.0.tgz#50976fd3f31168f585666f7922dde111c74567b8"
818+
integrity sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==
819+
820+
"@redis/time-series@1.1.0":
821+
version "1.1.0"
822+
resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.1.0.tgz#cba454c05ec201bd5547aaf55286d44682ac8eb5"
823+
integrity sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==
824+
791825
"@shelf/jest-mongodb@^1.2.2":
792826
version "1.3.4"
793827
resolved "https://registry.yarnpkg.com/@shelf/jest-mongodb/-/jest-mongodb-1.3.4.tgz#200bac386cf513bed2d41952b1857689f0b88f31"
@@ -2138,6 +2172,11 @@ cloudpayments@^6.0.1:
21382172
object-hash "^2.2.0"
21392173
qs "^6.10.1"
21402174

2175+
cluster-key-slot@1.1.2:
2176+
version "1.1.2"
2177+
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
2178+
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
2179+
21412180
co@^4.6.0:
21422181
version "4.6.0"
21432182
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -3326,6 +3365,11 @@ gauge@^3.0.0:
33263365
strip-ansi "^6.0.1"
33273366
wide-align "^1.1.2"
33283367

3368+
generic-pool@3.9.0:
3369+
version "3.9.0"
3370+
resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4"
3371+
integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==
3372+
33293373
gensync@^1.0.0-beta.2:
33303374
version "1.0.0-beta.2"
33313375
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@@ -5718,6 +5762,18 @@ readdirp@~3.6.0:
57185762
dependencies:
57195763
picomatch "^2.2.1"
57205764

5765+
redis@^4.7.0:
5766+
version "4.7.1"
5767+
resolved "https://registry.yarnpkg.com/redis/-/redis-4.7.1.tgz#08588a30936be0e7ad9c0f3e1ac6a85ccaf73e94"
5768+
integrity sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==
5769+
dependencies:
5770+
"@redis/bloom" "1.2.0"
5771+
"@redis/client" "1.6.1"
5772+
"@redis/graph" "1.1.1"
5773+
"@redis/json" "1.0.7"
5774+
"@redis/search" "1.2.0"
5775+
"@redis/time-series" "1.1.0"
5776+
57215777
regex-not@^1.0.0, regex-not@^1.0.2:
57225778
version "1.0.2"
57235779
resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
@@ -7107,7 +7163,7 @@ y18n@^4.0.0:
71077163
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
71087164
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
71097165

7110-
yallist@^4.0.0:
7166+
yallist@4.0.0, yallist@^4.0.0:
71117167
version "4.0.0"
71127168
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
71137169
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==

0 commit comments

Comments
 (0)