Skip to content
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
7 changes: 7 additions & 0 deletions components/gitpod-db/src/typeorm/entity/db-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ export class DBWorkspace implements Workspace {
})
type: WorkspaceType;

@Column({
default: "",
transformer: Transformer.MAP_EMPTY_STR_TO_UNDEFINED,
})
@Index("ind_deletionEligibilityTime")
deletionEligibilityTime?: string;

@Column({
type: "varchar",
default: "",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2024 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";
import { columnExists, indexExists } from "./helper/helper";

const TABLE_NAME = "d_b_workspace";
const COLUMN_NAME = "deletionEligibilityTime";
const INDEX_NAME = "ind_deletionEligibilityTime";

export class WorkspaceDeletionEligibilityTime1717407555612 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) {
await queryRunner.query(
`ALTER TABLE \`${TABLE_NAME}\` ADD COLUMN \`${COLUMN_NAME}\` varchar(30) NOT NULL DEFAULT '', ALGORITHM=INPLACE, LOCK=NONE`,
);
}
if (!(await indexExists(queryRunner, TABLE_NAME, INDEX_NAME))) {
await queryRunner.query(
`ALTER TABLE \`${TABLE_NAME}\` ADD INDEX \`${INDEX_NAME}\` (${COLUMN_NAME}), ALGORITHM=INPLACE, LOCK=NONE`,
);
}
}

public async down(queryRunner: QueryRunner): Promise<void> {}
}
6 changes: 6 additions & 0 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,12 @@ export interface Workspace {
*/
contentDeletedTime?: string;

/**
* The time when the workspace is eligible for soft deletion. This is the time when the workspace
* is marked as softDeleted earliest.
*/
deletionEligibilityTime?: string;

type: WorkspaceType;

basedOnPrebuildId?: string;
Expand Down
103 changes: 103 additions & 0 deletions components/server/src/workspace/workspace-service.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,107 @@ describe("WorkspaceService", async () => {
"from must be before to",
);
});

it("should update the deletion eligibility time of a workspace", async () => {
const svc = container.get(WorkspaceService);
const db = container.get<WorkspaceDB>(WorkspaceDB);
const today = new Date();
const daysAgo = (days: number) => new Date(today.getTime() - 1000 * 60 * 60 * 24 * days);

const ws = await createTestWorkspace(svc, org, owner, project);
await db.storeInstance({
id: v4(),
workspaceId: ws.id,
creationTime: daysAgo(7).toISOString(),
status: {
conditions: {},
phase: "stopped",
},
region: "us-central1",
ideUrl: "",
configuration: {
ideImage: "",
},
workspaceImage: "",
});

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

const workspace = await svc.getWorkspace(owner.id, ws.id);
expect(workspace).to.not.be.undefined;
expect(workspace.workspace.deletionEligibilityTime).to.not.be.undefined;
expect(workspace.workspace.deletionEligibilityTime).to.eq(daysAgo(7 - 14).toISOString());
});

it("should update the deletion eligibility time of a workspace with git changes", async () => {
const svc = container.get(WorkspaceService);
const db = container.get<WorkspaceDB>(WorkspaceDB);
const today = new Date();
const daysAgo = (days: number) => new Date(today.getTime() - 1000 * 60 * 60 * 24 * days);

const ws = await createTestWorkspace(svc, org, owner, project);
await db.storeInstance({
id: v4(),
workspaceId: ws.id,
creationTime: daysAgo(7).toISOString(),
status: {
conditions: {},
phase: "stopped",
},
region: "us-central1",
ideUrl: "",
gitStatus: {
totalUnpushedCommits: 2,
},
configuration: {
ideImage: "",
},
workspaceImage: "",
});

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

const workspace = await svc.getWorkspace(owner.id, ws.id);
expect(workspace).to.not.be.undefined;
expect(workspace.workspace.deletionEligibilityTime).to.not.be.undefined;
expect(workspace.workspace.deletionEligibilityTime).to.eq(daysAgo(7 - 14 * 2).toISOString());
});

