Skip to content

Commit 067bd6a

Browse files
committed
refactor: Apply PR #576 review comments and improve architecture
Changes based on @neSpecc review: 1. TypeDefs improvements: - Use DateTime! instead of String! for date parameters in Project.chartData 2. Method renaming for clarity: - eventsFactory: getChartData → getProjectChartData - eventsFactory: getChartDataFromMongo → getEventDailyChart - Improved parameter order (main identifier first) 3. Code quality: - Remove duplication in try-catch blocks - Extract Redis key composition to utility functions 4. Architecture improvements: - Create ChartDataService to separate business logic from Redis client - Simplify RedisHelper to only low-level Redis operations - Add tsRange() method for TS.RANGE commands - Move chart data logic from redisHelper to dedicated service 5. New utilities: - src/utils/redisKeys.ts with composeTimeSeriesKey(), composeEventTimeSeriesKey() - Type-safe key composition with required parameters Benefits: - Clear separation of concerns (Data Access vs Business Logic vs Service) - RedisHelper stays focused and doesn't grow - Easier to test and maintain - Better type safety and documentation
1 parent 56281b8 commit 067bd6a

File tree

7 files changed

+216
-124
lines changed

7 files changed

+216
-124
lines changed

src/models/eventsFactory.js

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const mongo = require('../mongo');
88
const Event = require('../models/event');
99
const { ObjectID } = require('mongodb');
1010
import RedisHelper from '../redisHelper';
11+
import ChartDataService from '../services/chartDataService';
1112
const { composeEventPayloadByRepetition } = require('../utils/merge');
1213

