Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[dashboard][server] Make Project overview page load faster by pre-fetching and caching Git provider data (branch details) #7610

Merged
merged 1 commit into from
Jan 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions components/gitpod-db/src/project-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ export interface ProjectDB {
getProjectEnvironmentVariableById(variableId: string): Promise<ProjectEnvVar | undefined>;
deleteProjectEnvironmentVariable(variableId: string): Promise<void>;
getProjectEnvironmentVariableValues(envVars: ProjectEnvVar[]): Promise<ProjectEnvVarWithValue[]>;
findCachedProjectOverview(projectId: string): Promise<Project.Overview | undefined>;
storeCachedProjectOverview(projectId: string, overview: Project.Overview): Promise<void>;
}
6 changes: 6 additions & 0 deletions components/gitpod-db/src/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
deletionColumn: 'deleted',
timeColumn: '_lastModified',
},
{
name: 'd_b_project_info',
primaryKeys: ['projectId'],
deletionColumn: 'deleted',
timeColumn: '_lastModified',
},
/**
* BEWARE
*
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-db/src/typeorm/deleted-entry-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const tables: TableWithDeletion[] = [
{ deletionColumn: "deleted", name: "d_b_prebuild_info" },
{ deletionColumn: "deleted", name: "d_b_oss_allow_list" },
{ deletionColumn: "deleted", name: "d_b_project_env_var" },
{ deletionColumn: "deleted", name: "d_b_project_info" },
];

interface TableWithDeletion {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PrebuildInfo } from "@gitpod/gitpod-protocol";
import { TypeORM } from "../../typeorm/typeorm";

@Entity()
export class DBPrebuildInfo {
export class DBPrebuildInfo {

@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
prebuildId: string;
Expand Down
43 changes: 43 additions & 0 deletions components/gitpod-db/src/typeorm/entity/db-project-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the Gitpod Enterprise Source Code License,
* See License.enterprise.txt in the project root folder.
*/

import { Entity, Column, PrimaryColumn } from "typeorm";
import { Project } from "@gitpod/gitpod-protocol";

import { TypeORM } from "../../typeorm/typeorm";

@Entity()
// on DB but not Typeorm: @Index("ind_dbsync", ["_lastModified"]) // DBSync
export class DBProjectInfo {

@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
projectId: string;

@Column({
type: 'simple-json',
transformer: (() => {
return {
to(value: any): any {
return JSON.stringify(value);
},
from(value: any): any {
try {
const obj = JSON.parse(value);
if (Project.Overview.is(obj)) {
return obj;
}
} catch (error) {
}
}
};
})()
})
overview: Project.Overview;

// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
@Column()
deleted: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { MigrationInterface, QueryRunner } from "typeorm";

export class ProjectInfo1642497869312 implements MigrationInterface {

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query("CREATE TABLE IF NOT EXISTS `d_b_project_info` ( `projectId` char(36) NOT NULL, `overview` longtext NOT NULL, `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;");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I'm using longtext for overview instead of just text because the (enormous) Project Overview of the GitLab repository (my stress test) wouldn't fit into a simple text.

We could also look into trimming unnecessary information from Project Overviews, but I'd leave this as a potential future optimization.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should look into this issue. I assume it's because of commit message length.

}

public async down(queryRunner: QueryRunner): Promise<void> {
}

}
26 changes: 26 additions & 0 deletions components/gitpod-db/src/typeorm/project-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { EncryptionService } from "@gitpod/gitpod-protocol/lib/encryption/encryp
import { ProjectDB } from "../project-db";
import { DBProject } from "./entity/db-project";
import { DBProjectEnvVar } from "./entity/db-project-env-vars";
import { DBProjectInfo } from "./entity/db-project-info";

