Skip to content

Commit ef7cdd5

Browse files
committed
[server] Build out SnapshotService
1 parent 8fa4002 commit ef7cdd5

File tree

7 files changed

+150
-39
lines changed

7 files changed

+150
-39
lines changed

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { injectable, inject } from "inversify";
88
import { Repository, EntityManager, DeepPartial, UpdateQueryBuilder, Brackets } from "typeorm";
99
import { MaybeWorkspace, MaybeWorkspaceInstance, WorkspaceDB, FindWorkspacesOptions, PrebuiltUpdatableAndWorkspace, WorkspaceInstanceSessionWithWorkspace, PrebuildWithWorkspace, WorkspaceAndOwner, WorkspacePortsAuthData, WorkspaceOwnerAndSoftDeleted } from "../workspace-db";
10-
import { Workspace, WorkspaceInstance, WorkspaceInfo, WorkspaceInstanceUser, WhitelistedRepository, Snapshot, LayoutData, PrebuiltWorkspace, RunningWorkspaceInfo, PrebuiltWorkspaceUpdatable, WorkspaceAndInstance, WorkspaceType, PrebuildInfo, AdminGetWorkspacesQuery } from "@gitpod/gitpod-protocol";
10+
import { Workspace, WorkspaceInstance, WorkspaceInfo, WorkspaceInstanceUser, WhitelistedRepository, Snapshot, LayoutData, PrebuiltWorkspace, RunningWorkspaceInfo, PrebuiltWorkspaceUpdatable, WorkspaceAndInstance, WorkspaceType, PrebuildInfo, AdminGetWorkspacesQuery, SnapshotState } from "@gitpod/gitpod-protocol";
1111
import { TypeORM } from "./typeorm";
1212
import { DBWorkspace } from "./entity/db-workspace";
1313
import { DBWorkspaceInstance } from "./entity/db-workspace-instance";
@@ -549,6 +549,16 @@ export abstract class AbstractTypeORMWorkspaceDBImpl implements WorkspaceDB {
549549
const snapshots = await this.getSnapshotRepo();
550550
return snapshots.findOne(snapshotId);
551551
}
552+
public async findSnapshotsWithState(state: SnapshotState, offset: number, limit: number): Promise<{ snapshots: Snapshot[], total: number }> {
553+
const snapshotRepo = await this.getSnapshotRepo();
554+
const [snapshots, total] = await snapshotRepo.createQueryBuilder("snapshot")
555+
.where("snapshot.state = :state", { state })
556+
.orderBy("creationTime", "ASC")
557+
.offset(offset)
558+
.take(limit)
559+
.getManyAndCount();
560+
return { snapshots, total };
561+
}
552562