1314
const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
@@ -71,11 +72,6 @@ const MAX_DB_READ_BATCH_SIZE = Number(process.env.MAX_DB_READ_BATCH_SIZE);
7172
* Factory Class for Event's Model
7273
*/
7374
class EventsFactory extends Factory {
74-
/**
75-
* Redis helper instance for modifying data through redis (singleton)
76-
*/
77-
redis = RedisHelper.getInstance();
78-
7975
/**
8076
* Event types with collections where they stored
8177
* @return {{EVENTS: string, DAILY_EVENTS: string, REPETITIONS: string, RELEASES: string}}
@@ -97,6 +93,16 @@ class EventsFactory extends Factory {
9793
constructor(projectId) {
9894
super();
9995

96+
/**
97+
* Redis helper instance (singleton)
98+
*/
99+
this.redis = RedisHelper.getInstance();
100+
101+
/**
102+
* Chart data service for fetching data from Redis TimeSeries
103+
*/
104+
this.chartDataService = new ChartDataService(this.redis);
105+
100106
if (!projectId) {
101107
throw new Error('Can not construct Event model, because projectId is not provided');
102108
}
@@ -424,54 +430,52 @@ class EventsFactory extends Factory {
424430
}
425431

426432
/**
427-
* Get chart data for projects (uses Redis with fallback to MongoDB)
433+
* Get project chart data from Redis or fallback to MongoDB
428434
*
429-
* @param {string} startDate - start date (ISO string or Unix timestamp)
430-
* @param {string} endDate - end date (ISO string or Unix timestamp)
431-
* @param {number} groupBy - grouping interval in minutes
432-
* @param {number} timezoneOffset - user's local timezone offset in minutes
433435
* @param {string} projectId - project ID
434-
* @param {string} groupHash - event's group hash (empty for project-level data)
436+
* @param {string} startDate - start date (ISO string)
437+
* @param {string} endDate - end date (ISO string)
438+
* @param {number} groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day)
439+
* @param {number} timezoneOffset - user's local timezone offset in minutes
435440
* @returns {Promise<Array>}
436441
*/
437-
async getChartData(startDate, endDate, groupBy = 60, timezoneOffset = 0, projectId = '', groupHash = '') {
442+
async getProjectChartData(projectId, startDate, endDate, groupBy = 60, timezoneOffset = 0) {
443+
// Calculate days for MongoDB fallback
444+
const start = new Date(startDate).getTime();
445+
const end = new Date(endDate).getTime();
446+
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
447+
438448
try {
439-
const redisData = await this.redis.getChartDataFromRedis(
449+
const redisData = await this.chartDataService.getProjectChartData(
450+
projectId,
440451
startDate,
441452
endDate,
442453
groupBy,
443-
timezoneOffset,
444-
projectId,
445-
groupHash
454+
timezoneOffset
446455
);
447456

448457
if (redisData && redisData.length > 0) {
449458
return redisData;
450459
}
451460

452-
// Fallback to Mongo
453-
const start = new Date(startDate).getTime();
454-
const end = new Date(endDate).getTime();
455-
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
456-
return this.findChartData(days, timezoneOffset, groupHash);
461+
// Fallback to Mongo (empty groupHash for project-level data)
462+
return this.findChartData(days, timezoneOffset, '');
457463
} catch (err) {
458-
console.error('[EventsFactory] getChartData error:', err);
459-
const start = new Date(startDate).getTime();
460-
const end = new Date(endDate).getTime();
461-
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));
462-
return this.findChartData(days, timezoneOffset, groupHash);
464+
console.error('[EventsFactory] getProjectChartData error:', err);
465+
// Fallback to Mongo on error (empty groupHash for project-level data)
466+
return this.findChartData(days, timezoneOffset, '');
463467
}
464468
}
465469

466470
/**
467-
* Get chart data from MongoDB only (for events)
471+
* Get event daily chart data from MongoDB only
468472
*
473+
* @param {string} groupHash - event's group hash
469474
* @param {number} days - how many days to fetch
470475
* @param {number} timezoneOffset - user's local timezone offset in minutes
471-
* @param {string} groupHash - event's group hash
472476
* @returns {Promise<Array>}
473477
*/
474-
async getChartDataFromMongo(days, timezoneOffset = 0, groupHash = '') {
478+
async getEventDailyChart(groupHash, days, timezoneOffset = 0) {
475479
return this.findChartData(days, timezoneOffset, groupHash);
476480
}
477481

src/redisHelper.ts

Lines changed: 26 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -116,95 +116,32 @@ export default class RedisHelper {
116116
public isConnected(): boolean {
117117
return this.redisClient.isOpen;
118118
}
119-
120-
public async getChartDataFromRedis(
121-
startDate: string,
122-
endDate: string,
123-
groupBy: number, // minutes: 1=minute, 60=hour, 1440=day
124-
timezoneOffset = 0,
125-
projectId = '',
126-
groupHash = ''
127-
): Promise<{ timestamp: number; count: number }[]> {
128-
// If Redis is not connected, throw error to fallback to MongoDB
129-
if (!this.redisClient.isOpen) {
130-
console.warn('[Redis] Client not connected, will fallback to MongoDB');
131-
throw new Error('Redis client not connected');
132-
}
133-
134-
// Determine suffix based on groupBy
135-
let suffix: string;
136-
if (groupBy === 1) {
137-
suffix = 'minutely';
138-
} else if (groupBy === 60) {
139-
suffix = 'hourly';
140-
} else if (groupBy === 1440) {
141-
suffix = 'daily';
142-
} else {
143-
// For custom intervals, fallback to minutely with aggregation
144-
suffix = 'minutely';
145-
}
146-
147-
const key = groupHash
148-
? `ts:events:${groupHash}:${suffix}`
149-
: projectId
150-
? `ts:events:${projectId}:${suffix}`
151-
: `ts:events:${suffix}`;
152-
153-
// Parse dates (support ISO string or Unix timestamp in seconds)
154-
const start = typeof startDate === 'string' && startDate.includes('-')
155-
? new Date(startDate).getTime()
156-
: Number(startDate) * 1000;
157-
const end = typeof endDate === 'string' && endDate.includes('-')
158-
? new Date(endDate).getTime()
159-
: Number(endDate) * 1000;
160-
161-
const bucketMs = groupBy * 60 * 1000;
162-
163-
let result: [string, string][] = [];
164-
try {
165-
// Use aggregation to sum values within each bucket
166-
// TS.INCRBY creates one point per time period with accumulated count
167-
result = (await this.redisClient.sendCommand([
168-
'TS.RANGE',
169-
key,
170-
start.toString(),
171-
end.toString(),
172-
'AGGREGATION',
173-
'sum',
174-
bucketMs.toString(),
175-
])) as [string, string][] | [];
176-
} catch (err: any) {
177-
if (err.message.includes('TSDB: the key does not exist')) {
178-
console.warn(`[Redis] Key ${key} does not exist, returning zeroed data`);
179-
result = [];
180-
} else {
181-
throw err;
182-
}
183-
}
184119

185-
// Transform data from Redis
186-
const dataPoints: { [ts: number]: number } = {};
187-
for (const [tsStr, valStr] of result) {
188-
const tsMs = Number(tsStr);
189-
dataPoints[tsMs] = Number(valStr) || 0;
190-
}
191-
192-
// Fill missing intervals with zeros
193-
const filled: { timestamp: number; count: number }[] = [];
194-
let current = start;
195-
196-
// Round current to the nearest bucket boundary
197-
current = Math.floor(current / bucketMs) * bucketMs;
198-
199-
while (current <= end) {
200-
const count = dataPoints[current] || 0;
201-
filled.push({
202-
timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000),
203-
count,
204-
});
205-
current += bucketMs;
206-
}
207-
208-
return filled.sort((a, b) => a.timestamp - b.timestamp);
120+
/**
121+
* Execute TS.RANGE command with aggregation
122+
*
123+
* @param key - Redis TimeSeries key
124+
* @param start - start timestamp in milliseconds
125+
* @param end - end timestamp in milliseconds
126+
* @param aggregationType - aggregation type (sum, avg, min, max, etc.)
127+
* @param bucketMs - bucket size in milliseconds
128+
* @returns Array of [timestamp, value] tuples
129+
*/
130+
public async tsRange(
131+
key: string,
132+
start: string,
133+
end: string,
134+
aggregationType: string,
135+
bucketMs: string
136+
): Promise<[string, string][]> {
137+
return (await this.redisClient.sendCommand([
138+
'TS.RANGE',
139+
key,
140+
start,
141+
end,
142+
'AGGREGATION',
143+
aggregationType,
144+
bucketMs,
145+
])) as [string, string][];
209146
}
210147
}

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.getChartDataFromMongo(days, timezoneOffset, groupHash);
89+
return factory.getEventDailyChart(groupHash, days, timezoneOffset);
9090
},
9191

9292
/**

src/resolvers/project.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ module.exports = {
486486
async chartData(project, { startDate, endDate, groupBy, timezoneOffset }, context) {
487487
const factory = getEventsFactory(context, project._id);
488488

489-
return factory.getChartData(startDate, endDate, groupBy, timezoneOffset, project._id);
489+
return factory.getProjectChartData(project._id, startDate, endDate, groupBy, timezoneOffset);
490490
},
491491

492492
/**

src/services/chartDataService.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import RedisHelper from '../redisHelper';
2+
import { composeTimeSeriesKey, getTimeSeriesSuffix } from '../utils/redisKeys';
3+
4+
/**
5+
* Service for fetching chart data from Redis TimeSeries
6+
*/
7+
export default class ChartDataService {
8+
private redisHelper: RedisHelper;
9+
10+
constructor(redisHelper: RedisHelper) {
11+
this.redisHelper = redisHelper;
12+
}
13+
14+
/**
15+
* Get project chart data from Redis TimeSeries
16+
*
17+
* @param projectId - project ID
18+
* @param startDate - start date as ISO string (e.g., '2025-01-01T00:00:00Z')
19+
* @param endDate - end date as ISO string (e.g., '2025-01-31T23:59:59Z')
20+
* @param groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day)
21+
* @param timezoneOffset - user's local timezone offset in minutes (default: 0)
22+
* @returns Array of data points with timestamp and count
23+
* @throws Error if Redis is not connected (caller should fallback to MongoDB)
24+
*/
25+
public async getProjectChartData(
26+
projectId: string,
27+
startDate: string,
28+
endDate: string,
29+
groupBy: number,
30+
timezoneOffset = 0
31+
): Promise<{ timestamp: number; count: number }[]> {
32+
// Check if Redis is connected
33+
if (!this.redisHelper.isConnected()) {
34+
console.warn('[ChartDataService] Redis not connected, will fallback to MongoDB');
35+
throw new Error('Redis client not connected');
36+
}
37+
38+
// Determine suffix and compose key
39+
const suffix = getTimeSeriesSuffix(groupBy);
40+
const key = composeTimeSeriesKey(suffix, projectId);
41+
42+
// Parse ISO date strings to milliseconds
43+
const start = new Date(startDate).getTime();
44+
const end = new Date(endDate).getTime();
45+
const bucketMs = groupBy * 60 * 1000;
46+
47+
// Fetch data from Redis
48+
let result: [string, string][] = [];
49+
try {
50+
result = await this.redisHelper.tsRange(
51+
key,
52+
start.toString(),
53+
end.toString(),
54+
'sum',
55+
bucketMs.toString()
56+
);
57+
} catch (err: any) {
58+
if (err.message.includes('TSDB: the key does not exist')) {
59+
console.warn(`[ChartDataService] Key ${key} does not exist, returning zeroed data`);
60+
result = [];
61+
} else {
62+
throw err;
63+
}
64+
}
65+
66+
// Transform data from Redis
67+
const dataPoints: { [ts: number]: number } = {};
68+
for (const [tsStr, valStr] of result) {
69+
const tsMs = Number(tsStr);
70+
dataPoints[tsMs] = Number(valStr) || 0;
71+
}
72+
73+
// Fill missing intervals with zeros
74+
const filled: { timestamp: number; count: number }[] = [];
75+
let current = start;
76+
77+
// Round current to the nearest bucket boundary
78+
current = Math.floor(current / bucketMs) * bucketMs;
79+
80+
while (current <= end) {
81+
const count = dataPoints[current] || 0;
82+
filled.push({
83+
timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000),
84+
count,
85+
});
86+
current += bucketMs;
87+
}
88+
89+
return filled.sort((a, b) => a.timestamp - b.timestamp);
90+
}
91+
}
92+

src/typeDefs/project.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -354,14 +354,14 @@ type Project {
354354
"""
355355
chartData(
356356
"""
357-
Start date (ISO string or Unix timestamp in seconds)
357+
Start date (ISO 8601 DateTime string)
358358
"""
359-
startDate: String!
359+
startDate: DateTime!
360360
361361
"""
362-
End date (ISO string or Unix timestamp in seconds)
362+
End date (ISO 8601 DateTime string)
363363
"""
364-
endDate: String!
364+
endDate: DateTime!
365365
366366
"""
367367
Grouping interval in minutes (1=minute, 60=hour, 1440=day)

0 commit comments

Comments
 (0)