Skip to content

Commit d2308d7

Browse files
geropleasyCZ
authored andcommitted
[server] Delete Workspaces & Workspace Instances after configured period
1 parent df234b9 commit d2308d7

File tree

21 files changed

+256
-27
lines changed

21 files changed

+256
-27
lines changed

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

+18-1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { DBPrebuiltWorkspace } from "./entity/db-prebuilt-workspace";
5252
import { DBPrebuiltWorkspaceUpdatable } from "./entity/db-prebuilt-workspace-updatable";
5353
import { BUILTIN_WORKSPACE_PROBE_USER_ID } from "../user-db";
5454
import { DBPrebuildInfo } from "./entity/db-prebuild-info-entry";
55+
import { daysBefore } from "@gitpod/gitpod-protocol/lib/util/timeutil";
5556

5657
type RawTo<T> = (instance: WorkspaceInstance, ws: Workspace) => T;
5758
interface OrderBy {
@@ -592,6 +593,22 @@ export abstract class AbstractTypeORMWorkspaceDBImpl implements WorkspaceDB {
592593
return dbResults as WorkspaceAndOwner[];
593594
}
594595

596+
public async findWorkspacesForPurging(
597+
minContentDeletionTimeInDays: number,
598+
limit: number,
599+
now: Date,
600+
): Promise<WorkspaceAndOwner[]> {
601+
const minPurgeTime = daysBefore(now.toISOString(), minContentDeletionTimeInDays);
602+
const repo = await this.getWorkspaceRepo();
603+
const qb = repo
604+
.createQueryBuilder("ws")
605+
.select(["ws.id", "ws.ownerId"])
606+
.where(`ws.contentDeletedTime != ''`)
607+
.andWhere(`ws.contentDeletedTime < :minPurgeTime`, { minPurgeTime })
608+
.limit(limit);
609+
return await qb.getMany();
610+
}
611+
595612
public async findWorkspacesForContentDeletion(
596613
minSoftDeletedTimeInDays: number,
597614
limit: number,
@@ -964,8 +981,8 @@ export abstract class AbstractTypeORMWorkspaceDBImpl implements WorkspaceDB {
964981
* around to deleting them.
965982
*/
966983
public async hardDeleteWorkspace(workspaceId: string): Promise<void> {
967-
await (await this.getWorkspaceRepo()).update(workspaceId, { deleted: true });
968984
await (await this.getWorkspaceInstanceRepo()).update({ workspaceId }, { deleted: true });
985+
await (await this.getWorkspaceRepo()).update(workspaceId, { deleted: true });
969986
}
970987

971988
public async findAllWorkspaces(

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

+73
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ class WorkspaceDBSpec {
662662
]);
663663
}
664664
}
665+
665666
@test(timeout(10000))
666667
public async testFindVolumeSnapshotWorkspacesForGC() {
667668
await this.threeVolumeSnapshotsForTwoWorkspaces();
@@ -726,5 +727,77 @@ class WorkspaceDBSpec {
726727
workspaceId: workspaceId2,
727728
});
728729
}
730+
731+
@test(timeout(10000))
732+
public async findWorkspacesForPurging() {
733+
const creationTime = "2018-01-01T00:00:00.000Z";
734+
const ownerId = "1221423";
735+
const purgeDate = new Date("2019-02-01T00:00:00.000Z");
736+
const d20180202 = "2018-02-02T00:00:00.000Z";
737+
const d20180201 = "2018-02-01T00:00:00.000Z";
738+
const d20180131 = "2018-01-31T00:00:00.000Z";
739+
await Promise.all([
740+
this.db.store({
741+
id: "1",
742+
creationTime,
743+
description: "something",
744+
contextURL: "http://github.com/myorg/inactive",
745+
ownerId,
746+
context: {
747+
title: "my title",
748+
},
749+
config: {},
750+
type: "regular",
751+
contentDeletedTime: d20180131,
752+
}),
753+
this.db.store({
754+
id: "2",
755+
creationTime,
756+
description: "something",
757+
contextURL: "http://github.com/myorg/active",
758+
ownerId,
759+
context: {
760+
title: "my title",
761+
},
762+
config: {},
763+
type: "regular",
764+
contentDeletedTime: d20180201,
765+
}),
766+
this.db.store({
767+
id: "3",
768+
creationTime,
769+
description: "something",
770+
contextURL: "http://github.com/myorg/active",
771+
ownerId,
772+
context: {
773+
title: "my title",
774+
},
775+
config: {},
776+
type: "regular",
777+
contentDeletedTime: d20180202,
778+
}),
779+
this.db.store({
780+
id: "4",
781+
creationTime,
782+
description: "something",
783+
contextURL: "http://github.com/myorg/active",
784+
ownerId,
785+
context: {
786+
title: "my title",
787+
},
788+
config: {},
789+
type: "regular",
790+
contentDeletedTime: undefined,
791+
}),
792+
]);
793+
794+
const wsIds = await this.db.findWorkspacesForPurging(365, 1000, purgeDate);
795+
expect(wsIds).to.deep.equal([
796+
{
797+
id: "1",
798+
ownerId,
799+
},
800+
]);
801+
}
729802
}
730803
module.exports = new WorkspaceDBSpec();

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

