Skip to content

Commit efec965

Browse files
committed
Merge branch 'feature/redis-timeseries-helper' into stage
2 parents 28d26ee + 82e0dc0 commit efec965

10 files changed

Lines changed: 125 additions & 49 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"jest": "^26.2.2",
2929
"mongodb-memory-server": "^6.6.1",
3030
"nodemon": "^2.0.2",
31+
"redis-mock": "^0.56.3",
3132
"ts-jest": "^26.1.4",
3233
"ts-node": "^10.9.1",
3334
"typescript": "^4.7.4"

src/index.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ class HawkAPI {
149149
/**
150150
* Creates factories to work with models
151151
* @param dataLoaders - dataLoaders for fetching data form database
152+
* @returns factories object
152153
*/
153154
private static setupFactories(dataLoaders: DataLoaders): ContextFactories {
154155
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -213,21 +214,20 @@ class HawkAPI {
213214

214215
/**
215216
* Initializing accounting SDK
216-
*/
217-
let tlsVerify;
218-
219-
/**
220217
* Checking env variables
221218
* If at least one path is not transmitted, the variable tlsVerify is undefined
222219
*/
223220
if (
224221
![process.env.TLS_CA_CERT, process.env.TLS_CERT, process.env.TLS_KEY].some(value => value === undefined || value.length === 0)
225222
) {
226-
tlsVerify = {
227-
tlsCaCertPath: `${process.env.TLS_CA_CERT}`,
228-
tlsCertPath: `${process.env.TLS_CERT}`,
229-
tlsKeyPath: `${process.env.TLS_KEY}`,
230-
};
223+
/*
224+
* tlsVerify is used for accounting SDK (currently commented out)
225+
* const tlsVerify = {
226+
* tlsCaCertPath: `${process.env.TLS_CA_CERT}`,
227+
* tlsCertPath: `${process.env.TLS_CERT}`,
228+
* tlsKeyPath: `${process.env.TLS_KEY}`,
229+
* };
230+
*/
231231
}
232232

233233
/*
@@ -253,11 +253,12 @@ class HawkAPI {
253253
public async start(): Promise<void> {
254254
await mongo.setupConnections();
255255
await rabbitmq.setupConnections();
256-
256+
257257
// Initialize Redis singleton with auto-reconnect
258258
const redis = RedisHelper.getInstance();
259+
259260
await redis.initialize();
260-
261+
261262
await this.server.start();
262263
this.app.use(graphqlUploadExpress());
263264
this.server.applyMiddleware({ app: this.app });

src/models/eventsFactory.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +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';
4+
import RedisHelper from '../redisHelper';
5+
import ChartDataService from '../services/chartDataService';
56

67
const Factory = require('./modelFactory');
78
const mongo = require('../mongo');
89
const Event = require('../models/event');
910
const { ObjectID } = require('mongodb');
10-
import RedisHelper from '../redisHelper';
11-
import ChartDataService from '../services/chartDataService';
1211
const { composeEventPayloadByRepetition } = require('../utils/merge');
1312

1413
const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
@@ -88,7 +87,7 @@ class EventsFactory extends Factory {
8887

8988
/**
9089
* Creates Event instance
91-
* @param {ObjectId} projectId - project ID
90+
* @param {ObjectId} projectId
9291
*/
9392
constructor(projectId) {
9493
super();
@@ -107,8 +106,6 @@ class EventsFactory extends Factory {
107106
throw new Error('Can not construct Event model, because projectId is not provided');
108107
}
109108

110-
this.redis.initialize();
111-
112109
this.projectId = projectId;
113110
this.eventsDataLoader = createProjectEventsByIdLoader(mongo.databases.events, this.projectId);
114111
}
@@ -462,6 +459,7 @@ class EventsFactory extends Factory {
462459
return this.findChartData(days, timezoneOffset, '');
463460
} catch (err) {
464461
console.error('[EventsFactory] getProjectChartData error:', err);
462+
465463
// Fallback to Mongo on error (empty groupHash for project-level data)
466464
return this.findChartData(days, timezoneOffset, '');
467465
}

src/redisHelper.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import HawkCatcher from '@hawk.so/nodejs';
22
import { createClient, RedisClientType } from 'redis';
3-
import { Effect, sgr } from './utils/ansi';
3+
4+
// eslint call error: 0:0 error Parsing error: Cannot read properties of undefined (reading 'map')
5+
// export type TsRangeResult = [timestamp: string, value: string];
6+
export type TsRangeResult = any;
47

58
/**
69
* Helper class for working with Redis
@@ -36,8 +39,10 @@ export default class RedisHelper {
3639
url: process.env.REDIS_URL,
3740
socket: {
3841
reconnectStrategy: (retries) => {
39-
// Exponential backoff: wait longer between each retry
40-
// Max wait time: 30 seconds
42+
/*
43+
* Exponential backoff: wait longer between each retry
44+
* Max wait time: 30 seconds
45+
*/
4146
const delay = Math.min(retries * 1000, 30000);
4247
console.log(`[Redis] Reconnecting... attempt ${retries}, waiting ${delay}ms`);
4348
return delay;
@@ -133,7 +138,7 @@ export default class RedisHelper {
133138
end: string,
134139
aggregationType: string,
135140
bucketMs: string
136-
): Promise<[string, string][]> {
141+
): Promise<TsRangeResult[]> {
137142
return (await this.redisClient.sendCommand([
138143
'TS.RANGE',
139144
key,
@@ -142,6 +147,6 @@ export default class RedisHelper {
142147
'AGGREGATION',
143148
aggregationType,
144149
bucketMs,
145-
])) as [string, string][];
150+
])) as TsRangeResult[];
146151
}
147-
}
152+
}

