| 1 | import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { AUTHORED_PAGE, assembleExport, exportAccount, exportAuthored, exportRepository, failExport, startExport, type ExportJob } from "./export.js"; |
| 4 | import { deleteAccountObjects, destroyRepository, ghostPage, type DeletionCleanup } from "./lifecycle.js"; |
| 5 | import { plainSteps } from "../lib/rpc.js"; |
| 6 | |
| 7 | export type AccountJob = ExportJob | DeletionCleanup; |
| 8 | |
| 9 | const RETRY = { retries: { limit: 5, delay: "10 seconds", backoff: "exponential" }, timeout: "15 minutes" } as const; |
| 10 | /** Bundling a large repository, or putting a large archive together, takes longer. */ |
| 11 | const LONG = { ...RETRY, timeout: "60 minutes" } as const; |
| 12 | /** An overall cap on paged steps, so a broken page can't run forever (enough for 100,000 repositories). */ |
| 13 | const MAX_PAGES = 4000; |
| 14 | |
| 15 | /** |
| 16 | * The background part of the account lifecycle (accounts/): building an export (instance `export-<id>`), and |
| 17 | * finishing a deletion (instance `delete-<account id>`): wiping the deleted repositories, the account's own |
| 18 | * R2 objects, and attributing its work in every other repository to the ghost. Each step is retried on |
| 19 | * failure; an export that still fails is marked failed with the reason. |
| 20 | */ |
| 21 | export class AccountWorkflow extends WorkflowEntrypoint<Env, AccountJob> { |
| 22 | override async run(event: WorkflowEvent<AccountJob>, rpcStep: WorkflowStep): Promise<unknown> { |
| 23 | const step = plainSteps(rpcStep); |
| 24 | const job = event.payload; |
| 25 | if (job.kind === "export") return this.export(job.exportId, step); |
| 26 | return this.cleanup(job, step); |
| 27 | } |
| 28 | |
| 29 | private async export(id: string, step: WorkflowStep): Promise<unknown> { |
| 30 | try { |
| 31 | const plan = await step.do("start", RETRY, () => startExport(this.env, id)); |
| 32 | await step.do("account", RETRY, () => exportAccount(this.env, id)); |
| 33 | for (const repoId of plan.owned) await step.do(`repository ${repoId}`, LONG, () => exportRepository(this.env, id, repoId)); |
| 34 | for (let i = 0; i < plan.visible.length; i += AUTHORED_PAGE) { |
| 35 | const page = plan.visible.slice(i, i + AUTHORED_PAGE); |
| 36 | await step.do(`authored ${i}`, RETRY, () => exportAuthored(this.env, id, page)); |
| 37 | } |
| 38 | await step.do("assemble", LONG, () => assembleExport(this.env, id)); |
| 39 | return { ready: true }; |
| 40 | } catch (e) { |
| 41 | const message = e instanceof Error ? e.message : String(e); |
| 42 | await step.do("fail", RETRY, () => failExport(this.env, id, message)); |
| 43 | return { failed: message }; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | private async cleanup(job: DeletionCleanup, step: WorkflowStep): Promise<unknown> { |
| 48 | for (const repoId of job.repoIds) await step.do(`destroy ${repoId}`, LONG, () => destroyRepository(this.env, repoId)); |
| 49 | for (let i = 0; i < 10; i++) { |
| 50 | if (await step.do(`objects ${i}`, RETRY, () => deleteAccountObjects(this.env, job.accountId))) break; |
| 51 | } |
| 52 | let after: string | null = ""; |
| 53 | for (let i = 0; after !== null && i < MAX_PAGES; i++) { |
| 54 | const from: string = after; |
| 55 | after = await step.do(`ghost ${i}`, RETRY, () => ghostPage(this.env, job, from)); |
| 56 | } |
| 57 | return { done: true }; |
| 58 | } |
| 59 | } |