function toProjectEnvVar(envVarWithValue: ProjectEnvVarWithValue): ProjectEnvVar {
const envVar = { ...envVarWithValue };
Expand All @@ -37,6 +38,10 @@ export class ProjectDBImpl implements ProjectDB {
return (await this.getEntityManager()).getRepository<DBProjectEnvVar>(DBProjectEnvVar);
}

protected async getProjectInfoRepo(): Promise<Repository<DBProjectInfo>> {
return (await this.getEntityManager()).getRepository<DBProjectInfo>(DBProjectInfo);
}

public async findProjectById(projectId: string): Promise<Project | undefined> {
const repo = await this.getRepo();
return repo.findOne({ id: projectId, markedDeleted: false });
Expand Down Expand Up @@ -106,6 +111,12 @@ export class ProjectDBImpl implements ProjectDB {
project.markedDeleted = true;
await repo.save(project);
}
// Delete any additional cached infos about this project
const projectInfoRepo = await this.getProjectInfoRepo();
const info = await projectInfoRepo.findOne({ projectId, deleted: false });
if (info) {
await projectInfoRepo.update(projectId, { deleted: true });
}
}

public async setProjectEnvironmentVariable(projectId: string, name: string, value: string, censored: boolean): Promise<void> {
Expand Down Expand Up @@ -164,4 +175,19 @@ export class ProjectDBImpl implements ProjectDB {
const envVarsWithValues = await envVarRepo.findByIds(envVars);
return envVarsWithValues;
}

public async findCachedProjectOverview(projectId: string): Promise<Project.Overview | undefined> {
const projectInfoRepo = await this.getProjectInfoRepo();
const info = await projectInfoRepo.findOne({ projectId });
return info?.overview;
}

public async storeCachedProjectOverview(projectId: string, overview: Project.Overview): Promise<void> {
const projectInfoRepo = await this.getProjectInfoRepo();
await projectInfoRepo.save({
projectId,
overview,
creationTime: new Date().toISOString(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not using for now, but let's keep it. we can add more optimization based on it.

});
}
}
8 changes: 7 additions & 1 deletion components/gitpod-protocol/src/teams-projects-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ export namespace Project {
}

export interface Overview {
branches: BranchDetails[]
branches: BranchDetails[];
}

export namespace Overview {
export function is(data?: any): data is Project.Overview {
return Array.isArray(data?.branches);
}
}

export interface BranchDetails {
Expand Down
26 changes: 23 additions & 3 deletions components/server/src/projects/projects-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,25 @@ export class ProjectsService {
return this.projectDB.findProjectsByCloneUrls(cloneUrls);
}

async getProjectOverview(user: User, project: Project): Promise<Project.Overview | undefined> {
const branches = await this.getBranchDetails(user, project);
return { branches };
async getProjectOverviewCached(user: User, project: Project): Promise<Project.Overview | undefined> {
// Check for a cached project overview (fast!)
const cachedPromise = this.projectDB.findCachedProjectOverview(project.id);

// ...but also refresh the cache on every request (asynchronously / in the background)
const refreshPromise = this.getBranchDetails(user, project).then(branches => {
const overview = { branches };
// No need to await here
this.projectDB.storeCachedProjectOverview(project.id, overview).catch(error => {
log.error(`Could not store cached project overview: ${error}`, { cloneUrl: project.cloneUrl })
});
return overview;
});

const cachedOverview = await cachedPromise;
if (cachedOverview) {
return cachedOverview;
}
return await refreshPromise;
}

protected getRepositoryProvider(project: Project) {
Expand Down Expand Up @@ -113,6 +129,10 @@ export class ProjectsService {
}

protected async onDidCreateProject(project: Project, installer: User) {
// Pre-fetch project details in the background -- don't await
this.getProjectOverviewCached(installer, project);

// Install the prebuilds webhook if possible
let { userId, teamId, cloneUrl } = project;
const parsedUrl = RepoURL.parseRepoUrl(project.cloneUrl);
const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined;
Expand Down
2 changes: 1 addition & 1 deletion components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1644,7 +1644,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
}
await this.guardProjectOperation(user, projectId, "get");
try {
return await this.projectsService.getProjectOverview(user, project);
return await this.projectsService.getProjectOverviewCached(user, project);
} catch (error) {
if (UnauthorizedError.is(error)) {
throw new ResponseError(ErrorCodes.NOT_AUTHENTICATED, "Unauthorized", error.data);
Expand Down