Skip to content

Commit 5cb145e

Browse files
authored
Reconcile startWorkspace (#18673)
* [server] Reconcile workspace starts using redlock * [server] Check abortSignal before updating the DB state * [server] Add feature flag workspace_start_controller review comments
1 parent 45ac12d commit 5cb145e

File tree

11 files changed

+305
-212
lines changed

11 files changed

+305
-212
lines changed

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

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -379,45 +379,6 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
379379
return qb.getOne();
380380
}
381381

382-
public async findAllWorkspaceInstances(
383-
offset: number,
384-
limit: number,
385-
orderBy: keyof WorkspaceInstance,
386-
orderDir: "ASC" | "DESC",
387-
ownerId?: string,
388-
minCreationTime?: Date,
389-
maxCreationTime?: Date,
390-
onlyRunning?: boolean,
391-
type?: WorkspaceType,
392-
): Promise<{ total: number; rows: WorkspaceInstance[] }> {
393-
const workspaceInstanceRepo = await this.getWorkspaceInstanceRepo();
394-
const queryBuilder = workspaceInstanceRepo
395-
.createQueryBuilder("wsi")
396-
.leftJoinAndMapOne("wsi.workspace", DBWorkspace, "ws", "wsi.workspaceId = ws.id")
397-
.skip(offset)
398-
.take(limit)
399-
.orderBy("wsi." + orderBy, orderDir)
400-
.where("ws.type = :type", { type: type ? type.toString() : "regular" }); // only regular workspaces by default
401-
if (ownerId) {
402-
queryBuilder.andWhere("wsi.ownerId = :ownerId", { ownerId });
403-
}
404-
if (minCreationTime) {
405-
queryBuilder.andWhere("wsi.creationTime >= :minCreationTime", {
406-
minCreationTime: minCreationTime.toISOString(),
407-
});
408-
}
409-
if (maxCreationTime) {
410-
queryBuilder.andWhere("wsi.creationTime < :maxCreationTime", {
411-
maxCreationTime: maxCreationTime.toISOString(),
412-
});
413-
}
414-
if (onlyRunning) {
415-
queryBuilder.andWhere("wsi.phasePersisted != 'stopped'").andWhere("wsi.deleted != TRUE");
416-
}
417-
const [rows, total] = await queryBuilder.getManyAndCount();
418-
return { total, rows };
419-
}
420-
421382
public async getInstanceCount(type?: string): Promise<number> {
422383
const workspaceInstanceRepo = await this.getWorkspaceInstanceRepo();
423384
const queryBuilder = workspaceInstanceRepo
@@ -1096,13 +1057,17 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
10961057
return <WorkspaceAndInstance>res;
10971058
}
10981059

