Skip to content

[ws-gc] WS soft deletion improvements #20271

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

Merged
merged 14 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
59 changes: 37 additions & 22 deletions components/gitpod-db/src/typeorm/workspace-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ import {
PrebuildWithWorkspace,
PrebuildWithWorkspaceAndInstances,
PrebuiltUpdatableAndWorkspace,
WorkspaceAndOwner,
WorkspaceDB,
WorkspaceOwnerAndContentDeletedTime,
WorkspaceOwnerAndDeletionEligibility,
WorkspaceOwnerAndSoftDeleted,
WorkspacePortsAuthData,
} from "../workspace-db";
Expand Down Expand Up @@ -486,42 +487,56 @@ export class TypeORMWorkspaceDBImpl extends TransactionalDBImpl<WorkspaceDB> imp
cutOffDate: Date = new Date(),
limit: number = 100,
type: WorkspaceType = "regular",
): Promise<WorkspaceAndOwner[]> {
// we do not allow to run this with a future date
): Promise<WorkspaceOwnerAndDeletionEligibility[]> {
if (cutOffDate > new Date()) {
throw new Error("cutOffDate must not be in the future, was: " + cutOffDate.toISOString());
}
const workspaceRepo = await this.getWorkspaceRepo();
const dbResults = await workspaceRepo.query(
Copy link
Member Author

Choose a reason for hiding this comment

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

I wanted to align this with findWorkspacesForPurging by converting from a raw SQL statement, but maybe it's too out of scope here. I'm curious if anyone has thoughts about it

Copy link
Member

Choose a reason for hiding this comment

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

Looking at the "async predicate" which looks way too much like a join in disguise, let' snot do this right now. 👍 🙃

Copy link
Member Author

Choose a reason for hiding this comment

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

WDYT about the readability of this with 13e1a76 (#20271)? I tried putting everything into a single query and think that it makes a bit more sense.

The benefit of using this join is not too big though so I'm happy to revert if you think it's not that helpful.

`
SELECT ws.id AS id,
ws.ownerId AS ownerId
FROM d_b_workspace AS ws
WHERE ws.deleted = 0
AND ws.type = ?
AND ws.softDeleted IS NULL
AND ws.softDeletedTime = ''
AND ws.pinned = 0
AND ws.deletionEligibilityTime != ''
AND ws.deletionEligibilityTime < ?
LIMIT ?;
`,
[type, cutOffDate.toISOString(), limit],
);
const qb = workspaceRepo
.createQueryBuilder("ws")
.leftJoinAndMapOne(
"ws.latestInstance",
DBWorkspaceInstance,
"wsi",
`wsi.id = (
SELECT i.id
FROM d_b_workspace_instance AS i
WHERE i.workspaceId = ws.id
ORDER BY i.creationTime DESC
LIMIT 1
)`,
)
.select(["ws.id", "ws.ownerId", "ws.deletionEligibilityTime", "wsi.id", "wsi.status"])
.where("ws.deleted = :deleted", { deleted: 0 })
.andWhere("ws.type = :type", { type })
.andWhere("ws.softDeleted IS NULL")
.andWhere("ws.softDeletedTime = ''")
.andWhere("ws.pinned = :pinned", { pinned: 0 })
.andWhere("ws.deletionEligibilityTime != ''")
.andWhere("ws.deletionEligibilityTime < :cutOffDate", { cutOffDate: cutOffDate.toISOString() })
// we don't want to delete workspaces that are active
.andWhere(
new Brackets((qb) => {
qb.where("wsi.id IS NULL").orWhere("JSON_UNQUOTE(wsi.status->>'$.phase') = :stoppedStatus", {
stoppedStatus: "stopped",
});
}),
)
.limit(limit);

return dbResults as WorkspaceAndOwner[];
return qb.getMany();
}

public async findWorkspacesForPurging(
minContentDeletionTimeInDays: number,
limit: number,
now: Date,
): Promise<WorkspaceAndOwner[]> {
): Promise<WorkspaceOwnerAndContentDeletedTime[]> {
const minPurgeTime = daysBefore(now.toISOString(), minContentDeletionTimeInDays);
const repo = await this.getWorkspaceRepo();
const qb = repo
.createQueryBuilder("ws")
.select(["ws.id", "ws.ownerId"])
.select(["ws.id", "ws.ownerId", "ws.contentDeletedTime"])
.where(`ws.contentDeletedTime != ''`)
.andWhere(`ws.contentDeletedTime < :minPurgeTime`, { minPurgeTime })
.andWhere(`ws.deleted = 0`)
Expand Down
56 changes: 50 additions & 6 deletions components/gitpod-db/src/workspace-db.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class WorkspaceDBSpec {
readonly timeWs = new Date(2018, 2, 16, 10, 0, 0).toISOString();
readonly timeBefore = new Date(2018, 2, 16, 11, 5, 10).toISOString();
readonly timeAfter = new Date(2019, 2, 16, 11, 5, 10).toISOString();
readonly timeAfter2 = new Date(2019, 2, 17, 4, 20, 10).toISOString();
readonly userId = "12345";
readonly projectAID = "projectA";
readonly projectBID = "projectB";
Expand Down Expand Up @@ -101,6 +102,30 @@ class WorkspaceDBSpec {
deleted: false,
usageAttributionId: undefined,
};
readonly wsi3: WorkspaceInstance = {
workspaceId: this.ws.id,
id: "12345",
ideUrl: "example.org",
region: "unknown",
workspaceClass: undefined,
workspaceImage: "abc.io/test/image:123",
creationTime: this.timeAfter2,
startedTime: undefined,
deployedTime: undefined,
stoppingTime: undefined,
stoppedTime: undefined,
status: {
version: 1,
phase: "stopped",
conditions: {},
},
configuration: {
theiaVersion: "unknown",
ideImage: "unknown",
},
deleted: false,
usageAttributionId: undefined,
};
readonly ws2: Workspace = {
id: "2",
type: "regular",
Expand Down Expand Up @@ -235,7 +260,7 @@ class WorkspaceDBSpec {
}

@test(timeout(10000))
public async testFindEligableWorkspacesForSoftDeletion_markedEligable_Prebuild() {
public async testFindEligibleWorkspacesForSoftDeletion_markedEligible_Prebuild() {
const { ws } = await this.createPrebuild(20, 15);
const dbResult = await this.db.findEligibleWorkspacesForSoftDeletion(new Date(), 10, "prebuild");
expect(dbResult.length).to.equal(1);
Expand All @@ -244,7 +269,7 @@ class WorkspaceDBSpec {
}

@test(timeout(10000))
public async testFindEligableWorkspacesForSoftDeletion_notMarkedEligable_Prebuild() {
public async testFindEligibleWorkspacesForSoftDeletion_notMarkedEligible_Prebuild() {
await this.createPrebuild(20, -7);
const dbResult = await this.db.findEligibleWorkspacesForSoftDeletion(new Date(), 10, "prebuild");
expect(dbResult.length).to.eq(0);
Expand All @@ -254,7 +279,7 @@ class WorkspaceDBSpec {
public async testPrebuildGarbageCollection() {
const { pbws } = await this.createPrebuild(20, 15);

// mimick the behavior of the Garbage Collector
// mimic the behavior of the Garbage Collector
const gcWorkspaces = await this.db.findEligibleWorkspacesForSoftDeletion(new Date(), 10, "prebuild");
expect(gcWorkspaces.length).to.equal(1);

Expand Down Expand Up @@ -311,16 +336,34 @@ class WorkspaceDBSpec {
}

@test(timeout(10000))
public async testFindEligableWorkspacesForSoftDeletion_markedEligable() {
public async testFindEligibleWorkspacesForSoftDeletion_markedEligible() {
this.ws.deletionEligibilityTime = this.timeWs;
await Promise.all([this.db.store(this.ws), this.db.storeInstance(this.wsi1), this.db.storeInstance(this.wsi2)]);
await Promise.all([
this.db.store(this.ws),
this.db.storeInstance(this.wsi1),
this.db.storeInstance(this.wsi2),
this.db.storeInstance(this.wsi3),
Copy link
Member

Choose a reason for hiding this comment

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

🧡

]);
const dbResult = await this.db.findEligibleWorkspacesForSoftDeletion(new Date(this.timeAfter), 10);
expect(dbResult[0].id).to.eq(this.ws.id);
expect(dbResult[0].ownerId).to.eq(this.ws.ownerId);
}

@test(timeout(10000))
public async testFindEligableWorkspacesForSoftDeletion_notMarkedEligable() {
public async testFindEligibleWorkspacesForSoftDeletion_notMarkedEligible() {
await Promise.all([
this.db.store(this.ws),
this.db.storeInstance(this.wsi1),
this.db.storeInstance(this.wsi2),
this.db.storeInstance(this.wsi3),
]);
const dbResult = await this.db.findEligibleWorkspacesForSoftDeletion(new Date(this.timeAfter), 10);
expect(dbResult.length).to.eq(0);
}

@test(timeout(10000))
public async testFindEligibleWorkspacesForSoftDeletion_notMarkedEligibleRunningInstance() {
this.ws.deletionEligibilityTime = this.timeWs;
await Promise.all([this.db.store(this.ws), this.db.storeInstance(this.wsi1), this.db.storeInstance(this.wsi2)]);
const dbResult = await this.db.findEligibleWorkspacesForSoftDeletion(new Date(this.timeAfter), 10);
expect(dbResult.length).to.eq(0);
Expand Down Expand Up @@ -768,6 +811,7 @@ class WorkspaceDBSpec {
{
id: "1",
ownerId,
contentDeletedTime: d20180131,
},
]);
}
Expand Down
6 changes: 4 additions & 2 deletions components/gitpod-db/src/workspace-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface PrebuildWithWorkspaceAndInstances {

export type WorkspaceAndOwner = Pick<Workspace, "id" | "ownerId">;
export type WorkspaceOwnerAndSoftDeleted = Pick<Workspace, "id" | "ownerId" | "softDeleted">;
export type WorkspaceOwnerAndDeletionEligibility = Pick<Workspace, "id" | "ownerId" | "deletionEligibilityTime">;
export type WorkspaceOwnerAndContentDeletedTime = Pick<Workspace, "id" | "ownerId" | "contentDeletedTime">;

export const WorkspaceDB = Symbol("WorkspaceDB");
export interface WorkspaceDB {
Expand Down Expand Up @@ -102,7 +104,7 @@ export interface WorkspaceDB {
cutOffDate?: Date,
limit?: number,
type?: WorkspaceType,
): Promise<WorkspaceAndOwner[]>;
): Promise<WorkspaceOwnerAndDeletionEligibility[]>;
findWorkspacesForContentDeletion(
minSoftDeletedTimeInDays: number,
limit: number,
Expand All @@ -111,7 +113,7 @@ export interface WorkspaceDB {
minContentDeletionTimeInDays: number,
limit: number,
now: Date,
): Promise<WorkspaceAndOwner[]>;
): Promise<WorkspaceOwnerAndContentDeletedTime[]>;
findAllWorkspaces(
offset: number,
limit: number,
Expand Down
4 changes: 2 additions & 2 deletions components/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ export interface WorkspaceGarbageCollection {
/** The minimal age of a workspace before it is marked as 'softDeleted' (= hidden for the user) */
minAgeDays: number;

/** The minimal age of a prebuild (incl. workspace) before it's content is deleted (+ marked as 'softDeleted') */
/** The minimal age of a prebuild (incl. workspace) before its content is deleted (+ marked as 'softDeleted') */
minAgePrebuildDays: number;

/** The minimal number of days a workspace has to stay in 'softDeleted' before it's content is deleted */
/** The minimal number of days a workspace has to stay in 'softDeleted' before its content is deleted */
contentRetentionPeriodDays: number;

/** The maximum amount of workspaces whose content is deleted in one go */
Expand Down
14 changes: 13 additions & 1 deletion components/server/src/jobs/workspace-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { StorageClient } from "../storage/storage-client";
* - find _any_ workspace "softDeleted" for long enough -> move to "contentDeleted"
* - find _any_ workspace "contentDeleted" for long enough -> move to "purged"
* - prebuilds are special in that:
* - the GC has a dedicated sub-task to move workspace of type "prebuid" from "to delete" (with a different threshold) -> to "contentDeleted" directly
* - the GC has a dedicated sub-task to move workspace of type "prebuild" from "to delete" (with a different threshold) -> to "contentDeleted" directly
* - the "purging" takes care of all Prebuild-related sub-resources, too
*/
@injectable()
Expand All @@ -55,6 +55,11 @@ export class WorkspaceGarbageCollector implements Job {
return;
}

log.info("workspace-gc: job started", {
workspaceMinAgeDays: this.config.workspaceGarbageCollection.minAgeDays,
prebuildMinAgeDays: this.config.workspaceGarbageCollection.minAgePrebuildDays,
});

// Move eligible "regular" workspace -> softDeleted
try {
await this.softDeleteEligibleWorkspaces();
Expand Down Expand Up @@ -115,6 +120,10 @@ export class WorkspaceGarbageCollector implements Job {
err,
);
}

log.info({ workspaceId: ws.id }, `workspace-gc: soft deleted a workspace`, {
deletionEligibilityTime: ws.deletionEligibilityTime,
});
}
const afterDelete = new Date();

Expand Down Expand Up @@ -187,6 +196,9 @@ export class WorkspaceGarbageCollector implements Job {
} catch (err) {
log.error({ workspaceId: ws.id }, "workspace-gc: failed to purge workspace", err);
}
log.info({ workspaceId: ws.id }, `workspace-gc: hard deleted a workspace`, {
contentDeletedTime: ws.contentDeletedTime,
});
}
const afterDelete = new Date();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ describe("WorkspaceService", async () => {
workspaceImage: "",
});

await svc.updateDeletionEligabilityTime(owner.id, ws.id);
await svc.updateDeletionEligibilityTime(owner.id, ws.id);

const workspace = await svc.getWorkspace(owner.id, ws.id);
expect(workspace).to.not.be.undefined;
Expand Down Expand Up @@ -735,7 +735,7 @@ describe("WorkspaceService", async () => {
workspaceImage: "",
});

await svc.updateDeletionEligabilityTime(owner.id, ws.id);
await svc.updateDeletionEligibilityTime(owner.id, ws.id);

const workspace = await svc.getWorkspace(owner.id, ws.id);
expect(workspace).to.not.be.undefined;
Expand Down Expand Up @@ -771,7 +771,7 @@ describe("WorkspaceService", async () => {
workspaceImage: "",
});

await svc.updateDeletionEligabilityTime(owner.id, ws.id);
await svc.updateDeletionEligibilityTime(owner.id, ws.id);

const workspace = await svc.getWorkspace(owner.id, ws.id);
expect(workspace).to.not.be.undefined;
Expand Down
Loading
Loading