src/services/chartDataService.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import RedisHelper from '../redisHelper';
1+
import RedisHelper, { TsRangeResult } from '../redisHelper';
22
import { composeProjectMetricsKey, getTimeSeriesSuffix } from '../utils/chartStorageKeys';
33

44
/**
@@ -45,7 +45,7 @@ export default class ChartDataService {
4545
const bucketMs = groupBy * 60 * 1000;
4646

4747
// Fetch data from Redis
48-
let result: [string, string][] = [];
48+
let result: TsRangeResult[] = [];
4949
try {
5050
result = await this.redisHelper.tsRange(
5151
key,
@@ -89,4 +89,3 @@ export default class ChartDataService {
8989
return filled.sort((a, b) => a.timestamp - b.timestamp);
9090
}
9191
}
92-

src/utils/chartStorageKeys.ts

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
/**
2-
* Utility for composing Redis TimeSeries keys
2+
* Utilities for composing Redis TimeSeries keys for chart data storage
3+
*
4+
* Note: Event-level metrics use MongoDB only (not Redis TimeSeries)
5+
* Only project-level metrics are stored in Redis TimeSeries
36
*/
47

58
/**
@@ -26,24 +29,6 @@ export function composeProjectMetricsKey(
2629
return `ts:project-${metricType}:${projectId}:${granularity}`;
2730
}
2831

29-
/**
30-
* Compose Redis TimeSeries key for event-level metrics
31-
*
32-
* @param suffix - time granularity suffix (minutely, hourly, daily)
33-
* @param groupHash - event group hash
34-
* @returns Redis key string
35-
*
36-
* @example
37-
* composeEventTimeSeriesKey('daily', 'abc123def')
38-
* // => 'ts:events:abc123def:daily'
39-
*/
40-
export function composeEventTimeSeriesKey(
41-
suffix: string,
42-
groupHash: string
43-
): string {
44-
return `ts:events:${groupHash}:${suffix}`;
45-
}
46-
4732
/**
4833
* Get time granularity suffix based on groupBy interval
4934
*
@@ -58,8 +43,7 @@ export function getTimeSeriesSuffix(groupBy: number): string {
5843
} else if (groupBy === 1440) {
5944
return 'daily';
6045
}
61-
46+
6247
// For custom intervals, fallback to minutely with aggregation
6348
return 'minutely';
64-
}
65-
49+
}

test/integration/api.env

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ MONGO_HAWK_DB_URL=mongodb://mongodb:27017/hawk
77
# Events database URL
88
MONGO_EVENTS_DB_URL=mongodb://mongodb:27017/hawk_events
99

10-
10+
# Redis URL (optional, for TimeSeries metrics)
11+
# If not set, Redis features will be disabled gracefully
12+
REDIS_URL=redis:6379
1113

1214
# MongoDB settings
1315
MONGO_RECONNECT_TRIES=60

test/integration/jestEnv.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const NodeEnvironment = require('jest-environment-node');
22
const amqp = require('amqplib');
33
const mongodb = require('mongodb');
4+
const { installRedisMock, uninstallRedisMock } = require('./redisMock');
45

56
/**
67
* Custom test environment for defining global connections
@@ -19,6 +20,12 @@ class CustomEnvironment extends NodeEnvironment {
1920
await mongoClient.db('hawk').dropDatabase();
2021
// await mongoClient.db('codex_accounting').dropDatabase();
2122

23+
/**
24+
* Use redis-mock instead of a real Redis connection.
25+
* This avoids spinning up Redis during integration tests while keeping the API surface.
26+
*/
27+
this.global.redisClient = installRedisMock();
28+
2229
this.rabbitMqConnection = await amqp.connect('amqp://guest:guest@rabbitmq:5672/');
2330
this.global.rabbitChannel = await this.rabbitMqConnection.createChannel();
2431
await this.global.rabbitChannel.purgeQueue('cron-tasks/limiter');
@@ -41,6 +48,8 @@ class CustomEnvironment extends NodeEnvironment {
4148
if (this.rabbitMqConnection) {
4249
await this.rabbitMqConnection.close();
4350
}
51+
52+
uninstallRedisMock();
4453
} catch (error) {
4554
console.error('Error during teardown:', error);
4655
}