1099-
async findInstancesByPhaseAndRegion(phase: string, region: string): Promise<WorkspaceInstance[]> {
1060+
async findInstancesByPhase(phases: string[]): Promise<WorkspaceInstance[]> {
1061+
if (phases.length < 0) {
1062+
throw new Error("At least one phase must be provided");
1063+
}
1064+
11001065
const repo = await this.getWorkspaceInstanceRepo();
1101-
// uses index: ind_phasePersisted_region
1066+
// uses index: ind_phasePersisted
11021067
const qb = repo
11031068
.createQueryBuilder("wsi")
1104-
.where("wsi.phasePersisted = :phase", { phase })
1105-
.andWhere("wsi.region = :region", { region });
1069+
.where("wsi.deleted != TRUE")
1070+
.andWhere("wsi.phasePersisted IN (:phases)", { phases });
11061071
return qb.getMany();
11071072
}
11081073

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

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,24 +130,12 @@ export interface WorkspaceDB {
130130
query?: AdminGetWorkspacesQuery,
131131
): Promise<{ total: number; rows: WorkspaceAndInstance[] }>;
132132
findWorkspaceAndInstance(id: string): Promise<WorkspaceAndInstance | undefined>;
133-
findInstancesByPhaseAndRegion(phase: string, region: string): Promise<WorkspaceInstance[]>;
133+
findInstancesByPhase(phases: string[]): Promise<WorkspaceInstance[]>;
134134

135135
getWorkspaceCount(type?: String): Promise<Number>;
136136
getWorkspaceCountByCloneURL(cloneURL: string, sinceLastDays?: number, type?: string): Promise<number>;
137137
getInstanceCount(type?: string): Promise<number>;
138138

139-
findAllWorkspaceInstances(
140-
offset: number,
141-
limit: number,
142-
orderBy: keyof WorkspaceInstance,
143-
orderDir: "ASC" | "DESC",
144-
ownerId?: string,
145-
minCreationTime?: Date,
146-
maxCreationTime?: Date,
147-
onlyRunning?: boolean,
148-
type?: WorkspaceType,
149-
): Promise<{ total: number; rows: WorkspaceInstance[] }>;
150-
151139
findRegularRunningInstances(userId?: string): Promise<WorkspaceInstance[]>;
152140
findRunningInstancesWithWorkspaces(
153141
workspaceClusterName?: string,

components/gitpod-protocol/src/util/timeutil.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export function rightBefore(date: string): string {
7777
return new Date(new Date(date).getTime() - 1).toISOString();
7878
}
7979

80+
export function durationLongerThanSeconds(time: number, durationSeconds: number, now: number = Date.now()): boolean {
81+
return (now - time) / 1000 > durationSeconds;
82+
}
83+
8084
export function millisecondsToHours(milliseconds: number): number {
8185
return milliseconds / 1000 / 60 / 60;
8286
}

components/server/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ import { GitpodTokenService } from "./user/gitpod-token-service";
133133
import { EnvVarService } from "./user/env-var-service";
134134
import { ScmService } from "./projects/scm-service";
135135
import { RelationshipUpdateJob } from "./authorization/relationship-updater-job";
136+
import { WorkspaceStartController } from "./workspace/workspace-start-controller";
136137

137138
export const productionContainerModule = new ContainerModule(
138139
(bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => {
@@ -170,6 +171,7 @@ export const productionContainerModule = new ContainerModule(
170171
bind(WorkspaceService).toSelf().inSingletonScope();
171172
bind(WorkspaceFactory).toSelf().inSingletonScope();
172173
bind(WorkspaceStarter).toSelf().inSingletonScope();
174+
bind(WorkspaceStartController).toSelf().inSingletonScope();
173175
bind(ImageSourceProvider).toSelf().inSingletonScope();
174176

175177
bind(ServerFactory).toAutoFactory(GitpodServerImpl);

components/server/src/jobs/runner.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { repeat } from "@gitpod/gitpod-protocol/lib/util/repeat";
99
import { inject, injectable } from "inversify";
1010
import { RedisMutex } from "../redis/mutex";
1111
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
12-
import { ExecutionError, ResourceLockedError } from "redlock";
1312
import { jobsDurationSeconds, reportJobCompleted, reportJobStarted } from "../prometheus-metrics";
1413
import { DatabaseGarbageCollector } from "./database-gc";
1514
import { OTSGarbageCollector } from "./ots-gc";
@@ -19,6 +18,7 @@ import { WorkspaceGarbageCollector } from "./workspace-gc";
1918
import { SnapshotsJob } from "./snapshots";
2019
import { RelationshipUpdateJob } from "../authorization/relationship-updater-job";
2120
import { runWithContext } from "../util/log-context";
21+
import { WorkspaceStartController } from "../workspace/workspace-start-controller";
2222

2323
export const Job = Symbol("Job");
2424

@@ -40,6 +40,7 @@ export class JobRunner {
4040
@inject(WorkspaceGarbageCollector) private readonly workspaceGC: WorkspaceGarbageCollector,
4141
@inject(SnapshotsJob) private readonly snapshotsJob: SnapshotsJob,
4242
@inject(RelationshipUpdateJob) private readonly relationshipUpdateJob: RelationshipUpdateJob,
43+
@inject(WorkspaceStartController) private readonly workspaceStartController: WorkspaceStartController,
4344
) {}
4445

4546
public start(): DisposableCollection {
@@ -53,6 +54,7 @@ export class JobRunner {
5354
this.workspaceGC,
5455
this.snapshotsJob,
5556
this.relationshipUpdateJob,
57+
this.workspaceStartController,
5658
];
5759

5860
for (const job of jobs) {
@@ -106,7 +108,7 @@ export class JobRunner {
106108
});
107109
});
108110
} catch (err) {
109-
if (err instanceof ResourceLockedError || err instanceof ExecutionError) {
111+
if (RedisMutex.isLockError(err)) {
110112
log.debug(
111113
`Failed to acquire lock for job ${job.name}. Likely another instance already holds the lock.`,
112114
err,

components/server/src/redis/mutex.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { inject, injectable } from "inversify";
88
import { Redis } from "ioredis";
9-
import Redlock, { RedlockAbortSignal } from "redlock";
9+
import Redlock, { RedlockAbortSignal, ResourceLockedError, ExecutionError, Settings } from "redlock";
1010

1111
@injectable()
1212
export class RedisMutex {
@@ -40,7 +40,12 @@ export class RedisMutex {
4040
resources: string[],
4141
duration: number,
4242
routine: (signal: RedlockAbortSignal) => Promise<T>,
43+
settings: Partial<Settings> = {},
4344
): Promise<T> {
44-
return this.client().using(resources, duration, routine);
45+
return this.client().using(resources, duration, settings, routine);
46+
}
47+
48+
public static isLockError(err: any): boolean {
49+
return err instanceof ResourceLockedError || err instanceof ExecutionError;
4550
}
4651
}

components/server/src/workspace/workspace-service.spec.db.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe("WorkspaceService", async () => {
122122
const workspace = await createTestWorkspace(workspaceService, org, owner, project);
123123

124124
const result = await workspaceService.startWorkspace({}, owner, workspace.id);
125-
expect(result.workspaceURL).to.equal(`https://${workspace.id}.ws.gitpod.io`);
125+
expect(result.instanceID).to.not.be.undefined;
126126
});
127127

128128
it("owner can start own workspace - shared", async () => {
@@ -131,7 +131,7 @@ describe("WorkspaceService", async () => {
131131
await workspaceService.controlAdmission(owner.id, workspace.id, "everyone");
132132

133133
const result = await workspaceService.startWorkspace({}, owner, workspace.id);
134-
expect(result.workspaceURL).to.equal(`https://${workspace.id}.ws.gitpod.io`);
134+
expect(result.instanceID).to.not.be.undefined;
135135
});
136136

137137
it("stanger cannot start owner workspace", async () => {
@@ -164,7 +164,7 @@ describe("WorkspaceService", async () => {
164164
const workspaceService = container.get(WorkspaceService);
165165
const workspace = await createTestWorkspace(workspaceService, org, member, project);
166166
const result = await workspaceService.startWorkspace({}, member, workspace.id);
167-
expect(result.workspaceURL).to.equal(`https://${workspace.id}.ws.gitpod.io`);
167+
expect(result.instanceID).to.not.be.undefined;
168168
});
169169

170170
it("stanger cannot start org member workspace", async () => {

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

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/expe
5353
import { WorkspaceRegion, isWorkspaceRegion } from "@gitpod/gitpod-protocol/lib/workspace-cluster";
5454
import { RegionService } from "./region-service";
5555
import { ProjectsService } from "../projects/projects-service";
56-
import { EnvVarService } from "../user/env-var-service";
5756
import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-provider";
5857
import { SupportedWorkspaceClass } from "@gitpod/gitpod-protocol/lib/workspace-class";
5958
import { Config } from "../config";
@@ -80,7 +79,6 @@ export class WorkspaceService {
8079
@inject(WorkspaceManagerClientProvider) private readonly clientProvider: WorkspaceManagerClientProvider,
8180
@inject(WorkspaceDB) private readonly db: WorkspaceDB,
8281
@inject(EntitlementService) private readonly entitlementService: EntitlementService,
83-
@inject(EnvVarService) private readonly envVarService: EnvVarService,
8482
@inject(ProjectsService) private readonly projectsService: ProjectsService,
8583
@inject(OrganizationService) private readonly orgService: OrganizationService,
8684
@inject(RedisPublisher) private readonly publisher: RedisPublisher,
@@ -475,12 +473,6 @@ export class WorkspaceService {
475473
throw new ApplicationError(ErrorCodes.NOT_FOUND, "Workspace not found!");
476474
}
477475

478-
const envVarsPromise = this.envVarService.resolveEnvVariables(
479-
user.id,
480-
workspace.projectId,
481-
workspace.type,
482-
workspace.context,
483-
);
484476
const projectPromise = workspace.projectId
485477
? ApplicationError.notFoundToUndefined(this.projectsService.getProject(user.id, workspace.projectId))
486478
: Promise.resolve(undefined);
@@ -495,14 +487,7 @@ export class WorkspaceService {
495487
);
496488

497489
// at this point we're about to actually start a new workspace
498-
const result = await this.workspaceStarter.startWorkspace(
499-
ctx,
500-
workspace,
501-
user,
502-
await projectPromise,
503-
await envVarsPromise,
504-
options,
505-
);
490+
const result = await this.workspaceStarter.startWorkspace(ctx, workspace, user, await projectPromise, options);
506491
return result;
507492
}
508493

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Copyright (c) 2023 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 { inject, injectable } from "inversify";
8+
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
9+
import { Job } from "../jobs/runner";
10+
import { DBWithTracing, TracedWorkspaceDB, UserDB, WorkspaceDB } from "@gitpod/gitpod-db/lib";
11+
import { durationLongerThanSeconds } from "@gitpod/gitpod-protocol/lib/util/timeutil";
12+
import { WorkspaceStarter } from "./workspace-starter";
13+
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
14+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
15+
16+
@injectable()
17+
export class WorkspaceStartController implements Job {
18+
public readonly name: string = "workspace-start-controller";
19+
public readonly frequencyMs: number = 1000 * 10; // 10s
20+
21+
constructor(
22+
@inject(TracedWorkspaceDB) private readonly workspaceDB: DBWithTracing<WorkspaceDB>,
23+
@inject(UserDB) private readonly userDB: UserDB,
24+
@inject(WorkspaceStarter) private readonly workspaceStarter: WorkspaceStarter,
25+
) {}
26+
27+
public async run(): Promise<void> {
28+
const span = TraceContext.startSpan("controlStartingWorkspaces");
29+
const ctx = { span };
30+
31+
try {
32+
const instances = await this.workspaceDB.trace(ctx).findInstancesByPhase(WorkspaceStarter.STARTING_PHASES);
33+
for (const instance of instances) {
34+
try {
35+
const phase = instance.status.phase;
36+
if (
37+
phase === "preparing" ||
38+
phase === "building" ||
39+
// !!! Note: during pending, the handover between app-cluster and ws-manager happens. !!!
40+
// This means:
41+
// - there is a control loop on ws-manager-bridge that checks for an upper limit a instance may be in pending phase
42+
// - it will take some time after calling ws-manager to see the first status update
43+
// In 99.9% this is due to timing, so we need to be a bit cautious here not to spam ourselves.
44+
(phase === "pending" && durationLongerThanSeconds(Date.parse(instance.creationTime), 30))
45+
) {
46+
// this.reconcileWorkspaceStart(ctx, instance.id);
47+
const workspace = await this.workspaceDB.trace(ctx).findById(instance.workspaceId);
48+
if (!workspace) {
49+
throw new Error("cannot find workspace for instance");
50+
}
51+
const user = await this.userDB.findUserById(workspace.ownerId);
52+
if (!user) {
53+
throw new Error("cannot find owner for workspace");
54+
}
55+
56+
const isEnabled = await getExperimentsClientForBackend().getValueAsync(
57+
"workspace_start_controller",
58+
false,
59+
{
60+
user,
61+
projectId: workspace.projectId,
62+
},
63+
);
64+
if (!isEnabled) {
65+
continue;
66+
}
67+
68+
await this.workspaceStarter.reconcileWorkspaceStart(ctx, instance.id, user, workspace);
69+
}
70+
} catch (err) {
71+
log.warn({ instanceId: instance.id }, "error while reconciling workspace start", err);
72+
}
73+
}
74+
} catch (err) {
75+
TraceContext.setError(ctx, err);
76+
} finally {
77+
span.finish();
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)