Skip to content

Commit 436ceea

Browse files
committed
Stop running prebuilds for inactive projects (10+ weeks)
Fixes #8911 Fixes prebuild rate limit
1 parent 66d39e5 commit 436ceea

15 files changed

+174
-35
lines changed

components/gitpod-db/src/project-db.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See License.enterprise.txt in the project root folder.
55
*/
66

7-
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue } from "@gitpod/gitpod-protocol";
7+
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue, ProjectUsage } from "@gitpod/gitpod-protocol";
88

99
export const ProjectDB = Symbol("ProjectDB");
1010
export interface ProjectDB {
@@ -30,4 +30,6 @@ export interface ProjectDB {
3030
getProjectEnvironmentVariableValues(envVars: ProjectEnvVar[]): Promise<ProjectEnvVarWithValue[]>;
3131
findCachedProjectOverview(projectId: string): Promise<Project.Overview | undefined>;
3232
storeCachedProjectOverview(projectId: string, overview: Project.Overview): Promise<void>;
33+
getProjectUsage(projectId: string): Promise<ProjectUsage | undefined>;
34+
updateProjectUsage(projectId: string, usage: Partial<ProjectUsage>): Promise<void>;
3335
}

components/gitpod-db/src/tables.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,12 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
256256
deletionColumn: "deleted",
257257
timeColumn: "_lastModified",
258258
},
259+
{
260+
name: "d_b_project_usage",
261+
primaryKeys: ["projectId"],
262+
deletionColumn: "deleted",
263+
timeColumn: "_lastModified",
264+
},
259265
/**
260266
* BEWARE
261267
*

components/gitpod-db/src/typeorm/deleted-entry-gc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const tables: TableWithDeletion[] = [
6161
{ deletionColumn: "deleted", name: "d_b_oss_allow_list" },
6262
{ deletionColumn: "deleted", name: "d_b_project_env_var" },
6363
{ deletionColumn: "deleted", name: "d_b_project_info" },
64+
{ deletionColumn: "deleted", name: "d_b_project_usage" },
6465
];
6566

6667
interface TableWithDeletion {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { Entity, Column, PrimaryColumn } from "typeorm";
8+
9+
import { TypeORM } from "../../typeorm/typeorm";
10+
11+
@Entity()
12+
// on DB but not Typeorm: @Index("ind_dbsync", ["_lastModified"]) // DBSync
13+
export class DBProjectUsage {
14+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
15+
projectId: string;
16+
17+
@Column("varchar")
18+
lastWebhookReceived: string;
19+
20+
@Column("varchar")
21+
lastWorkspaceStart: string;
22+
23+
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
24+
@Column()
25+
deleted: boolean;
26+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
9+
export class ProjectUsage1649667202321 implements MigrationInterface {
10+
public async up(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query(
12+
"CREATE TABLE IF NOT EXISTS `d_b_project_usage` (`projectId` char(36) NOT NULL, `lastWebhookReceived` varchar(255) NOT NULL DEFAULT '', `lastWorkspaceStart` varchar(255) NOT NULL DEFAULT '', `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`projectId`), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
13+
);
14+
}
15+
16+
public async down(queryRunner: QueryRunner): Promise<void> {}
17+
}

components/gitpod-db/src/typeorm/project-db-impl.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import { inject, injectable } from "inversify";
88
import { TypeORM } from "./typeorm";
99
import { Repository } from "typeorm";
1010
import { v4 as uuidv4 } from "uuid";
11-
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue } from "@gitpod/gitpod-protocol";
11+
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue, ProjectUsage } from "@gitpod/gitpod-protocol";
1212
import { EncryptionService } from "@gitpod/gitpod-protocol/lib/encryption/encryption-service";
1313
import { ProjectDB } from "../project-db";
1414
import { DBProject } from "./entity/db-project";
1515
import { DBProjectEnvVar } from "./entity/db-project-env-vars";
1616
import { DBProjectInfo } from "./entity/db-project-info";
17+
import { DBProjectUsage } from "./entity/db-project-usage";
1718

1819
function toProjectEnvVar(envVarWithValue: ProjectEnvVarWithValue): ProjectEnvVar {
1920
const envVar = { ...envVarWithValue };
@@ -42,6 +43,10 @@ export class ProjectDBImpl implements ProjectDB {
4243
return (await this.getEntityManager()).getRepository<DBProjectInfo>(DBProjectInfo);
4344
}
4445

46+
protected async getProjectUsageRepo(): Promise<Repository<DBProjectUsage>> {
47+
return (await this.getEntityManager()).getRepository<DBProjectUsage>(DBProjectUsage);
48+
}
49+
4550
public async findProjectById(projectId: string): Promise<Project | undefined> {
4651
const repo = await this.getRepo();
4752
return repo.findOne({ id: projectId, markedDeleted: false });
@@ -146,6 +151,11 @@ export class ProjectDBImpl implements ProjectDB {
146151
if (info) {
147152
await projectInfoRepo.update(projectId, { deleted: true });
148153
}
154+
const projectUsageRepo = await this.getProjectUsageRepo();
155+
const usage = await projectUsageRepo.findOne({ projectId, deleted: false });
156+
if (usage) {
157+
await projectUsageRepo.update(projectId, { deleted: true });
158+
}
149159
}
150160

151161
public async setProjectEnvironmentVariable(
@@ -229,4 +239,23 @@ export class ProjectDBImpl implements ProjectDB {
229239
creationTime: new Date().toISOString(),
230240
});
231241
}
242+
243+
public async getProjectUsage(projectId: string): Promise<ProjectUsage | undefined> {
244+
const projectUsageRepo = await this.getProjectUsageRepo();
245+
const usage = await projectUsageRepo.findOne({ projectId });
246+
if (usage) {
247+
return {
248+
lastWebhookReceived: usage.lastWebhookReceived,
249+
lastWorkspaceStart: usage.lastWorkspaceStart,
250+
};
251+
}
252+
}
253+
254+
public async updateProjectUsage(projectId: string, usage: Partial<ProjectUsage>): Promise<void> {
255+
const projectUsageRepo = await this.getProjectUsageRepo();
256+
await projectUsageRepo.save({
257+
projectId,
258+
...usage,
259+
});
260+
}
232261
}

components/gitpod-protocol/src/teams-projects-protocol.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export namespace Project {
6969

7070
export type PartialProject = DeepPartial<Project> & Pick<Project, "id">;
7171

72+
export interface ProjectUsage {
73+
lastWebhookReceived: string;
74+
lastWorkspaceStart: string;
75+
}
76+
7277
export interface PrebuildWithStatus {
7378
info: PrebuildInfo;
7479
status: PrebuiltWorkspaceState;

components/server/ee/src/prebuilds/bitbucket-app.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ export class BitbucketApp {
9595
): Promise<StartPrebuildResult | undefined> {
9696
const span = TraceContext.startSpan("Bitbucket.handlePushHook", ctx);
9797
try {
98+
const projectAndOwner = await this.findProjectAndOwner(data.gitCloneUrl, user);
99+
if (projectAndOwner.project) {
100+
/* tslint:disable-next-line */
101+
/** no await */ this.projectDB.updateProjectUsage(projectAndOwner.project.id, {
102+
lastWebhookReceived: new Date().toISOString(),
103+
});
104+
}
105+
98106
const contextURL = this.createContextUrl(data);
99107
const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
100108
span.setTag("contextURL", contextURL);
@@ -116,11 +124,10 @@ export class BitbucketApp {
116124
data.commitHash,
117125
);
118126
}
119-
const projectAndOwner = await this.findProjectAndOwner(data.gitCloneUrl, user);
120127
// todo@alex: add branch and project args
121128
const ws = await this.prebuildManager.startPrebuild(
122129
{ span },
123-
{ user, project: projectAndOwner?.project, context, commitInfo },
130+
{ user, project: projectAndOwner.project, context, commitInfo },
124131
);
125132
return ws;
126133
} finally {

components/server/ee/src/prebuilds/github-app.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,14 @@ export class GithubApp {
240240
const installationId = ctx.payload.installation?.id;
241241
const cloneURL = ctx.payload.repository.clone_url;
242242
let { user, project } = await this.findOwnerAndProject(installationId, cloneURL);
243-
const logCtx: LogContext = { userId: user.id };
243+
if (project) {
244+
/* tslint:disable-next-line */
245+
/** no await */ this.projectDB.updateProjectUsage(project.id, {
246+
lastWebhookReceived: new Date().toISOString(),
247+
});
248+
}
244249

250+
const logCtx: LogContext = { userId: user.id };
245251
if (!!user.blocked) {
246252
log.info(logCtx, `Blocked user tried to start prebuild`, { repo: ctx.payload.repository });
247253
return;
@@ -347,6 +353,12 @@ export class GithubApp {
347353
const pr = ctx.payload.pull_request;
348354
const contextURL = pr.html_url;
349355
let { user, project } = await this.findOwnerAndProject(installationId, cloneURL);
356+
if (project) {
357+
/* tslint:disable-next-line */
358+
/** no await */ this.projectDB.updateProjectUsage(project.id, {
359+
lastWebhookReceived: new Date().toISOString(),
360+
});
361+
}
350362

351363
const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
352364
const config = await this.prebuildManager.fetchConfig({ span }, user, context);

components/server/ee/src/prebuilds/github-enterprise-app.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ export class GitHubEnterpriseApp {
116116
): Promise<StartPrebuildResult | undefined> {
117117
const span = TraceContext.startSpan("GitHubEnterpriseApp.handlePushHook", ctx);
118118
try {
119+
const cloneURL = payload.repository.clone_url;
120+
const projectAndOwner = await this.findProjectAndOwner(cloneURL, user);
121+
if (projectAndOwner.project) {
122+
/* tslint:disable-next-line */
123+
/** no await */ this.projectDB.updateProjectUsage(projectAndOwner.project.id, {
124+
lastWebhookReceived: new Date().toISOString(),
125+
});
126+
}
127+
119128
const contextURL = this.createContextUrl(payload);
120129
span.setTag("contextURL", contextURL);
121130
const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
@@ -127,15 +136,13 @@ export class GitHubEnterpriseApp {
127136

128137
log.debug("GitHub Enterprise push event: Starting prebuild.", { contextURL });
129138

130-
const cloneURL = payload.repository.clone_url;
131-
const projectAndOwner = await this.findProjectAndOwner(cloneURL, user);
132139
const commitInfo = await this.getCommitInfo(user, payload.repository.url, payload.after);
133140
const ws = await this.prebuildManager.startPrebuild(
134141
{ span },
135142
{
136143
context,
137144
user: projectAndOwner.user,
138-
project: projectAndOwner?.project,
145+
project: projectAndOwner.project,
139146
commitInfo,
140147
},
141148
);

components/server/ee/src/prebuilds/gitlab-app.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ export class GitLabApp {
108108
span.setTag("contextURL", contextURL);
109109
const context = (await this.contextParser.handle({ span }, user, contextURL)) as CommitContext;
110110
const projectAndOwner = await this.findProjectAndOwner(context.repository.cloneUrl, user);
111+
if (projectAndOwner.project) {
112+
/* tslint:disable-next-line */
113+
/** no await */ this.projectDB.updateProjectUsage(projectAndOwner.project.id, {
114+
lastWebhookReceived: new Date().toISOString(),
115+
});
116+
}
117+
111118
const config = await this.prebuildManager.fetchConfig({ span }, user, context);
112119
if (!this.prebuildManager.shouldPrebuild(config)) {
113120
log.debug({ userId: user.id }, "GitLab push hook: There is no prebuild config.", {
@@ -123,8 +130,8 @@ export class GitLabApp {
123130
const ws = await this.prebuildManager.startPrebuild(
124131
{ span },
125132
{
126-
user: projectAndOwner?.user || user,
127-
project: projectAndOwner?.project,
133+
user: projectAndOwner.user || user,
134+
project: projectAndOwner.project,
128135
context,
129136
commitInfo,
130137
},

components/server/ee/src/prebuilds/prebuild-manager.ts

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -159,33 +159,11 @@ export class PrebuildManager {
159159
prebuildContext,
160160
context.normalizedContextURL!,
161161
);
162-
const prebuildPromise = this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspace.id)!;
163-
164-
span.setTag("starting", true);
165-
const projectEnvVars = await projectEnvVarsPromise;
166-
await this.workspaceStarter.startWorkspace({ span }, workspace, user, [], projectEnvVars, {
167-
excludeFeatureFlags: ["full_workspace_backup"],
168-
});
169-
const prebuild = await prebuildPromise;
162+
const prebuild = await this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspace.id)!;
170163
if (!prebuild) {
171164
throw new Error(`Failed to create a prebuild for: ${context.normalizedContextURL}`);
172165
}
173166

174-
if (await this.shouldRateLimitPrebuild(span, cloneURL)) {
175-
prebuild.state = "aborted";
176-
prebuild.error =
177-
"Prebuild is rate limited. Please contact Gitpod if you believe this happened in error.";
178-
179-
await this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild);
180-
span.setTag("starting", false);
181-
span.setTag("ratelimited", true);
182-
return {
183-
wsid: workspace.id,
184-
prebuildId: prebuild.id,
185-
done: false,
186-
};
187-
}
188-
189167
if (project) {
190168
let aCommitInfo = commitInfo;
191169
if (!aCommitInfo) {
@@ -205,6 +183,26 @@ export class PrebuildManager {
205183
}
206184
await this.storePrebuildInfo({ span }, project, prebuild, workspace, user, aCommitInfo);
207185
}
186+
187+
if (await this.shouldRateLimitPrebuild(span, cloneURL)) {
188+
prebuild.state = "aborted";
189+
prebuild.error =
190+
"Prebuild is rate limited. Please contact Gitpod if you believe this happened in error.";
191+
await this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild);
192+
span.setTag("ratelimited", true);
193+
} else if (project && (await this.shouldSkipInactiveProject(project))) {
194+
prebuild.state = "aborted";
195+
prebuild.error =
196+
"Project is inactive. Please start a new workspace for this project to re-enable prebuilds.";
197+
await this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild);
198+
} else {
199+
span.setTag("starting", true);
200+
const projectEnvVars = await projectEnvVarsPromise;
201+
await this.workspaceStarter.startWorkspace({ span }, workspace, user, [], projectEnvVars, {
202+
excludeFeatureFlags: ["full_workspace_backup"],
203+
});
204+
}
205+
208206
return { prebuildId: prebuild.id, wsid: workspace.id, done: false };
209207
} catch (err) {
210208
TraceContext.setError({ span }, err);
@@ -347,4 +345,15 @@ export class PrebuildManager {
347345
// Last resort default
348346
return PREBUILD_LIMITER_DEFAULT_LIMIT;
349347
}
348+
349+
private async shouldSkipInactiveProject(project: Project): Promise<boolean> {
350+
const usage = await this.projectService.getProjectUsage(project.id);
351+
if (!usage?.lastWorkspaceStart) {
352+
return false;
353+
}
354+
const now = Date.now();
355+
const lastUse = new Date(usage.lastWorkspaceStart).getTime();
356+
const inactiveProjectTime = 1000 * 60 * 60 * 24 * 7 * 10; // 10 weeks
357+
return now - lastUse > inactiveProjectTime;
358+
}
350359
}

components/server/src/projects/projects-service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { HostContextProvider } from "../auth/host-context-provider";
2020
import { RepoURL } from "../repohost";
2121
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
22-
import { PartialProject } from "@gitpod/gitpod-protocol/src/teams-projects-protocol";
22+
import { PartialProject, ProjectUsage } from "@gitpod/gitpod-protocol/src/teams-projects-protocol";
2323

2424
@injectable()
2525
export class ProjectsService {
@@ -248,4 +248,8 @@ export class ProjectsService {
248248
async deleteProjectEnvironmentVariable(variableId: string): Promise<void> {
249249
return this.projectDB.deleteProjectEnvironmentVariable(variableId);
250250
}
251+
252+
async getProjectUsage(projectId: string): Promise<ProjectUsage | undefined> {
253+
return this.projectDB.getProjectUsage(projectId);
254+
}
251255
}

components/server/src/user/user-controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ export class UserController {
580580
await this.userService.updateUserEnvVarsOnLogin(user, envVars);
581581
await this.userService.acceptCurrentTerms(user);
582582

583-
/* no await */ trackSignup(user, req, this.analytics).catch((err) =>
583+
/** no await */ trackSignup(user, req, this.analytics).catch((err) =>
584584
log.warn({ userId: user.id }, "trackSignup", err),
585585
);
586586

components/server/src/workspace/workspace-starter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ export class WorkspaceStarter {
206206
const span = TraceContext.startSpan("WorkspaceStarter.startWorkspace", ctx);
207207
span.setTag("workspaceId", workspace.id);
208208

209+
if (workspace.projectId && workspace.type === "regular") {
210+
/* tslint:disable-next-line */
211+
/** no await */ this.projectDB.updateProjectUsage(workspace.projectId, {
212+
lastWorkspaceStart: new Date().toISOString(),
213+
});
214+
}
215+
209216
options = options || {};
210217
try {
211218
// Some workspaces do not have an image source.

0 commit comments

Comments
 (0)