test/integration/redisMock.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
const path = require('path');
2+
const redisMock = require('redis-mock');
3+
4+
let originalRedisModule = null;
5+
let redisModulePath = null;
6+
7+
/**
8+
* Create Redis mock client compatible with node-redis v4 API portions we use.
9+
*
10+
* @returns {object} mocked redis client
11+
*/
12+
function createMockClient() {
13+
const client = redisMock.createClient();
14+
15+
client.isOpen = true;
16+
client.connect = async () => client;
17+
client.quit = async () => undefined;
18+
client.sendCommand = async () => [];
19+
client.on = () => client;
20+
21+
return client;
22+
}
23+
24+
/**
25+
* Install redis-mock into Node's module cache so that `require('redis')`
26+
* returns the mocked client factory.
27+
*
28+
* @returns {object} mock client instance to be reused in tests
29+
*/
30+
function installRedisMock() {
31+
redisModulePath = require.resolve('redis');
32+
originalRedisModule = require.cache[redisModulePath] || null;
33+
34+
const mockExports = {
35+
createClient: () => createMockClient(),
36+
};
37+
38+
require.cache[redisModulePath] = {
39+
id: redisModulePath,
40+
filename: redisModulePath,
41+
loaded: true,
42+
exports: mockExports,
43+
path: path.dirname(redisModulePath),
44+
children: [],
45+
};
46+
47+
return mockExports.createClient();
48+
}
49+
50+
/**
51+
* Restore original `redis` module if it existed.
52+
*/
53+
function uninstallRedisMock() {
54+
if (!redisModulePath) {
55+
return;
56+
}
57+
58+
if (originalRedisModule) {
59+
require.cache[redisModulePath] = originalRedisModule;
60+
} else {
61+
delete require.cache[redisModulePath];
62+
}
63+
64+
originalRedisModule = null;
65+
redisModulePath = null;
66+
}
67+
68+
module.exports = {
69+
installRedisMock,
70+
uninstallRedisMock,
71+
};
72+

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5817,6 +5817,11 @@ readdirp@~3.6.0:
58175817
dependencies:
58185818
picomatch "^2.2.1"
58195819

5820+
redis-mock@^0.56.3:
5821+
version "0.56.3"
5822+
resolved "https://registry.yarnpkg.com/redis-mock/-/redis-mock-0.56.3.tgz#e96471bcc774ddc514c2fc49cdd03cab2baecd89"
5823+
integrity sha512-ynaJhqk0Qf3Qajnwvy4aOjS4Mdf9IBkELWtjd+NYhpiqu4QCNq6Vf3Q7c++XRPGiKiwRj9HWr0crcwy7EiPjYQ==
5824+
58205825
redis@^4.7.0:
58215826
version "4.7.1"
58225827
resolved "https://registry.yarnpkg.com/redis/-/redis-4.7.1.tgz#08588a30936be0e7ad9c0f3e1ac6a85ccaf73e94"

0 commit comments

Comments
 (0)