Skip to content

Commit fd26eca

Browse files
authored
Merge pull request #565 from codex-team/feat/ProjectDailyEvents-query-opt
feat(perf): added dataLoading for dynamic events collections
2 parents 80cd24c + d2030cd commit fd26eca

File tree

8 files changed

+120
-45
lines changed

8 files changed

+120
-45
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.2.7",
3+
"version": "1.2.8",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

src/dataLoaders.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import DataLoader from 'dataloader';
22
import { Db, ObjectId } from 'mongodb';
3-
import { PlanDBScheme, UserDBScheme, WorkspaceDBScheme, ProjectDBScheme } from '@hawk.so/types';
3+
import { PlanDBScheme, UserDBScheme, WorkspaceDBScheme, ProjectDBScheme, EventData, EventAddons } from '@hawk.so/types';
4+
5+
type EventDbScheme = {
6+
_id: ObjectId;
7+
} & EventData<EventAddons>;
48

59
/**
610
* Class for setting up data loaders
@@ -65,7 +69,7 @@ export default class DataLoaders {
6569
* @param collectionName - collection name to get entities
6670
* @param ids - ids for resolving
6771
*/
68-
private async batchByIds<T extends {_id: ObjectId}>(collectionName: string, ids: ReadonlyArray<string>): Promise<(T | null | Error)[]> {
72+
private async batchByIds<T extends { _id: ObjectId }>(collectionName: string, ids: ReadonlyArray<string>): Promise<(T | null | Error)[]> {
6973
return this.batchByField<T, ObjectId>(collectionName, ids.map(id => new ObjectId(id)), '_id');
7074
}
7175

@@ -77,9 +81,9 @@ export default class DataLoaders {
7781
*/
7882
private async batchByField<
7983
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80-
T extends {[key: string]: any},
84+
T extends { [key: string]: any },
8185
FieldType extends object | string
82-
>(collectionName: string, values: ReadonlyArray<FieldType>, fieldName: string): Promise<(T | null | Error)[]> {
86+
>(collectionName: string, values: ReadonlyArray<FieldType>, fieldName: string): Promise<(T | null | Error)[]> {
8387
const queryResult = await this.dbConnection.collection(collectionName)
8488
.find({
8589
[fieldName]: { $in: values },
@@ -99,3 +103,31 @@ export default class DataLoaders {
99103
return values.map((field) => entitiesMap[field.toString()] || null);
100104
}
101105
}
106+
107+
/**
108+
* Create DataLoader for events in dynamic collections `events:<projectId>` stored in the events DB
109+
*
110+
* @param eventsDb - MongoDB connection to the events database
111+
* @param projectId - project id used to pick a dynamic collection
112+
*/
113+
export function createProjectEventsByIdLoader(
114+
eventsDb: Db,
115+
projectId: string
116+
): DataLoader<string, EventDbScheme | null> {
117+
return new DataLoader<string, EventDbScheme | null>(async (ids) => {
118+
const objectIds = ids.map((id) => new ObjectId(id));
119+
120+
const docs = await eventsDb
121+
.collection(`events:${projectId}`)
122+
.find({ _id: { $in: objectIds } })
123+
.toArray();
124+
125+
const map: Record<string, EventDbScheme> = {};
126+
127+
docs.forEach((doc) => {
128+
map[doc._id.toString()] = doc as EventDbScheme;
129+
});
130+
131+
return ids.map((id) => map[id] || null);
132+
}, { cache: true });
133+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ class HawkAPI {
237237
id: userId,
238238
accessTokenExpired: isAccessTokenExpired,
239239
},
240+
eventsFactoryCache: new Map(),
240241
// accounting,
241242
};
242243
}

src/models/eventsFactory.js

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getMidnightWithTimezoneOffset, getUTCMidnight } from '../utils/dates';
22
import safe from 'safe-regex';
3+
import { createProjectEventsByIdLoader } from '../dataLoaders';
34

45
const Factory = require('./modelFactory');
56
const mongo = require('../mongo');
@@ -94,6 +95,7 @@ class EventsFactory extends Factory {
9495
}
9596

9697
this.projectId = projectId;
98+
this.eventsDataLoader = createProjectEventsByIdLoader(mongo.databases.events, this.projectId);
9799
}
98100

99101
/**
@@ -156,10 +158,7 @@ class EventsFactory extends Factory {
156158
* @returns {Event|null}
157159
*/
158160
async findById(id) {
159-
const searchResult = await this.getCollection(this.TYPES.EVENTS)
160-
.findOne({
161-
_id: new ObjectID(id),
162-
});
161+
const searchResult = await this.eventsDataLoader.load(id);
163162

164163
const event = searchResult ? new Event(searchResult) : null;
165164

@@ -603,10 +602,7 @@ class EventsFactory extends Factory {
603602
* If originalEventId equals repetitionId than user wants to get first repetition which is original event
604603
*/
605604
if (repetitionId === originalEventId) {
606-
const originalEvent = await this.getCollection(this.TYPES.EVENTS)
607-
.findOne({
608-
_id: ObjectID(originalEventId),
609-
});
605+
const originalEvent = await this.eventsDataLoader.load(originalEventId);
610606

611607
/**
612608
* All events have same type with originalEvent id
@@ -626,10 +622,7 @@ class EventsFactory extends Factory {
626622
_id: ObjectID(repetitionId),
627623
});
628624

629-
const originalEvent = await this.getCollection(this.TYPES.EVENTS)
630-
.findOne({
631-
_id: ObjectID(originalEventId),
632-
});
625+
const originalEvent = await this.eventsDataLoader.load(originalEventId);
633626

634627
/**
635628
* If one of the ids are invalid (originalEvent or repetition not found) return null
@@ -710,7 +703,7 @@ class EventsFactory extends Factory {
710703
async toggleEventMark(eventId, mark) {
711704
const collection = this.getCollection(this.TYPES.EVENTS);
712705

713-
const event = await this.findById(eventId);
706+
const event = await this.eventsDataLoader.load(eventId);
714707

715708
if (!event) {
716709
throw new Error(`Event not found for eventId: ${eventId}`);

src/resolvers/event.js

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
const EventsFactory = require('../models/eventsFactory');
2-
const { ObjectID } = require('mongodb');
32
const sendPersonalNotification = require('../utils/personalNotifications').default;
43

4+
/**
5+
* Returns a per-request, per-project EventsFactory instance
6+
* Uses context.eventsFactoryCache to memoize by projectId
7+
*
8+
* @param {ResolverContextBase} context - resolver context
9+
* @param {string} projectId - project id to get EventsFactory instance for
10+
* @returns {EventsFactory} - EventsFactory instance bound to a specific project object
11+
*/
12+
function getEventsFactoryForProjectId(context, projectId) {
13+
const cache = context.eventsFactoryCache || (context.eventsFactoryCache = new Map());
14+
const cacheKey = projectId.toString();
15+
16+
if (!cache.has(cacheKey)) {
17+
cache.set(cacheKey, new EventsFactory(projectId));
18+
}
19+
20+
return cache.get(cacheKey);
21+
}
22+
523
/**
624
* See all types and fields here {@see ../typeDefs/event.graphql}
725
*/
@@ -29,8 +47,8 @@ module.exports = {
2947
*
3048
* @return {RepetitionsPortion}
3149
*/
32-
async repetitionsPortion({ projectId, originalEventId }, { limit, cursor }) {
33-
const factory = new EventsFactory(projectId);
50+
async repetitionsPortion({ projectId, originalEventId }, { limit, cursor }, context) {
51+
const factory = getEventsFactoryForProjectId(context, projectId);
3452

3553
return factory.getEventRepetitions(originalEventId, limit, cursor);
3654
},
@@ -84,8 +102,8 @@ module.exports = {
84102
* @param {number} timezoneOffset - user's local timezone offset in minutes
85103
* @returns {Promise<ProjectChartItem[]>}
86104
*/
87-
async chartData({ projectId, groupHash }, { days, timezoneOffset }) {
88-
const factory = new EventsFactory(new ObjectID(projectId));
105+
async chartData({ projectId, groupHash }, { days, timezoneOffset }, context) {
106+
const factory = getEventsFactoryForProjectId(context, projectId);
89107

90108
return factory.findChartData(days, timezoneOffset, groupHash);
91109
},
@@ -97,8 +115,8 @@ module.exports = {
97115
* @param {String} eventId - event id
98116
* @returns {Promise<Release>}
99117
*/
100-
async release({ projectId, id: eventId }) {
101-
const factory = new EventsFactory(new ObjectID(projectId));
118+
async release({ projectId, id: eventId }, _args, context) {
119+
const factory = getEventsFactoryForProjectId(context, projectId);
102120
const release = await factory.getEventRelease(eventId);
103121

104122
return release;
@@ -114,8 +132,8 @@ module.exports = {
114132
* @param {UserInContext} user - user context
115133
* @return {Promise<boolean>}
116134
*/
117-
async visitEvent(_obj, { projectId, eventId }, { user }) {
118-
const factory = new EventsFactory(projectId);
135+
async visitEvent(_obj, { projectId, eventId }, { user, ...context }) {
136+
const factory = getEventsFactoryForProjectId(context, projectId);
119137

120138
const { result } = await factory.visitEvent(eventId, user.id);
121139

@@ -131,8 +149,8 @@ module.exports = {
131149
* @param {string} mark - mark to set
132150
* @return {Promise<boolean>}
133151
*/
134-
async toggleEventMark(_obj, { project, eventId, mark }) {
135-
const factory = new EventsFactory(project);
152+
async toggleEventMark(_obj, { project, eventId, mark }, context) {
153+
const factory = getEventsFactoryForProjectId(context, project);
136154

137155
const { result } = await factory.toggleEventMark(eventId, mark);
138156

@@ -155,9 +173,9 @@ module.exports = {
155173
* @param factories - factories for working with models
156174
* @return {Promise<boolean>}
157175
*/
158-
async updateAssignee(_obj, { input }, { factories, user }) {
176+
async updateAssignee(_obj, { input }, { factories, user, ...context }) {
159177
const { projectId, eventId, assignee } = input;
160-
const factory = new EventsFactory(projectId);
178+
const factory = getEventsFactoryForProjectId(context, projectId);
161179

162180
const userExists = await factories.usersFactory.findById(assignee);
163181

@@ -206,9 +224,9 @@ module.exports = {
206224
* @param factories - factories for working with models
207225
* @return {Promise<boolean>}
208226
*/
209-
async removeAssignee(_obj, { input }) {
227+
async removeAssignee(_obj, { input }, context) {
210228
const { projectId, eventId } = input;
211-
const factory = new EventsFactory(projectId);
229+
const factory = getEventsFactoryForProjectId(context, projectId);
212230

213231
const { result } = await factory.updateAssignee(eventId, '');
214232

src/resolvers/project.js

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,30 @@ const REPETITIONS_GROUP_HASH_INDEX_NAME = 'groupHash_hashed';
1414
const REPETITIONS_USER_ID_INDEX_NAME = 'userId';
1515
const MAX_SEARCH_QUERY_LENGTH = 50;
1616

17+
/**
18+
* Returns a singleton EventsFactory instance bound to a specific project object.
19+
* Uses request-scoped cache to share across nested resolvers.
20+
*
21+
* @param {ProjectDBScheme|Object} project - project instance to make a instance of EventsFactory
22+
* @param {ResolverContextBase} context - resolver context
23+
* @returns {EventsFactory} - EventsFactory instance bound to a specific project object
24+
*/
25+
function getEventsFactoryForProject(project, context) {
26+
const cache = context && context.eventsFactoryCache;
27+
const key = project._id.toString();
28+
29+
if (cache) {
30+
if (!cache.has(key)) {
31+
cache.set(key, new EventsFactory(project._id));
32+
}
33+
34+
return cache.get(key);
35+
}
36+
37+
// Fallback (shouldn't happen in normal resolver flow): return a fresh instance
38+
return new EventsFactory(project._id);
39+
}
40+
1741
/**
1842
* See all types and fields here {@see ../typeDefs/project.graphql}
1943
*/
@@ -288,8 +312,8 @@ module.exports = {
288312
*
289313
* @returns {EventRepetitionSchema}
290314
*/
291-
async event(project, { eventId: repetitionId, originalEventId }) {
292-
const factory = new EventsFactory(project._id);
315+
async event(project, { eventId: repetitionId, originalEventId }, context) {
316+
const factory = getEventsFactoryForProject(project, context);
293317
const repetition = await factory.getEventRepetition(repetitionId, originalEventId);
294318

295319
if (!repetition) {
@@ -310,8 +334,8 @@ module.exports = {
310334
* @param {Context.user} user - current authorized user {@see ../index.js}
311335
* @returns {Event[]}
312336
*/
313-
async events(project, { limit, skip }) {
314-
const factory = new EventsFactory(project._id);
337+
async events(project, { limit, skip }, context) {
338+
const factory = getEventsFactoryForProject(project, context);
315339

316340
return factory.find({}, limit, skip);
317341
},
@@ -325,8 +349,8 @@ module.exports = {
325349
*
326350
* @return {Promise<number>}
327351
*/
328-
async unreadCount(project, data, { user }) {
329-
const eventsFactory = new EventsFactory(project._id);
352+
async unreadCount(project, data, { user, ...context }) {
353+
const eventsFactory = getEventsFactoryForProject(project, context);
330354
const userInProject = new UserInProject(user.id, project._id);
331355
const lastVisit = await userInProject.getLastVisit();
332356

@@ -345,14 +369,14 @@ module.exports = {
345369
*
346370
* @return {Promise<RecentEventSchema[]>}
347371
*/
348-
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search }) {
372+
async dailyEventsPortion(project, { limit, nextCursor, sort, filters, search }, context) {
349373
if (search) {
350374
if (search.length > MAX_SEARCH_QUERY_LENGTH) {
351375
search = search.slice(0, MAX_SEARCH_QUERY_LENGTH);
352376
}
353377
}
354378

355-
const factory = new EventsFactory(project._id);
379+
const factory = getEventsFactoryForProject(project, context);
356380

357381
const dailyEventsPortion = await factory.findDailyEventsPortion(limit, nextCursor, sort, filters, search);
358382

@@ -368,8 +392,8 @@ module.exports = {
368392
*
369393
* @return {Promise<ProjectChartItem[]>}
370394
*/
371-
async chartData(project, { days, timezoneOffset }) {
372-
const factory = new EventsFactory(project._id);
395+
async chartData(project, { days, timezoneOffset }, context) {
396+
const factory = getEventsFactoryForProject(project, context);
373397

374398
return factory.findChartData(days, timezoneOffset);
375399
},

src/types/graphql.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export interface ResolverContextBase {
2020
*/
2121
factories: ContextFactories;
2222

23+
/**
24+
* Request-scoped cache for EventsFactory instances keyed by projectId
25+
*/
26+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27+
eventsFactoryCache: Map<string, any>;
28+
2329
// /**
2430
// * SDK for working with CodeX Accounting API
2531
// */
@@ -96,13 +102,13 @@ export interface ResolverContextWithUser extends ResolverContextBase {
96102
* e.g. in directive definition
97103
*/
98104
export type UnknownGraphQLField<TContext extends ResolverContextBase = ResolverContextBase>
99-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
105+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
100106
= GraphQLField<any, TContext>
101107

102108
/**
103109
* Use this type when you want to show that you don't know what GraphQL field resolver returns (to avoid 'any' type),
104110
* e.g. in directive definition
105111
*/
106112
export type UnknownGraphQLResolverResult
107-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
113+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
108114
= Promise<any>;

test/resolvers/billingNew.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ function createComposePaymentTestSetup(options: {
7171
};
7272

7373
const mockContext: ResolverContextWithUser = {
74+
eventsFactoryCache: new Map(),
7475
user: {
7576
id: userId,
7677
accessTokenExpired: false,

0 commit comments

Comments
 (0)