Skip to content

Commit 63d160a

Browse files
committed
Make migration run-specific and add a Migrate button to the console run view
POST /actor-runtime/migrate/:actorId becomes POST /actor-runtime/migrate/:runId - keyed by the run (the thing that actually migrates), ownership-scoped like every other run endpoint. The response is now the run object (the same shape abort/reboot return, still RUNNING - a migration is not a status); a finished run is rejected 403 job-finished like reboot, a READY/ABORTING one 400 invalid-request. A second call while the window is open still joins it. The console run detail view now shows the run's migrationCount/rebootCount and a Migration section: a Migrate button for a RUNNING run (a console-local form POST funnelling into the same migrateRun service path, guarded against cross-site submissions like the console's other two writes), or an explanatory note for any other status. A press that raced the run ending redirects back with the reason shown inline - the dev-folder form's error pattern. Docs (api.md, console.md, CLAUDE.MD) updated; restartTrackingDriver moved to the shared test helpers for the new migrate-console suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzpRaq1BVPX272KpQmVwCT
1 parent 31ba0b1 commit 63d160a

9 files changed

Lines changed: 515 additions & 192 deletions

File tree

CLAUDE.MD

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,14 @@ Local Actor runtime is an Actor development tool for developing, running, and de
3030
hardcodes a `/v2`-suffixed base URL). From then on, edit locally, recompile locally (`tsc` or the
3131
language-appropriate equivalent), and `apify call` again - no `apify push`/build in between. Submitting
3232
`--body '""'` clears the registration. Dependency changes still need a real rebuild.
33-
- To test how an Actor handles platform migrations locally: while the Actor is running, call
34-
`apify api POST /actor-runtime/migrate/<actorId>` (this runtime's own `/actor-runtime/*` namespace,
35-
like `dev-folder` above). Every `RUNNING` run of that Actor then gets the same experience a platform
36-
migration gives it: a `migrating` event on its events websocket, its container stopped a few seconds
37-
later (immediately, if the SDK's default `migrating` handler calls `Actor.reboot()`), and a fresh
38-
container started for the same run - same run id, env vars, and default storages, in-memory state
39-
gone. The response lists the run ids being migrated; `POST /v2/actor-runs/<runId>/reboot` is also
40-
implemented, matching the real platform.
33+
- To test how an Actor handles platform migrations locally: while a run is `RUNNING`, call
34+
`apify api POST /actor-runtime/migrate/<runId>` (this runtime's own `/actor-runtime/*` namespace,
35+
like `dev-folder` above), or press the Migrate button on that run's detail page in the console. The
36+
run then gets the same experience a platform migration gives it: a `migrating` event on its events
37+
websocket, its container stopped a few seconds later (immediately, if the SDK's default `migrating`
38+
handler calls `Actor.reboot()`), and a fresh container started for the same run - same run id, env
39+
vars, and default storages, in-memory state gone. The response is the run object (still `RUNNING`);
40+
`POST /v2/actor-runs/<runId>/reboot` is also implemented, matching the real platform.
4141
- If a call fails because this runtime doesn't have the Actor/run/build/storage id you're after, or
4242
doesn't implement that endpoint at all, you can opt in to having such calls transparently relayed to
4343
the real Apify platform instead of failing:

requirements/api.md

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@
188188
- A second abort arriving during an open window: another `?gracefully=true` joins that window and neither
189189
restarts it nor stops the container early; a non-graceful one escalates and stops the container at once.
190190

191-
## Migration emulation (`POST /actor-runtime/migrate/:actorId`) and reboot
191+
## Migration emulation (`POST /actor-runtime/migrate/:runId`) and reboot
192192

193193
On the real platform a migration is not a run status and has no `/v2` API: a worker goes away and the
194194
controller daemon re-allocates its still-`RUNNING` runs to other workers (`apify-core`'s
@@ -198,23 +198,25 @@ websocket, its process being killed shortly after, and - once the new container
198198
continuing where its storages left off. This runtime emulates exactly that observable experience, with a
199199
local-namespace trigger so developers can test their Actor's migration handling on demand:
200200

201-
- **`POST /actor-runtime/migrate/:actorId`** (also at `/v2/actor-runtime/migrate/:actorId`, like every
202-
endpoint in this namespace) - authenticated like the rest of the namespace, `:actorId` accepting the
203-
usual id/name/`username~name` forms, scoped to the caller's own Actors (unknown/foreign: `404`
204-
`record-not-found`).
205-
- Triggers an emulated migration of every currently-`RUNNING` run of that Actor: a `migrating` frame
206-
with an empty payload is published on each run's events channel immediately, and each container is
207-
stopped `MIGRATING_STOP_WINDOW_MS` (5 seconds - the platform promises only "a few seconds") later,
208-
then restarted for the same run. The run's status stays `RUNNING` throughout; `startedAt`,
209-
`finishedAt`, `exitCode`, and the three default storage ids are untouched, and the restarted
210-
container gets byte-identical env vars, so the SDK reconnects to the same events websocket and
211-
re-reads the same `INPUT`. `stats.migrationCount` increments once per stop actually performed.
212-
- Runs in any other state are skipped (`READY` has no container yet; terminal runs are done). The
213-
response is immediate - `{ "data": { "migratingRunIds": [...] } }`, the ids whose migration was
214-
started or joined; an Actor with no `RUNNING` runs answers `{ "migratingRunIds": [] }`, not an
215-
error. A second migrate call while a run's window is still open joins it (same no-op-join semantics
216-
as a second `?gracefully=true` abort) - the run's id is still reported, but no second window opens
217-
and no second `migrating` frame is sent.
201+
- **`POST /actor-runtime/migrate/:runId`** (also at `/v2/actor-runtime/migrate/:runId`, like every
202+
endpoint in this namespace) - authenticated like the rest of the namespace, keyed by run (the thing
203+
that actually migrates), scoped to the caller's own runs (unknown/foreign: `404` `record-not-found`).
204+
The console's run detail view exposes the same trigger as a Migrate button (`console.md`).
205+
- Triggers an emulated migration of that run: a `migrating` frame with an empty payload is published
206+
on the run's events channel immediately, and the container is stopped `MIGRATING_STOP_WINDOW_MS`
207+
(5 seconds - the platform promises only "a few seconds") later, then restarted for the same run.
208+
The run's status stays `RUNNING` throughout; `startedAt`, `finishedAt`, `exitCode`, and the three
209+
default storage ids are untouched, and the restarted container gets byte-identical env vars, so
210+
the SDK reconnects to the same events websocket and re-reads the same `INPUT`.
211+
`stats.migrationCount` increments once per stop actually performed.
212+
- The response is immediate and is the run object (the same shape `abort`/`reboot` return), read
213+
back after the migration started - still `RUNNING`, since a migration is not a status. A second
214+
migrate call while the window is still open joins it (same no-op-join semantics as a second
215+
`?gracefully=true` abort): same response, but no second window opens and no second `migrating`
216+
frame is sent.
217+
- Only a `RUNNING` run has a container to migrate. A finished run is rejected `403` `job-finished`
218+
(the same rejection `reboot` gives); a non-terminal run with no container (`READY`, `ABORTING`)
219+
is `400` `invalid-request`.
218220
- The run's timeout budget is per run, not per container: a restarted container gets only the
219221
remaining `timeoutSecs`, counted from the run's original `startedAt` (mirroring the platform's
220222
`runtime.timeoutAt`, which is written once at start and survives migrations - only a resurrect

