Skip to content

Commit 319ffc5

Browse files
committed
add ephemeral maintenance loop
1 parent e7f9d94 commit 319ffc5

File tree

4 files changed

+139
-40
lines changed

4 files changed

+139
-40
lines changed

src/packages/hub/hub.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import initProjectControl, {
4343
import initIdleTimeout from "@cocalc/server/projects/control/stop-idle-projects";
4444
import initNewProjectPoolMaintenanceLoop from "@cocalc/server/projects/pool/maintain";
4545
import initPurchasesMaintenanceLoop from "@cocalc/server/purchases/maintenance";
46+
import initEphemeralMaintenance from "@cocalc/server/ephemeral-maintenance";
4647
import initSalesloftMaintenance from "@cocalc/server/salesloft/init";
4748
import { stripe_sync } from "@cocalc/server/stripe/sync";
4849
import { callback2, retry_until_success } from "@cocalc/util/async-utils";
@@ -295,6 +296,7 @@ async function startServer(): Promise<void> {
295296
// Starts periodic maintenance on pay-as-you-go purchases, e.g., quota
296297
// upgrades of projects.
297298
initPurchasesMaintenanceLoop();
299+
initEphemeralMaintenance();
298300
initSalesloftMaintenance();
299301
// Migrate bookmarks from database to conat (runs once at startup)
300302
migrateBookmarksToConat().catch((err) => {

src/packages/next/pages/api/v2/projects/delete.ts

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
/*
22
API endpoint to delete a project, which sets the "delete" flag to `true` in the database.
33
*/
4-
import isCollaborator from "@cocalc/server/projects/is-collaborator";
5-
import userIsInGroup from "@cocalc/server/accounts/is-in-group";
6-
import removeAllLicensesFromProject from "@cocalc/server/licenses/remove-all-from-project";
7-
import { getProject } from "@cocalc/server/projects/control";
8-
import userQuery from "@cocalc/database/user-query";
9-
import { isValidUUID } from "@cocalc/util/misc";
10-
4+
import deleteProject from "@cocalc/server/projects/delete";
115
import getAccountId from "lib/account/get-account";
126
import getParams from "lib/api/get-params";
137
import { apiRoute, apiRouteOperation } from "lib/api";
@@ -22,43 +16,11 @@ async function handle(req, res) {
2216
const account_id = await getAccountId(req);
2317

2418
try {
25-
if (!isValidUUID(project_id)) {
26-
throw Error("project_id must be a valid uuid");
27-
}
2819
if (!account_id) {
2920
throw Error("must be signed in");
3021
}
3122

32-
// If client is not an administrator, they must be a project collaborator in order to
33-
// delete a project.
34-
if (
35-
!(await userIsInGroup(account_id, "admin")) &&
36-
!(await isCollaborator({ account_id, project_id }))
37-
) {
38-
throw Error("must be an owner to delete a project");
39-
}
40-
41-
// Remove all project licenses
42-
//
43-
await removeAllLicensesFromProject({ project_id });
44-
45-
// Stop project
46-
//
47-
const project = getProject(project_id);
48-
await project.stop();
49-
50-
// Set "deleted" flag. We do this last to ensure that the project is not consuming any
51-
// resources while it is in the deleted state.
52-
//
53-
await userQuery({
54-
account_id,
55-
query: {
56-
projects: {
57-
project_id,
58-
deleted: true,
59-
},
60-
},
61-
});
23+
await deleteProject({ project_id, account_id });
6224

6325
res.json(OkStatus);
6426
} catch (err) {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import getPool from "@cocalc/database/pool";
2+
import deleteAccount from "@cocalc/server/accounts/delete";
3+
import deleteProject from "@cocalc/server/projects/delete";
4+
import { getLogger } from "@cocalc/backend/logger";
5+
6+
const log = getLogger("server:ephemeral-maintenance");
7+
8+
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
9+
const BATCH_SIZE = 25;
10+
11+
export default function initEphemeralMaintenance(): void {
12+
log.info("Starting ephemeral maintenance loop", {
13+
CHECK_INTERVAL_MS,
14+
BATCH_SIZE,
15+
});
16+
const run = async () => {
17+
try {
18+
await deleteExpiredProjects();
19+
await deleteExpiredAccounts();
20+
} catch (err) {
21+
log.error("ephemeral maintenance failed", err);
22+
}
23+
};
24+
run();
25+
setInterval(run, CHECK_INTERVAL_MS);
26+
}
27+
28+
async function deleteExpiredProjects(): Promise<void> {
29+
const pool = getPool();
30+
const { rows } = await pool.query(
31+
`SELECT project_id
32+
FROM projects
33+
WHERE deleted IS NOT true
34+
AND ephemeral IS NOT NULL
35+
AND ephemeral > 0
36+
AND created + ephemeral * interval '1 millisecond' < NOW()
37+
LIMIT $1`,
38+
[BATCH_SIZE],
39+
);
40+
for (const { project_id } of rows ?? []) {
41+
try {
42+
await deleteProject({ project_id, skipPermissionCheck: true });
43+
log.info("deleted expired ephemeral project", { project_id });
44+
} catch (err) {
45+
log.error("failed to delete ephemeral project", { project_id, err });
46+
}
47+
}
48+
}
49+
50+
async function deleteExpiredAccounts(): Promise<void> {
51+
const pool = getPool();
52+
const { rows } = await pool.query(
53+
`SELECT account_id
54+
FROM accounts
55+
WHERE deleted IS NOT true
56+
AND ephemeral IS NOT NULL
57+
AND ephemeral > 0
58+
AND created + ephemeral * interval '1 millisecond' < NOW()
59+
LIMIT $1`,
60+
[BATCH_SIZE],
61+
);
62+
for (const { account_id } of rows ?? []) {
63+
try {
64+
await deleteAccount(account_id);
65+
log.info("deleted expired ephemeral account", { account_id });
66+
} catch (err) {
67+
log.error("failed to delete ephemeral account", { account_id, err });
68+
}
69+
}
70+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import getPool from "@cocalc/database/pool";
2+
import userQuery from "@cocalc/database/user-query";
3+
import userIsInGroup from "@cocalc/server/accounts/is-in-group";
4+
import isCollaborator from "@cocalc/server/projects/is-collaborator";
5+
import removeAllLicensesFromProject from "@cocalc/server/licenses/remove-all-from-project";
6+
import { getProject } from "@cocalc/server/projects/control";
7+
import { getLogger } from "@cocalc/backend/logger";
8+
import { isValidUUID } from "@cocalc/util/misc";
9+
10+
const log = getLogger("server:projects:delete");
11+
12+
interface DeleteProjectOptions {
13+
project_id: string;
14+
account_id?: string;
15+
skipPermissionCheck?: boolean;
16+
}
17+
18+
export default async function deleteProject({
19+
project_id,
20+
account_id,
21+
skipPermissionCheck = false,
22+
}: DeleteProjectOptions): Promise<void> {
23+
if (!isValidUUID(project_id)) {
24+
throw Error("project_id must be a valid uuid");
25+
}
26+
27+
if (!skipPermissionCheck) {
28+
if (!account_id) {
29+
throw Error("must be signed in");
30+
}
31+
const admin = await userIsInGroup(account_id, "admin");
32+
const collaborator = admin
33+
? true
34+
: await isCollaborator({ account_id, project_id });
35+
if (!collaborator) {
36+
throw Error("must be an owner to delete a project");
37+
}
38+
}
39+
40+
await removeAllLicensesFromProject({ project_id });
41+
42+
const project = getProject(project_id);
43+
try {
44+
await project.stop();
45+
} catch (err) {
46+
log.debug("problem stopping project", { project_id, err });
47+
}
48+
49+
if (!skipPermissionCheck && account_id) {
50+
await userQuery({
51+
account_id,
52+
query: {
53+
projects: {
54+
project_id,
55+
deleted: true,
56+
},
57+
},
58+
});
59+
} else {
60+
const pool = getPool();
61+
await pool.query("UPDATE projects SET deleted=true WHERE project_id=$1", [
62+
project_id,
63+
]);
64+
}
65+
}

0 commit comments

Comments
 (0)