Skip to content

Commit f29feca

Browse files
💡 Advanced caching for team members (#99)
This PR adds eTag checking for team member listing. This should result in significant reductions in GitHub API calls. This will increase performance of each sync, while also reducing the amount of times rate limits are hit for larger orgs.
1 parent 0a47217 commit f29feca

11 files changed

Lines changed: 518 additions & 166 deletions

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
"main": "app.ts",
66
"module": "es2015",
77
"scripts": {
8-
"test:unit": "jest test/unit --coverage --reporters=jest-junit",
8+
"test:unit": "jest test/unit",
99
"test:int": "jest test/integration",
1010
"test:all": "jest --coverage --reporters=jest-junit",
11-
"test": "npm run test:unit",
11+
"test": "jest test/unit",
1212
"dev": "nodemon",
1313
"start": "node ./out/app.js",
1414
"build": "tsc && shx cp ./src/openapi.yaml ./out/openapi.yaml",
@@ -26,10 +26,11 @@
2626
"@types/swagger-ui-express": "^4.1.3",
2727
"graphql-tag": "^2.12.6",
2828
"jest": "^29.7.0",
29-
"jest-junit": "^16.0.0",
29+
"jest-junit": "^16.0.0",
3030
"nodemon": "^3.0.2",
3131
"shx": "^0.3.4",
3232
"ts-jest": "^29.2.5",
33+
"ts-mockito": "^2.6.1",
3334
"ts-node": "^10.9.1",
3435
"typescript": "^5.6.3"
3536
},
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { CacheClient } from "../app";
2+
import { ICacheClient } from "../services/CacheClient";
3+
4+
export class RedisCacheClient implements ICacheClient {
5+
constructor(private cacheClient: CacheClient){}
6+
7+
async set(cacheKey: string, object: string, options: { EX: number; }): Promise<undefined> {
8+
await this.cacheClient.set(cacheKey, object, {
9+
EX: options.EX
10+
});
11+
}
12+
13+
async get(cacheKey: string): Promise<string | null> {
14+
return await this.cacheClient.get(cacheKey);
15+
}
16+
17+
}

src/services/CacheClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ICacheClient {
2+
set(cacheKey: string, object: string, options: { EX: number; }): Promise<undefined>;
3+
get(cacheKey: string): Promise<string | undefined | null>;
4+
}

src/services/gitHub.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import { Octokit } from "octokit";
22
import { createAppAuth } from "@octokit/auth-app";
33
import { Config } from "../config";
4-
import { GitHubClient, InstalledClient, Org } from "./gitHubTypes";
4+
import { GitHubClient, IInstalledClient, Org } from "./gitHubTypes";
55
import { AppConfig } from "./appConfig";
66
import yaml from "js-yaml";
77
import { throttling } from "@octokit/plugin-throttling";
88
import { Log, LoggerToUse } from "../logging";
99
import { GitHubClientCache } from "./gitHubCache";
1010
import { redisClient } from "../app";
1111
import { InstalledGitHubClient } from "./installedGitHubClient";
12+
import { RedisCacheClient } from "../integrations/redisCacheClient";
1213

1314
const config = Config();
1415

15-
async function GetOrgClient(installationId: number): Promise<InstalledClient> {
16+
async function GetOrgClient(installationId: number): Promise<IInstalledClient> {
1617
// TODO: look further into this... it seems like it would be best if
1718
// installation client was generated from the original client, and not
1819
// created fresh.
@@ -65,9 +66,10 @@ async function GetOrgClient(installationId: number): Promise<InstalledClient> {
6566

6667
// HACK: gross typing nonsense
6768
const baseClient = new InstalledGitHubClient(installedOctokit, (orgName.data.account as thisShouldNotBeNeeded).login);
69+
const redisCacheClient = new RedisCacheClient(redisClient);
6870

6971
if (Config().AppOptions.RedisHost) {
70-
const cachedClient = new GitHubClientCache(baseClient, redisClient, LoggerToUse());
72+
const cachedClient = new GitHubClientCache(baseClient, redisCacheClient, LoggerToUse());
7173
return cachedClient;
7274
}
7375

src/services/gitHubCache.ts

Lines changed: 125 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
1-
import { CacheClient } from "../app";
21
import { ILogger } from "../logging";
3-
import { AddMemberResponse, CopilotAddResponse, GitHubTeamId, InstalledClient, OrgConfigResponse, OrgInvite, OrgRoles, RemoveMemberResponse, Response } from "./gitHubTypes";
4-
5-
export class GitHubClientCache implements InstalledClient {
6-
client: InstalledClient;
7-
cacheClient: CacheClient;
8-
logger:ILogger;
9-
10-
constructor(client: InstalledClient, cacheClient: CacheClient, logger:ILogger) {
11-
this.client = client;
12-
this.cacheClient = cacheClient;
13-
this.logger = logger;
14-
}
2+
import { AddMemberResponse, CopilotAddResponse, GitHubTeamId, IInstalledClient as IInstalledClient, IRawInstalledGitHubClient, OrgConfigResponse, OrgInvite, OrgRoles, RemoveMemberResponse, Response } from "./gitHubTypes";
3+
import { ICacheClient } from "./CacheClient";
4+
5+
/**
6+
* This class decorates the InstalledClient with additional caching logic. In general,
7+
* this logic should be kept simple. However, in an effort to reduce the bleeding of
8+
* complex logic into the concrete InstalledClient implementations, some complexity
9+
* may be acceptable here (such as checking for eTag and then retrying a call).
10+
*
11+
* Chain of responsibility:
12+
*
13+
* eTag checker
14+
* if there are no changes, check cache and return
15+
* if cache has nothing, call client, cache value, then return
16+
* if there are changes, check client, cache changes and etag, return
17+
*/
18+
export class GitHubClientCache implements IInstalledClient {
19+
constructor(private client: IRawInstalledGitHubClient, private cacheClient: ICacheClient, private logger: ILogger) { }
1520

1621
AddTeamsToCopilotSubscription(teamNames: string[]): Response<CopilotAddResponse[]> {
1722
return this.client.AddTeamsToCopilotSubscription(teamNames);
1823
}
19-
24+
2025
ListPendingInvitesForTeam(teamName: string): Response<OrgInvite[]> {
2126
return this.client.ListPendingInvitesForTeam(teamName);
2227
}
23-
28+
2429
CancelOrgInvite(invite: OrgInvite): Response<unknown> {
2530
return this.client.CancelOrgInvite(invite);
2631
}
@@ -45,36 +50,36 @@ export class GitHubClientCache implements InstalledClient {
4550
return this.client.AddOrgMember(id);
4651
}
4752

48-
async IsUserMember(id: string): Response<boolean> {
53+
async IsUserMember(id: string): Response<boolean> {
4954
const cacheKey = `github-member-1:${id}-${this.GetCurrentOrgName()}`;
5055

51-
const result = await this.cacheClient.get(cacheKey);
56+
const result = await this.cacheClient.get(cacheKey);
5257

5358
if (result) {
5459
this.ReportCacheHit({
5560
cacheKey: cacheKey,
5661
operation: "IsUserMember",
57-
value: result,
62+
value: JSON.stringify(result),
5863
user: id
5964
});
6065

6166
return {
6267
successful: true,
6368
data: Boolean(result)
6469
}
65-
}
70+
}
6671

6772
const actualResult = await this.client.IsUserMember(id);
68-
69-
if (actualResult.successful) {
73+
74+
if (actualResult.successful) {
7075
// TODO: switch all cache expirations to application configuration values.
71-
76+
7277
const userIsMember = actualResult.data;
7378

74-
if(userIsMember) {
79+
if (userIsMember) {
7580
await this.cacheClient.set(cacheKey, userIsMember.toString(), {
7681
EX: 172800 // Expire every 2 days
77-
});
82+
});
7883
}
7984
else {
8085
// If membership check comes back as "not a member," we still want to cache
@@ -84,11 +89,11 @@ export class GitHubClientCache implements InstalledClient {
8489
await this.cacheClient.set(cacheKey, userIsMember.toString(), {
8590
EX: 1800 // Expire every 30 minutes
8691
// It is unlikely that 30 minutes will cause much pain
87-
});
88-
}
92+
});
93+
}
8994
}
9095

91-
return actualResult;
96+
return actualResult;
9297
}
9398

9499
GetAllTeams(): Response<GitHubTeamId[]> {
@@ -106,18 +111,18 @@ export class GitHubClientCache implements InstalledClient {
106111
async DoesUserExist(gitHubId: string): Response<string> {
107112
const cacheKey = `github-user-2:${gitHubId}`;
108113

109-
const result = await this.cacheClient.get(cacheKey);
114+
const result = await this.cacheClient.get(cacheKey);
110115

111116
if (result) {
112117
this.ReportCacheHit({
113118
operation: "DoesUserExist",
114119
user: gitHubId,
115120
value: result,
116121
cacheKey: cacheKey
117-
});
122+
});
118123

119124
return JSON.parse(result);
120-
}
125+
}
121126

122127
const actualResult = await this.client.DoesUserExist(gitHubId);
123128

@@ -142,8 +147,85 @@ export class GitHubClientCache implements InstalledClient {
142147
return actualResult;
143148
}
144149

145-
ListCurrentMembersOfGitHubTeam(team: string): Response<string[]> {
146-
return this.client.ListCurrentMembersOfGitHubTeam(team);
150+
async ListCurrentMembersOfGitHubTeam(team: string): Response<string[]> {
151+
const teamSlug = `${this.GetCurrentOrgName()}_${team}`;
152+
const eTagCacheKey = `t-e:${teamSlug}`;
153+
const teamCacheKey = `t:${teamSlug}`;
154+
155+
const cachedEtag = await this.cacheClient.get(eTagCacheKey) ?? "";
156+
157+
const eTagResponse = await this.client.ListMembersOfTeamEtagCheck(team, cachedEtag);
158+
159+
if (eTagResponse.successful == false) {
160+
return {
161+
successful: false
162+
}
163+
}
164+
165+
this.ReportCacheHit({
166+
operation: "eTag-TeamMembers",
167+
team: team,
168+
value: cachedEtag,
169+
cacheKey: eTagCacheKey
170+
})
171+
172+
let newETag = "";
173+
let teamMembers: string[] | undefined = undefined;
174+
175+
if (eTagResponse.successful == "no_changes") {
176+
newETag = eTagResponse.eTag;
177+
178+
const cachedTeamMembers = await this.cacheClient.get(teamCacheKey);
179+
180+
if (cachedTeamMembers) {
181+
teamMembers = JSON.parse(cachedTeamMembers);
182+
this.ReportCacheHit({
183+
operation: "TeamMembers",
184+
team: team,
185+
value: cachedTeamMembers,
186+
cacheKey: teamCacheKey
187+
})
188+
}
189+
else {
190+
const newTeamMembersResponse = await this.client.ListCurrentMembersOfGitHubTeam(team);
191+
192+
if (newTeamMembersResponse.successful == false) {
193+
return {
194+
successful: false
195+
}
196+
}
197+
198+
await this.cacheClient.set(teamCacheKey, JSON.stringify(newTeamMembersResponse.data), { EX: fourteenDaysInSeconds });
199+
teamMembers = newTeamMembersResponse.data;
200+
}
201+
}
202+
203+
if (eTagResponse.successful == true) {
204+
newETag = eTagResponse.data;
205+
const newTeamMembersResponse = await this.client.ListCurrentMembersOfGitHubTeam(team);
206+
207+
if (newTeamMembersResponse.successful == false) {
208+
return {
209+
successful: false
210+
}
211+
}
212+
213+
await this.cacheClient.set(teamCacheKey, JSON.stringify(newTeamMembersResponse.data), { EX: fourteenDaysInSeconds });
214+
teamMembers = newTeamMembersResponse.data;
215+
}
216+
217+
await this.cacheClient.set(eTagCacheKey, newETag, { EX: fourteenDaysInSeconds });
218+
219+
if (teamMembers === undefined) {
220+
return {
221+
successful: false
222+
}
223+
}
224+
225+
return {
226+
successful: true,
227+
data: teamMembers
228+
};
147229
}
148230

149231
RemoveTeamMemberAsync(team: string, user: string): RemoveMemberResponse {
@@ -160,28 +242,31 @@ export class GitHubClientCache implements InstalledClient {
160242

161243
GetConfigurationForInstallation(): OrgConfigResponse {
162244
return this.client.GetConfigurationForInstallation();
163-
}
245+
}
164246

165-
private ReportCacheHit(props: {operation: string, user?: string, team?:string, value: string, cacheKey:string}) {
166-
const properties:any = {
247+
private ReportCacheHit(props: { operation: string, user?: string, team?: string, value: string, cacheKey: string }) {
248+
const properties: any = {
167249
"Group": "GitHub",
168250
"Operation": props.operation,
169-
"Org": this.GetCurrentOrgName(),
251+
"Org": this.GetCurrentOrgName(),
170252
"Value": props.value,
171253
"CacheKey": props.cacheKey
172254
};
173255

174-
if(props.user) {
256+
if (props.user) {
175257
properties["User"] = props.user;
176258
}
177259

178-
if(props.team) {
260+
if (props.team) {
179261
properties["Team"] = props.team;
180262
}
181263

182264
this.logger.ReportEvent({
183-
Name:"CacheHit",
265+
Name: "CacheHit",
184266
properties: properties
185267
});
186268
}
187-
}
269+
}
270+
271+
const twoDaysInSeconds = 172_800;
272+
const fourteenDaysInSeconds = 1_209_600;

0 commit comments

Comments
 (0)