+5
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ export interface WorkspaceDB {
108108
minSoftDeletedTimeInDays: number,
109109
limit: number,
110110
): Promise<WorkspaceOwnerAndSoftDeleted[]>;
111+
findWorkspacesForPurging(
112+
minContentDeletionTimeInDays: number,
113+
limit: number,
114+
now: Date,
115+
): Promise<WorkspaceAndOwner[]>;
111116
findPrebuiltWorkspacesForGC(daysUnused: number, limit: number): Promise<WorkspaceAndOwner[]>;
112117
findAllWorkspaces(
113118
offset: number,

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

+21
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,25 @@ export class TimeutilSpec {
4545
const later = oneMonthLater(from.toISOString(), day);
4646
expect(later, `expected ${later} to be equal ${expectation}`).to.be.equal(expectation.toISOString());
4747
}
48+
49+
@test
50+
testDaysBefore2() {
51+
const tests: { date: Date; daysEarlier: number; expectation: string }[] = [
52+
{
53+
date: new Date("2021-07-13T00:00:00.000Z"),
54+
daysEarlier: 365,
55+
expectation: "2020-07-13T00:00:00.000Z",
56+
},
57+
{
58+
date: new Date("2019-02-01T00:00:00.000Z"),
59+
daysEarlier: 365,
60+
expectation: "2018-02-01T00:00:00.000Z",
61+
},
62+
];
63+
64+
for (const t of tests) {
65+
const actual = daysBefore(t.date.toISOString(), t.daysEarlier);
66+
expect(actual).to.equal(t.expectation, `expected ${actual} to be equal ${t.expectation}`);
67+
}
68+
}
4869
}

components/server/src/config.ts