553563
public async storeSnapshot(snapshot: Snapshot): Promise<Snapshot> {
554564
const snapshots = await this.getSnapshotRepo();

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

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

77
import { DeepPartial } from 'typeorm';
88

9-
import { Workspace, WorkspaceInfo, WorkspaceInstance, WorkspaceInstanceUser, WhitelistedRepository, Snapshot, LayoutData, PrebuiltWorkspace, PrebuiltWorkspaceUpdatable, RunningWorkspaceInfo, WorkspaceAndInstance, WorkspaceType, PrebuildInfo, AdminGetWorkspacesQuery } from '@gitpod/gitpod-protocol';
9+
import { Workspace, WorkspaceInfo, WorkspaceInstance, WorkspaceInstanceUser, WhitelistedRepository, Snapshot, LayoutData, PrebuiltWorkspace, PrebuiltWorkspaceUpdatable, RunningWorkspaceInfo, WorkspaceAndInstance, WorkspaceType, PrebuildInfo, AdminGetWorkspacesQuery, SnapshotState } from '@gitpod/gitpod-protocol';
1010

1111
export type MaybeWorkspace = Workspace | undefined;
1212
export type MaybeWorkspaceInstance = WorkspaceInstance | undefined;
@@ -93,6 +93,7 @@ export interface WorkspaceDB {
9393
getFeaturedRepositories(): Promise<Partial<WhitelistedRepository>[]>;
9494

9595
findSnapshotById(snapshotId: string): Promise<Snapshot | undefined>;
96+
findSnapshotsWithState(state: SnapshotState, offset: number, limit: number): Promise<{ snapshots: Snapshot[], total: number }>;
9697
findSnapshotsByWorkspaceId(workspaceId: string): Promise<Snapshot[]>;
9798
storeSnapshot(snapshot: Snapshot): Promise<Snapshot>;
9899
deleteSnapshot(snapshotId: string): Promise<void>;

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ export namespace GitpodServer {
340340
export interface TakeSnapshotOptions {
341341
workspaceId: string;
342342
layoutData?: string;
343+
/* this is here to enable backwards-compatibility and untangling rollout between workspace, IDE and meta */
344+
dontWait?: boolean;
343345
}
344346
export interface GetUserStorageResourceOptions {
345347
readonly uri: string;

components/server/ee/src/server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,21 @@ import { log } from '@gitpod/gitpod-protocol/lib/util/logging';
1313
import { GitLabApp } from './prebuilds/gitlab-app';
1414
import { BitbucketApp } from './prebuilds/bitbucket-app';
1515
import { GithubApp } from './prebuilds/github-app';
16+
import { SnapshotService } from './workspace/snapshot-service';
1617

1718
export class ServerEE<C extends GitpodClient, S extends GitpodServer> extends Server<C, S> {
1819
@inject(GraphQLController) protected readonly adminGraphQLController: GraphQLController;
1920
@inject(GithubApp) protected readonly githubApp: GithubApp;
2021
@inject(GitLabApp) protected readonly gitLabApp: GitLabApp;
2122
@inject(BitbucketApp) protected readonly bitbucketApp: BitbucketApp;
23+
@inject(SnapshotService) protected readonly snapshotService: SnapshotService;
24+
25+
public async init(app: express.Application) {
26+
await super.init(app);
27+
28+
// Start Snapshot Service
29+
await this.snapshotService.start();
30+
}
2231

2332
protected async registerRoutes(app: express.Application): Promise<void> {
2433
await super.registerRoutes(app);
@@ -37,6 +46,5 @@ export class ServerEE<C extends GitpodClient, S extends GitpodServer> extends Se
3746

3847
log.info("Registered Bitbucket app at " + BitbucketApp.path);
3948
app.use(BitbucketApp.path, this.bitbucketApp.router);
40-
4149
}
4250
}

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ import { Chargebee as chargebee } from '@gitpod/gitpod-payment-endpoint/lib/char
4141
import { GitHubAppSupport } from "../github/github-app-support";
4242
import { GitLabAppSupport } from "../gitlab/gitlab-app-support";
4343
import { Config } from "../../../src/config";
44-
import { SnapshotService } from "./snapshot-service";
44+
import { SnapshotService, WaitForSnapshotOptions } from "./snapshot-service";
45+
import { SafePromise } from "@gitpod/gitpod-protocol/lib/util/safe-promise";
4546

4647
@injectable()
4748
export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodServer> {
@@ -366,7 +367,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodSer
366367
this.requireEELicense(Feature.FeatureSnapshot);
367368

368369
const user = this.checkAndBlockUser("takeSnapshot");
369-
const { workspaceId, layoutData } = options;
370+
const { workspaceId, dontWait } = options;
370371

371372
const span = opentracing.globalTracer().startSpan("takeSnapshot");
372373
span.setTag("workspaceId", workspaceId);
@@ -389,17 +390,19 @@ export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodSer
389390
// this triggers the snapshots, but returns early! cmp. waitForSnapshot to wait for it's completion
390391
const resp = await client.takeSnapshot({ span }, request);
391392

392-
const id = uuidv4()
393-
await this.workspaceDb.trace({ span }).storeSnapshot({
394-
id,
395-
creationTime: new Date().toISOString(),
396-
state: 'available',
397-
bucketId: resp.getUrl(),
398-
originalWorkspaceId: workspaceId,
399-
layoutData,
400-
});
393+
const snapshot = await this.snapshotService.createSnapshot(options, resp.getUrl());
394+
395+
// to be backwards compatible during rollout, we require new clients to explicitly pass "dontWait: true"
396+
const waitOpts = { workspaceOwner: workspace.ownerId, snapshot };
397+
if (!dontWait) {
398+
// this mimicks the old behavior: wait until the snapshot is through
399+
await this.internalDoWaitForWorkspace(waitOpts);
400+
} else {
401+
// start driving the snapshot immediately
402+
SafePromise.catchAndLog(this.internalDoWaitForWorkspace(waitOpts), { userId: user.id, workspaceId: workspaceId})
403+
}
401404

402-
return id;
405+
return snapshot.id;
403406
} catch (err) {
404407
TraceContext.logError({ span }, err);
405408
throw err;
@@ -437,13 +440,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodSer
437440
throw new ResponseError(ErrorCodes.NOT_FOUND, `No snapshot with id '${snapshotId}' found.`)
438441
}
439442
const snapshotWorkspace = await this.guardSnaphotAccess(span, user.id, snapshot.originalWorkspaceId);
440-
441-
try {
442-
await this.snapshotService.driveSnapshot(snapshotWorkspace.ownerId, snapshot);
443-
} catch (err) {
444-
// wrap in SNAPSHOT_ERROR to signal this call should not be retried.
445-
throw new ResponseError(ErrorCodes.SNAPSHOT_ERROR, err.toString());
446-
}
443+
await this.internalDoWaitForWorkspace({ workspaceOwner: snapshotWorkspace.ownerId, snapshot });
447444
} catch (err) {
448445
TraceContext.logError({ span }, err);
449446
throw err;
@@ -452,6 +449,15 @@ export class GitpodServerEEImpl extends GitpodServerImpl<GitpodClient, GitpodSer
452449
}
453450
}
454451

452+
protected async internalDoWaitForWorkspace(opts: WaitForSnapshotOptions) {
453+
try {
454+
await this.snapshotService.waitForSnapshot(opts);
455+
} catch (err) {
456+
// wrap in SNAPSHOT_ERROR to signal this call should not be retried.
457+
throw new ResponseError(ErrorCodes.SNAPSHOT_ERROR, err.toString());
458+
}
459+
}
460+
455461
async getSnapshots(workspaceId: string): Promise<string[]> {
456462
// Allowed in the free version, because it is read only.
457463
// this.requireEELicense(Feature.FeatureSnapshot);

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

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,46 +4,129 @@
44
* See License-AGPL.txt in the project root for license information.
55
*/
66

7-
import { WorkspaceDB } from "@gitpod/gitpod-db/lib";
8-
import { Snapshot } from "@gitpod/gitpod-protocol";
97
import { inject, injectable } from "inversify";
8+
import { v4 as uuidv4 } from 'uuid';
9+
import { WorkspaceDB } from "@gitpod/gitpod-db/lib";
10+
import { Disposable, GitpodServer, Snapshot } from "@gitpod/gitpod-protocol";
11+
import { SafePromise } from "@gitpod/gitpod-protocol/lib/util/safe-promise";
1012
import { StorageClient } from "../../../src/storage/storage-client";
13+
import { ConsensusLeaderQorum } from "../../../src/consensus/consensus-leader-quorum";
14+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
1115

1216
const SNAPSHOT_TIMEOUT_SECONDS = 60 * 30;
17+
const SNAPSHOT_POLL_INTERVAL_SECONDS = 5;
18+
const SNAPSHOT_DB_POLL_INTERVAL_SECONDS = 60 * 5;
19+
20+
export interface WaitForSnapshotOptions {
21+
workspaceOwner: string;
22+
snapshot: Snapshot;
23+
}
1324

25+
/**
26+
* SnapshotService hosts all code that's necessary to create snapshots and drive them to completion.
27+
* To guarantee every snapshot reacheds an end state ('error' or 'available') it regularly polls the DB to pick up and drive those as well.
28+
*/
1429
@injectable()
1530
export class SnapshotService {
1631
@inject(WorkspaceDB) protected readonly workspaceDb: WorkspaceDB;
1732
@inject(StorageClient) protected readonly storageClient: StorageClient;
33+
@inject(ConsensusLeaderQorum) protected readonly leaderQuorum: ConsensusLeaderQorum;
1834

19-
public async driveSnapshot(snapshotWorkspaceOwner: string, _snapshot: Snapshot): Promise<void> {
20-
const { id: snapshotId, bucketId, originalWorkspaceId, creationTime } = _snapshot;
21-
const start = new Date(creationTime).getTime();
22-
while (start + SNAPSHOT_TIMEOUT_SECONDS < Date.now()) {
23-
await new Promise((resolve) => setTimeout(resolve, 3000));
35+
protected readonly runningSnapshots: Map<string, Promise<void>> = new Map();
2436

25-
// pending: check if we're done:
26-
const exists = await this.storageClient.workspaceSnapshotExists(snapshotWorkspaceOwner, originalWorkspaceId, bucketId);
27-
if (exists) {
28-
await this.workspaceDb.updateSnapshot({
29-
id: snapshotId,
30-
state: 'available',
31-
availableTime: new Date().toISOString(),
32-
});
33-
return
37+
public async start(): Promise<Disposable> {
38+
const timer = setInterval(() => this.pickupAndDriveFromDbIfWeAreLeader().catch(log.error), SNAPSHOT_DB_POLL_INTERVAL_SECONDS * 1000);
39+
return {
40+
dispose: () => clearInterval(timer)
41+
}
42+
}
43+
44+
public async pickupAndDriveFromDbIfWeAreLeader() {
45+
if (!await this.leaderQuorum.areWeLeader()) {
46+
return
47+
}
48+
49+
log.info("snapshots: we're leading the quorum. picking up pending snapshots and driving them home.");
50+
const step = 50; // make sure we're not flooding ourselves
51+
const { snapshots: pendingSnapshots, total } = await this.workspaceDb.findSnapshotsWithState('pending', 0, step);
52+
if (total > step) {
53+
log.warn("snapshots: looks like we have more pending snapshots then we can handle!");
54+
}
55+
56+
for (const snapshot of pendingSnapshots) {
57+
const workspace = await this.workspaceDb.findById(snapshot.originalWorkspaceId);
58+
if (!workspace) {
59+
log.error({ workspaceId: snapshot.originalWorkspaceId }, `snapshots: unable to find workspace for snapshot`, { snapshotId: snapshot.id });
60+
continue;
3461
}
3562

63+
SafePromise.catchAndLog(this.driveSnapshotCached({ workspaceOwner: workspace.ownerId, snapshot }), { workspaceId: snapshot.originalWorkspaceId });
64+
}
65+
}
66+
67+
public async createSnapshot(options: GitpodServer.TakeSnapshotOptions, snapshotUrl: string): Promise<Snapshot> {
68+
const id = uuidv4()
69+
return await this.workspaceDb.storeSnapshot({
70+
id,
71+
creationTime: new Date().toISOString(),
72+
state: 'pending',
73+
bucketId: snapshotUrl,
74+
originalWorkspaceId: options.workspaceId,
75+
layoutData: options.layoutData,
76+
});
77+
}
78+
79+
public async waitForSnapshot(opts: WaitForSnapshotOptions): Promise<void> {
80+
return await this.driveSnapshotCached(opts);
81+
}
82+
83+
protected async driveSnapshotCached(opts: WaitForSnapshotOptions): Promise<void> {
84+
const running = this.runningSnapshots.get(opts.snapshot.id);
85+
if (running) {
86+
return running;
87+
}
88+
89+
const started = this.driveSnapshot(opts);
90+
this.runningSnapshots.set(opts.snapshot.id, started);
91+
started.finally(() => this.runningSnapshots.delete(opts.snapshot.id));
92+
return started;
93+
}
94+
95+
protected async driveSnapshot(opts: WaitForSnapshotOptions): Promise<void> {
96+
if (opts.snapshot.state === 'available') {
97+
return;
98+
}
99+
if (opts.snapshot.state === 'error') {
100+
throw new Error(`snapshot error: ${opts.snapshot.message}`);
101+
}
102+
103+
const { id: snapshotId, bucketId, originalWorkspaceId, creationTime } = opts.snapshot;
104+
const start = new Date(creationTime).getTime();
105+
while (start + (SNAPSHOT_TIMEOUT_SECONDS * 1000) > Date.now()) {
106+
await new Promise((resolve) => setTimeout(resolve, SNAPSHOT_POLL_INTERVAL_SECONDS * 1000));
107+
108+
// did somebody else complete that snapshot?
36109
const snapshot = await this.workspaceDb.findSnapshotById(snapshotId);
37110
if (!snapshot) {
38111
throw new Error(`no snapshot with id '${snapshotId}' found.`)
39112
}
40-
41113
if (snapshot.state === 'available') {
42114
return;
43115
}
44116
if (snapshot.state === 'error') {
45117
throw new Error(`snapshot error: ${snapshot.message}`);
46118
}
119+
120+
// pending: check if the snapshot is there
121+
const exists = await this.storageClient.workspaceSnapshotExists(opts.workspaceOwner, originalWorkspaceId, bucketId);
122+
if (exists) {
123+
await this.workspaceDb.updateSnapshot({
124+
id: snapshotId,
125+
state: 'available',
126+
availableTime: new Date().toISOString(),
127+
});
128+
return;
129+
}
47130
}
48131

49132
// took too long

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ export class WorkspaceStarter {
778778
"function:getLayout",
779779
"function:generateNewGitpodToken",
780780
"function:takeSnapshot",
781+
"function:waitForSnapshot",
781782
"function:storeLayout",
782783
"function:stopWorkspace",
783784
"function:getToken",

0 commit comments

Comments
 (0)