it("should update the deletion eligibility time of a prebuild", async () => {
const svc = container.get(WorkspaceService);
const db = container.get<WorkspaceDB>(WorkspaceDB);
const today = new Date();
const daysAgo = (days: number) => new Date(today.getTime() - 1000 * 60 * 60 * 24 * days);

const ws = await createTestWorkspace(svc, org, owner, project);
ws.type = "prebuild";
await db.store(ws);
await db.storeInstance({
id: v4(),
workspaceId: ws.id,
creationTime: daysAgo(7).toISOString(),
status: {
conditions: {},
phase: "stopped",
},
region: "us-central1",
ideUrl: "",
gitStatus: {
totalUnpushedCommits: 2,
},
configuration: {
ideImage: "",
},
workspaceImage: "",
});

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

const workspace = await svc.getWorkspace(owner.id, ws.id);
expect(workspace).to.not.be.undefined;
expect(workspace.workspace.deletionEligibilityTime).to.not.be.undefined;
expect(workspace.workspace.deletionEligibilityTime).to.eq(daysAgo(7 - 7).toISOString());
});
});

async function createTestWorkspace(svc: WorkspaceService, org: Organization, owner: User, project: Project) {
Expand Down Expand Up @@ -754,4 +855,6 @@ async function createTestWorkspaceWithInstances(
workspaceImage: "",
});
}

return id;
}
51 changes: 50 additions & 1 deletion components/server/src/workspace/workspace-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export class WorkspaceService {
);
throw err;
}

await this.updateDeletionEligabilityTime(user.id, workspace.id);
return workspace;
}

Expand Down Expand Up @@ -335,6 +335,7 @@ export class WorkspaceService {
return;
}
await this.workspaceStarter.stopWorkspaceInstance({}, instance.id, instance.region, reason, policy);
this.updateDeletionEligabilityTime(userId, workspaceId);
}

public async stopRunningWorkspacesForUser(
Expand All @@ -355,11 +356,57 @@ export class WorkspaceService {
reason,
policy,
);
await this.updateDeletionEligabilityTime(userId, info.workspace.id);
}),
);
return infos.map((instance) => instance.workspace);
}

/**
* Sets the deletionEligibilityTime of the workspace, depening of the current state of the workspace and the configuration.
*
* @param userId sets the
* @param workspaceId
* @returns
*/
async updateDeletionEligabilityTime(userId: string, workspaceId: string): Promise<void> {
try {
let daysToLive = this.config.workspaceGarbageCollection?.minAgeDays || 14;
const daysToLiveForPrebuilds = this.config.workspaceGarbageCollection?.minAgePrebuildDays || 7;

const workspace = await this.doGetWorkspace(userId, workspaceId);
const instance = await this.db.findCurrentInstance(workspaceId);
const lastActive =
instance?.stoppingTime || instance?.startedTime || instance?.creationTime || workspace?.creationTime;
if (!lastActive) {
return;
}
const deletionEligibilityTime = new Date(lastActive);
if (workspace.type === "prebuild") {
// set to last active plus daysToLiveForPrebuilds as iso string
deletionEligibilityTime.setDate(deletionEligibilityTime.getDate() + daysToLiveForPrebuilds);
await this.db.updatePartial(workspaceId, {
deletionEligibilityTime: deletionEligibilityTime.toISOString(),
});
return;
}
// workspaces with pending changes live twice as long
if (
(instance?.gitStatus?.totalUncommitedFiles || 0) > 0 ||
(instance?.gitStatus?.totalUnpushedCommits || 0) > 0 ||
(instance?.gitStatus?.totalUnpushedCommits || 0) > 0
) {
daysToLive = daysToLive * 2;
}
deletionEligibilityTime.setDate(deletionEligibilityTime.getDate() + daysToLive);
await this.db.updatePartial(workspaceId, {
deletionEligibilityTime: deletionEligibilityTime.toISOString(),
});
} catch (error) {
log.error({ userId, workspaceId }, "Failed to update deletion eligibility time", error);
}
}

/**
* This method does nothing beyond marking the given workspace as 'softDeleted' with the given cause and sets the 'softDeletedTime' to now.
* The actual deletion happens as part of the regular workspace garbage collection.
Expand Down Expand Up @@ -610,6 +657,7 @@ export class WorkspaceService {

// at this point we're about to actually start a new workspace
const result = await this.workspaceStarter.startWorkspace(ctx, workspace, user, await projectPromise, options);
await this.updateDeletionEligabilityTime(user.id, workspaceId);
return result;
}

Expand Down Expand Up @@ -739,6 +787,7 @@ export class WorkspaceService {

const workspace = await this.doGetWorkspace(userId, workspaceId);
instance = await this.db.updateInstancePartial(instance.id, { gitStatus });
await this.updateDeletionEligabilityTime(userId, workspaceId);
await this.publisher.publishInstanceUpdate({
instanceID: instance.id,
ownerID: workspace.ownerId,
Expand Down