|
1 | 1 | /** |
2 | | - * `POST /actor-runtime/migrate/:actorId` - manually triggers an emulated migration of the Actor's |
3 | | - * currently `RUNNING` runs, so a developer can test their Actor's migration handling locally |
4 | | - * (`requirements/api.md`'s "Migration emulation" section). Deliberately outside the emulated `/v2` |
5 | | - * surface, in the same local-runtime-only namespace as `dev-folder` and `api-fallback`: the real |
6 | | - * platform has no "migrate this run" API at all - migrations happen when a worker goes away and the |
7 | | - * controller daemon re-allocates its runs - so an endpoint to trigger one on demand cannot live on a |
8 | | - * real `/v2` path. Mounted by `server.ts` on the shared `/actor-runtime` sub-router (with its own |
9 | | - * `auth()`, registered once there), which is also reachable at `/v2/actor-runtime/*` purely for |
10 | | - * `apify api` CLI ergonomics - see `server.ts`'s doc comment. |
| 2 | + * `POST /actor-runtime/migrate/:runId` - manually triggers an emulated migration of one run, so a |
| 3 | + * developer can test their Actor's migration handling locally (`requirements/api.md`'s "Migration |
| 4 | + * emulation" section). Deliberately outside the emulated `/v2` surface, in the same local-runtime-only |
| 5 | + * namespace as `dev-folder` and `api-fallback`: the real platform has no "migrate this run" API at all - |
| 6 | + * migrations happen when a worker goes away and the controller daemon re-allocates its runs - so an |
| 7 | + * endpoint to trigger one on demand cannot live on a real `/v2` path. Mounted by `server.ts` on the |
| 8 | + * shared `/actor-runtime` sub-router (with its own `auth()`, registered once there), which is also |
| 9 | + * reachable at `/v2/actor-runtime/*` purely for `apify api` CLI ergonomics - see `server.ts`'s doc |
| 10 | + * comment. |
11 | 11 | * |
12 | | - * Keyed by Actor, not run, and ownership-scoped like every other route on this API (`resolveOwnedActor` |
13 | | - * accepts the id, the plain name, or `username~name`): every `RUNNING` run of the caller's own Actor |
14 | | - * migrates, which is exactly the blast radius a real worker drain has on an Actor's runs that happen to |
15 | | - * share the dying worker. Runs in any other state have no container to migrate - `READY` ones have not |
16 | | - * started theirs yet (the platform re-allocates those invisibly to the Actor), terminal ones are done - |
17 | | - * so they are skipped, and an Actor with no `RUNNING` runs at all answers success with an empty list |
18 | | - * rather than an error: "migrate whatever is running" is naturally idempotent. |
19 | | - * |
20 | | - * The response returns immediately with `{ data: { migratingRunIds } }` - the ids whose migration was |
21 | | - * started (or joined, if a window was already open). The migration itself proceeds in the background, |
22 | | - * exactly like on the platform: `migrating` frame now, container stop `MIGRATING_STOP_WINDOW_MS` later |
23 | | - * (or immediately, when the Actor's SDK reacts by calling `POST .../reboot`), then a fresh container |
24 | | - * for the same run. Holding the response open for the whole window instead would serialize what the |
25 | | - * platform does asynchronously, and would make the CLI call feel hung. |
| 12 | + * Keyed by run (the thing that actually migrates), ownership-scoped like every other route on this API |
| 13 | + * port. Response is the run object, exactly like the `abort`/`reboot` run endpoints, read back fresh |
| 14 | + * *after* the migration started so the caller sees the record the migration is acting on (still |
| 15 | + * `RUNNING` - a migration is not a status). The migration itself proceeds in the background, exactly |
| 16 | + * like on the platform: `migrating` frame now, container stop `MIGRATING_STOP_WINDOW_MS` later (or |
| 17 | + * immediately, when the Actor's SDK reacts by calling `POST .../reboot`), then a fresh container for |
| 18 | + * the same run. A second call while the window is open joins it - same response, no second frame or |
| 19 | + * window. Only a `RUNNING` run has a container to migrate: a finished run is `403` `job-finished` |
| 20 | + * (the same rejection `reboot` gives, `errors.actor.jobAlreadyFinished()`), and a non-terminal run |
| 21 | + * with no container (`READY`, `ABORTING`) is `400` `invalid-request`. |
26 | 22 | */ |
27 | 23 | import type { Router } from 'express'; |
28 | 24 |
|
29 | 25 | import { requireUser } from '../auth.js'; |
30 | 26 | import { sendData } from '../envelope.js'; |
31 | | -import { recordNotFound } from '../errors.js'; |
| 27 | +import { invalidRequest, jobAlreadyFinished, recordNotFound } from '../errors.js'; |
32 | 28 | import { h } from '../handler.js'; |
33 | | -import { resolveOwnedActor } from '../../services/actors.js'; |
34 | | -import { listOwnedRuns } from '../../services/runs.js'; |
| 29 | +import { isTerminalJobStatus } from '../../services/job-status.js'; |
| 30 | +import { getOwnedRun } from '../../services/runs.js'; |
35 | 31 | import { migrateRun } from '../../services/migrations.js'; |
| 32 | +import { runDto } from '../dto/actors.js'; |
36 | 33 | import type { ApiServerDeps } from '../server.js'; |
37 | 34 |
|
38 | | -/** Mounts the `/migrate/:actorId` route onto `router`, matching `mountDevFolder`'s convention - |
| 35 | +/** Mounts the `/migrate/:runId` route onto `router`, matching `mountDevFolder`'s convention - |
39 | 36 | * `server.ts` owns the sub-router, its shared `auth()`, and both mount paths. */ |
40 | 37 | export function mountMigrate(router: Router, deps: ApiServerDeps): void { |
41 | 38 | router.post( |
42 | | - '/migrate/:actorId', |
| 39 | + '/migrate/:runId', |
43 | 40 | h(async (req, res) => { |
44 | 41 | const user = requireUser(req); |
45 | | - const actor = await resolveOwnedActor(user.id, req.params.actorId as string, user.username); |
46 | | - if (!actor) throw recordNotFound(); |
| 42 | + const run = await getOwnedRun(user.id, req.params.runId as string); |
| 43 | + if (!run) throw recordNotFound(); |
| 44 | + if (isTerminalJobStatus(run.status)) throw jobAlreadyFinished(); |
47 | 45 |
|
48 | | - const runs = await listOwnedRuns(user.id, actor.id); |
49 | | - const migratingRunIds: string[] = []; |
50 | | - for (const run of runs) { |
51 | | - if (run.status !== 'RUNNING') continue; |
52 | | - const result = await migrateRun(deps.driver, run); |
53 | | - // 'joined' still reports the id: that run *is* migrating, this call just did not have to |
54 | | - // open the window itself. 'not-running' means the status moved between the list above and |
55 | | - // the re-check inside `migrateRun` - genuinely nothing to migrate any more. |
56 | | - if (result !== 'not-running') migratingRunIds.push(run.id); |
| 46 | + const result = await migrateRun(deps.driver, run); |
| 47 | + if (result === 'not-running') { |
| 48 | + // Non-terminal (the check above) but without a container to migrate - READY has not started |
| 49 | + // one yet, ABORTING's is already being stopped by the abort that owns it. Also reachable when |
| 50 | + // the run turned terminal between the ownership lookup and `migrateRun`'s own re-check. |
| 51 | + throw invalidRequest(`Only a RUNNING run can be migrated (current status: ${run.status})`); |
57 | 52 | } |
58 | 53 |
|
59 | | - sendData(res, { migratingRunIds }); |
| 54 | + // Read back after the migration started, like abort/reboot return the post-write record - the |
| 55 | + // status is still RUNNING (a migration is not a status), but the caller gets the record the |
| 56 | + // migration is genuinely acting on. |
| 57 | + const current = await getOwnedRun(user.id, run.id); |
| 58 | + sendData(res, runDto(current ?? run)); |
60 | 59 | }), |
61 | 60 | ); |
62 | 61 | } |
0 commit comments