requirements/console.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
- The console has no login of its own, so with multiple users it lists and shows every user's objects
1010
rather than scoping to one - the API's own endpoints stay strictly scoped to the calling token's user
1111
(`storage.md`'s "Users" section).
12-
- The console is unauthenticated. Every route is a read except the dev-folder form below and the
13-
Settings form below, which are the console's only two writes - it is no longer strictly view-only.
14-
- Both of those two writes reject a submission that identifies itself as cross-site (via the
12+
- The console is unauthenticated. Every route is a read except the dev-folder form below, the run
13+
detail view's Migrate button below, and the Settings form below, which are the console's only three
14+
writes - it is no longer strictly view-only.
15+
- All three of those writes reject a submission that identifies itself as cross-site (via the
1516
`Sec-Fetch-Site` header) with a plain `403`; a submission that does not is unaffected. This
1617
narrows the console's existing "anyone who can reach it" model by one specific vector, it does
1718
not add a login.
@@ -57,6 +58,21 @@
5758
- A submission that fails validation redirects back to the same detail page with the classified error
5859
message shown inline, never swallowed by the redirect.
5960

61+
## Migrate button (run detail view)
62+
63+
- The run detail view shows the run's `migrationCount` and `rebootCount` alongside its other fields, and
64+
a "Migration" section. For a `RUNNING` run the section is a one-button form that triggers the same
65+
emulated migration as `POST /actor-runtime/migrate/:runId` (`api.md`'s "Migration emulation" section),
66+
funnelling into the same service path, so the two surfaces can never behave differently; for any other
67+
status it explains that only a `RUNNING` run can be migrated, with no button.
68+
- Pressing the button redirects back to the same run detail page - the migration proceeds in the
69+
background (`migrating` event now, container stop a few seconds later, then a restart of the same
70+
run), so reloading the page shows the log marker and the bumped `migrationCount` as they happen. A
71+
press that raced the run ending redirects back with the reason shown inline, never swallowed by the
72+
redirect - the dev-folder form's error pattern.
73+
- Like the dev-folder form, the button writes cross-user by the id already in the page URL - the
74+
console's usual unauthenticated model, not a new exposure specific to this button.
75+
6076
## Settings page
6177

6278
- Every page's header navigation includes a link to `/settings`, the one page for the upstream API

src/api/routes/migrate.ts

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,61 @@
11
/**
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.
1111
*
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`.
2622
*/
2723
import type { Router } from 'express';
2824

2925
import { requireUser } from '../auth.js';
3026
import { sendData } from '../envelope.js';
31-
import { recordNotFound } from '../errors.js';
27+
import { invalidRequest, jobAlreadyFinished, recordNotFound } from '../errors.js';
3228
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';
3531
import { migrateRun } from '../../services/migrations.js';
32+
import { runDto } from '../dto/actors.js';
3633
import type { ApiServerDeps } from '../server.js';
3734

38-
/** Mounts the `/migrate/:actorId` route onto `router`, matching `mountDevFolder`'s convention -
35+
/** Mounts the `/migrate/:runId` route onto `router`, matching `mountDevFolder`'s convention -
3936
* `server.ts` owns the sub-router, its shared `auth()`, and both mount paths. */
4037
export function mountMigrate(router: Router, deps: ApiServerDeps): void {
4138
router.post(
42-
'/migrate/:actorId',
39+
'/migrate/:runId',
4340
h(async (req, res) => {
4441
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();
4745

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})`);
5752
}
5853

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));
6059
}),
6160
);
6261
}

0 commit comments

Comments
 (0)