+19
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,30 @@ export interface WorkspaceDefaults {
4343
export interface WorkspaceGarbageCollection {
4444
disabled: boolean;
4545
startDate: number;
46+
47+
/** The number of seconds between a run and the next */
48+
intervalSeconds: number;
49+
50+
/** The maximum amount of workspaces that are marked as 'softDeleted' in one go */
4651
chunkLimit: number;
52+
53+
/** The minimal age of a workspace before it is marked as 'softDeleted' (= hidden for the user) */
4754
minAgeDays: number;
55+
56+
/** The minimal age of a prebuild (incl. workspace) before it's content is deleted (+ marked as 'softDeleted') */
4857
minAgePrebuildDays: number;
58+
59+
/** The minimal number of days a workspace has to stay in 'softDeleted' before it's content is deleted */
4960
contentRetentionPeriodDays: number;
61+
62+
/** The maximum amount of workspaces whose content is deleted in one go */
5063
contentChunkLimit: number;
64+
65+
/** The minimal number of days a workspace has to stay in 'contentDeleted' before it's purged from the DB */
66+
purgeRetentionPeriodDays: number;
67+
68+
/** The maximum amount of workspaces which are purged in one go */
69+
purgeChunkLimit: number;
5170
}
5271

5372
/**

components/server/src/prometheus-metrics.ts

+10
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,13 @@ const prebuildsStartedTotal = new prometheusClient.Counter({
166166
export function increasePrebuildsStartedCounter() {
167167
prebuildsStartedTotal.inc();
168168
}
169+
170+
const workspacesPurgedTotal = new prometheusClient.Counter({
171+
name: "gitpod_server_workspaces_purged_total",
172+
help: "Counter of workspaces hard deleted by periodic job running on server.",
173+
registers: [prometheusClient.register],
174+
});
175+
176+
export function reportWorkspacePurged() {
177+
workspacesPurgedTotal.inc();
178+
}

components/server/src/workspace/garbage-collector.ts

+36-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ export class WorkspaceGarbageCollector {
3434
dispose: () => {},
3535
};
3636
}
37-
return repeat(async () => this.garbageCollectWorkspacesIfLeader(), 30 * 60 * 1000);
37+
return repeat(
38+
async () => this.garbageCollectWorkspacesIfLeader(),
39+
this.config.workspaceGarbageCollection.intervalSeconds * 1000,
40+
);
3841
}
3942

4043
public async garbageCollectWorkspacesIfLeader() {
@@ -44,6 +47,9 @@ export class WorkspaceGarbageCollector {
4447
this.deleteWorkspaceContentAfterRetentionPeriod().catch((err) =>
4548
log.error("wsgc: error during content deletion", err),
4649
);
50+
this.purgeWorkspacesAfterPurgeRetentionPeriod().catch((err) =>
51+
log.error("wsgc: error during hard deletion of workspaces", err),
52+
);
4753
this.deleteOldPrebuilds().catch((err) => log.error("wsgc: error during prebuild deletion", err));
4854
this.deleteOutdatedVolumeSnapshots().catch((err) =>
4955
log.error("wsgc: error during volume snapshot gc deletion", err),
@@ -105,6 +111,34 @@ export class WorkspaceGarbageCollector {
105111
}
106112
}
107113

114+
/**
115+
* This method is meant to purge all traces of a Workspace and it's WorkspaceInstances from the DB
116+
*/
117+
protected async purgeWorkspacesAfterPurgeRetentionPeriod() {
118+
const span = opentracing.globalTracer().startSpan("purgeWorkspacesAfterPurgeRetentionPeriod");
119+
try {
120+
const now = new Date();
121+
const workspaces = await this.workspaceDB
122+
.trace({ span })
123+
.findWorkspacesForPurging(
124+
this.config.workspaceGarbageCollection.purgeRetentionPeriodDays,
125+
this.config.workspaceGarbageCollection.purgeChunkLimit,
126+
now,
127+
);
128+
const deletes = await Promise.all(
129+
workspaces.map((ws) => this.deletionService.hardDeleteWorkspace({ span }, ws.id)),
130+
);
131+
132+
log.info(`wsgc: successfully purged ${deletes.length} workspaces`);
133+
span.addTags({ nrOfCollectedWorkspaces: deletes.length });
134+
} catch (err) {
135+
TraceContext.setError({ span }, err);
136+
throw err;
137+
} finally {
138+
span.finish();
139+
}
140+
}
141+
108142
protected async deleteOldPrebuilds() {
109143
const span = opentracing.globalTracer().startSpan("deleteOldPrebuilds");
110144
try {
@@ -128,7 +162,7 @@ export class WorkspaceGarbageCollector {
128162
}
129163
}
130164

131-
// finds volume snapshots that have been superceded by newer volume snapshot and removes them
165+
// finds volume snapshots that have been superseded by newer volume snapshot and removes them
132166
protected async deleteOutdatedVolumeSnapshots() {
133167
const span = opentracing.globalTracer().startSpan("deleteOutdatedVolumeSnapshots");
134168
try {

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1166,7 +1166,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
11661166
try {
11671167
await this.guardAccess({ kind: "workspace", subject: workspace }, "create");
11681168
} catch (err) {
1169-
await this.workspaceDb.trace(ctx).hardDeleteWorkspace(workspace.id);
1169+
await this.workspaceDeletionService.hardDeleteWorkspace(ctx, workspace.id);
11701170
throw err;
11711171
}
11721172

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

+14
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
1919
import { WorkspaceManagerClientProvider } from "@gitpod/ws-manager/lib/client-provider";
2020
import { DeleteVolumeSnapshotRequest } from "@gitpod/ws-manager/lib";
2121
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
22+
import { reportWorkspacePurged } from "../prometheus-metrics";
2223

2324
@injectable()
2425
export class WorkspaceDeletionService {
@@ -46,6 +47,19 @@ export class WorkspaceDeletionService {
4647
});
4748
}
4849

50+
/**
51+
* This *hard deletes* the workspace entry and all corresponding workspace-instances, by triggering a db-sync mechanism that purges it from the DB.
52+
* Note: when this function returns that doesn't mean that the entries are actually gone yet, that might still take a short while until db-sync comes
53+
* around to deleting them.
54+
* @param ctx
55+
* @param workspaceId
56+
*/
57+
public async hardDeleteWorkspace(ctx: TraceContext, workspaceId: string): Promise<void> {
58+
await this.db.trace(ctx).hardDeleteWorkspace(workspaceId);
59+
log.info(`Purged Workspace ${workspaceId} and all WorkspaceInstances for this workspace`, { workspaceId });
60+
reportWorkspacePurged();
61+
}
62+
4963
/**
5064
* This method garbageCollects a workspace. It deletes its contents and sets the workspaces 'contentDeletedTime'
5165
* @param ctx

install/installer/cmd/testdata/render/aws-setup/output.golden

+5-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

install/installer/cmd/testdata/render/azure-setup/output.golden

+5-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

install/installer/cmd/testdata/render/customization/output.golden

+5-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)