Skip to content

Commit 4b58310

Browse files
committed
Emulate platform migrations: migrate endpoint, console Migrate button, reboot, migration events
Emulates the Apify platform's Actor run migration so developers can test their Actor's migration handling locally, with the same end-user experience as the platform (reference: apify-core and apify-sdk-js): - POST /actor-runtime/migrate/:runId (plus the /v2 alias): publishes a {"name":"migrating","data":{}} frame on the run's events websocket, stops the container 5s later, then restarts the same run - same run id, byte-identical env, same default storages, status RUNNING throughout, cumulative log, remaining (not full) timeout budget. Responds with the run object; a second call joins an open window. Finished run: 403 job-finished; READY/ABORTING: 400 invalid-request. - Console run detail view: migrationCount/rebootCount fields and a Migrate button for RUNNING runs (console-local form POST into the same service path, cross-site-guarded like the console's other writes). - POST /v2/actor-runs/:runId/reboot: the real platform endpoint the SDKs call on `migrating`; stops and restarts the container immediately, cancels an open migration window, increments stats.rebootCount. - Run records carry the platform's restart-bookkeeping stats (migrationCount/rebootCount/restartCount/resurrectCount) and expose them on the run DTO. - Graceful abort now sends the platform's frame pair: aborting plus persistState {isMigrating:false}; the periodic persistState stays SDK-generated and `migrating` is never paired with a server-sent one. - An abort landing during a migration window or an in-flight restart wins: the migration stands down and the run ends ABORTED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzpRaq1BVPX272KpQmVwCT
1 parent 987376b commit 4b58310

21 files changed

Lines changed: 1174 additions & 82 deletions

CLAUDE.MD

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ 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: while a run is `RUNNING`, call
34+
`apify api POST /actor-runtime/migrate/<runId>`, or press the Migrate button on the run's console
35+
detail page. The run gets the platform migration experience: a `migrating` event, its container
36+
stopped a few seconds later, and a fresh container for the same run (same run id, env vars, and
37+
storages, in-memory state gone). `POST /v2/actor-runs/<runId>/reboot` is also implemented.
3338
- If a call fails because this runtime doesn't have the Actor/run/build/storage id you're after, or
3439
doesn't implement that endpoint at all, you can opt in to having such calls transparently relayed to
3540
the real Apify platform instead of failing:

requirements/api.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
- v2/actor-runs
6666
- v2/actor-runs/:runId
6767
- v2/actor-runs/:runId/abort
68+
- v2/actor-runs/:runId/reboot
6869
- v2/actor-runs/:runId/log
6970
- Datasets
7071
- v2/datasets
@@ -145,7 +146,8 @@
145146
reject exactly the same inputs with the same outcomes.
146147
- **`GET /actor-runtime/events/:runId`** - a websocket upgrade, reachable at exactly this one path on
147148
the fixed API port (`system.md`). It carries the run's platform events: `systemInfo` once a second
148-
(`actor-driver.md`), plus a one-off `aborting` frame under `?gracefully=` (below). Each frame is a
149+
(`actor-driver.md`), a one-off `aborting`-plus-`persistState` pair under `?gracefully=` (below), and a
150+
one-off `migrating` frame when a migration is triggered ("Migration emulation" below). Each frame is a
149151
single text message, `{"name": "...", "data": {...}}`.
150152
- The endpoint has no authentication. The run id in the path is the only thing it scopes on, and a
151153
connection only ever receives that run's own frames; one run never sees another's.
@@ -154,20 +156,53 @@
154156
as fatal to the Actor.
155157
- A connection to a live run stays open until the run ends, when the server closes it with `1000`. It
156158
is never dropped while healthy, except that a graceful runtime shutdown terminates every open
157-
connection along with the rest of the server.
158-
- `persistState` is never sent over this channel; both SDKs generate it themselves.
159+
connection along with the rest of the server. A migration/reboot restart is not the run ending: the
160+
restarted container reconnects to the same path.
161+
- The _periodic_ `persistState` is never sent over this channel; both SDKs generate it themselves.
162+
The server sends `persistState` exactly once per graceful abort, alongside `aborting` (matching the
163+
platform), and never alongside `migrating` (the SDKs synthesize that one).
159164

160165
## Graceful abort (`?gracefully=`)
161166

162167
- `POST /v2/actor-runs/:runId/abort` accepts an optional `?gracefully=` boolean.
163168
- Omitted or `false`: the run aborts immediately.
164-
- `true` on a running run: the record moves to `ABORTING` at once, an `aborting` frame with an empty
165-
payload is published on the run's events channel, and the container is stopped 30 seconds later. The
166-
request stays open until then.
169+
- `true` on a running run: the record moves to `ABORTING` at once, an `aborting` frame plus a
170+
`persistState {"isMigrating": false}` frame (in that order, matching the platform) are published on
171+
the run's events channel, and the container is stopped 30 seconds later. The request stays open until
172+
then.
167173
- `true` on a run with no container (still `READY`, or already terminal): behaves as if omitted.
168174
- A second abort arriving during an open window: another `?gracefully=true` joins that window and neither
169175
restarts it nor stops the container early; a non-graceful one escalates and stops the container at once.
170176

177+
## Migration emulation (`POST /actor-runtime/migrate/:runId`) and reboot
178+
179+
A platform migration is not a run status: the run stays `RUNNING` while its container is killed and a
180+
new one starts for the same run - same run id, env vars, and default storages, in-memory state gone.
181+
This runtime emulates that observable experience on demand:
182+
183+
- **`POST /actor-runtime/migrate/:runId`** (also at `/v2/actor-runtime/migrate/:runId`) - authenticated
184+
like the rest of this namespace, scoped to the caller's own runs. The console's run detail view
185+
exposes the same trigger as a Migrate button (`console.md`).
186+
- Publishes a `migrating` frame (empty payload) on the run's events channel immediately, stops the
187+
container 5 seconds later (the platform promises only "a few seconds"), then restarts the same
188+
run. Status stays `RUNNING`; `startedAt`, `finishedAt`, `exitCode`, the default storage ids, and
189+
the container env are unchanged. `stats.migrationCount` increments once per performed stop.
190+
- Responds immediately with the run object (same shape as `abort`/`reboot`). A second call during
191+
the open window joins it: same response, no second frame or window.
192+
- Errors: unknown/foreign run `404` `record-not-found`; finished run `403` `job-finished`;
193+
`READY`/`ABORTING` `400` `invalid-request`.
194+
- The timeout budget is per run, not per container: a restarted container gets only the remaining
195+
`timeoutSecs`.
196+
- An abort (graceful or hard) landing during the window or restart wins: the run ends `ABORTED`,
197+
never restarted.
198+
- **`POST /v2/actor-runs/:runId/reboot`** - the real platform endpoint the SDKs call from their default
199+
`migrating` handler. Stops and restarts the run's container immediately (no warning frame), cancels an
200+
open migration window, and increments `stats.rebootCount`. A finished run is `403` `job-finished`; a
201+
non-terminal run with no container (`READY`, `ABORTING`) gets the count bump but no restart.
202+
- The run object's `stats` carries `migrationCount`, `rebootCount`, `restartCount`, and `resurrectCount`
203+
(the latter two always `0` here), initialized to `0` at run creation like the platform.
204+
- The run's log is cumulative across restarts, with a one-line marker between the incarnations' output.
205+
171206
## Upstream fallback (opt-in, off by default, all HTTP methods)
172207

173208
- Two independent booleans, `fallbackUnimplementedEnabled` and `fallbackNotFoundEnabled`, gate whether

requirements/console.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
- The console has no login of its own, so with multiple users it lists and shows every user's objects
77
rather than scoping to one - the API's own endpoints stay strictly scoped to the calling token's user
88
(`storage.md`'s "Users" section).
9-
- The console is unauthenticated. Every route is a read except the console's only two writes: the
10-
dev-folder form and the Settings form (both below).
11-
- Both of those writes reject a submission that identifies itself as cross-site (via the
9+
- The console is unauthenticated. Every route is a read except the console's only three writes: the
10+
dev-folder form, the run detail view's Migrate button, and the Settings form (all below).
11+
- All three of those writes reject a submission that identifies itself as cross-site (via the
1212
`Sec-Fetch-Site` header) with a plain `403`; a submission that does not is unaffected.
1313
- There are three types of objects: key-value store, dataset, request queue.
1414
- For each object type there must be exactly one widget for inspection.
@@ -50,6 +50,16 @@
5050
- A submission that fails validation redirects back to the same detail page with the classified error
5151
message shown inline, never swallowed by the redirect.
5252

53+
## Migrate button (run detail view)
54+
55+
- The run detail view shows the run's `migrationCount` and `rebootCount`, and a "Migration" section:
56+
for a `RUNNING` run, a Migrate button that triggers the same emulated migration as
57+
`POST /actor-runtime/migrate/:runId` (`api.md`); for any other status, a note that only a `RUNNING`
58+
run can be migrated, with no button.
59+
- Pressing the button returns to the same detail page while the migration proceeds in the background.
60+
A press that raced the run ending shows the reason inline, never swallowed by the redirect.
61+
- Like the dev-folder form, the button writes cross-user - the console's usual unauthenticated model.
62+
5363
## Settings page
5464

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

src/api/dto/actors.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,13 @@ export function runDto(run: RunRecord) {
7777
},
7878
generalAccess: run.generalAccess ?? 'FOLLOW_USER_SETTING',
7979
meta: run.meta,
80-
stats: {},
80+
// The platform's restart-bookkeeping stats (see `RunRecord.stats`); zeros backfill old fixtures.
81+
stats: {
82+
migrationCount: run.stats?.migrationCount ?? 0,
83+
rebootCount: run.stats?.rebootCount ?? 0,
84+
restartCount: run.stats?.restartCount ?? 0,
85+
resurrectCount: run.stats?.resurrectCount ?? 0,
86+
},
8187
statusMessage: run.statusMessage,
8288
containerUrl: undefined,
8389
};

src/api/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ export function cannotRemoveRunningRun(): ApiError {
3535
);
3636
}
3737

38+
/** Matches the real platform's rejection of reboot/migrate on a finished run (`apify-core`'s
39+
* `errors.actor.jobAlreadyFinished()`). */
40+
export function jobAlreadyFinished(): ApiError {
41+
return new ApiError(403, 'job-finished', 'Actor job is already finished.');
42+
}
43+
3844
/**
3945
* Matches the real Apify platform exactly: `DELETE /v2/actor-builds/:buildId` on a non-terminal build
4046
* is rejected rather than aborted-then-deleted (`apify-core`'s `errors.api.deletingUnfinishedBuild()`,

src/api/routes/migrate.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* `POST /actor-runtime/migrate/:runId` - triggers an emulated migration of one run
3+
* (`requirements/api.md`, "Migration emulation"). Lives in the local-runtime-only namespace because the
4+
* real platform has no migrate API. Ownership-scoped; responds with the run object (still `RUNNING`)
5+
* while the migration proceeds in the background. Only a `RUNNING` run can migrate: finished is `403`
6+
* `job-finished`, `READY`/`ABORTING` is `400` `invalid-request`.
7+
*/
8+
import type { Router } from 'express';
9+
10+
import { requireUser } from '../auth.js';
11+
import { sendData } from '../envelope.js';
12+
import { invalidRequest, jobAlreadyFinished, recordNotFound } from '../errors.js';
13+
import { h } from '../handler.js';
14+
import { isTerminalJobStatus } from '../../services/job-status.js';
15+
import { getOwnedRun } from '../../services/runs.js';
16+
import { migrateRun } from '../../services/migrations.js';
17+
import { runDto } from '../dto/actors.js';
18+
import type { ApiServerDeps } from '../server.js';
19+
20+
/** Mounts the `/migrate/:runId` route, matching `mountDevFolder`'s convention. */
21+
export function mountMigrate(router: Router, deps: ApiServerDeps): void {
22+
router.post(
23+
'/migrate/:runId',
24+
h(async (req, res) => {
25+
const user = requireUser(req);
26+
const run = await getOwnedRun(user.id, req.params.runId as string);
27+
if (!run) throw recordNotFound();
28+
if (isTerminalJobStatus(run.status)) throw jobAlreadyFinished();
29+
30+
const result = await migrateRun(deps.driver, run);
31+
if (result === 'not-running') {
32+
// Non-terminal but without a container to migrate (READY, ABORTING).
33+
throw invalidRequest(`Only a RUNNING run can be migrated (current status: ${run.status})`);
34+
}
35+
36+
// Read back after the migration started, like abort/reboot return the post-write record.
37+
const current = await getOwnedRun(user.id, run.id);
38+
sendData(res, runDto(current ?? run));
39+
}),
40+
);
41+
}

src/api/routes/runs.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import type { Router } from 'express';
33
import { requireUser } from '../auth.js';
44

55
import { paginate, sendData, sortByTimestamp } from '../envelope.js';
6-
import { cannotRemoveRunningRun, recordNotFound } from '../errors.js';
6+
import { cannotRemoveRunningRun, jobAlreadyFinished, recordNotFound } from '../errors.js';
77
import { h, paginationParams, queryBoolean } from '../handler.js';
88
import { abortRun, deleteRun, getOwnedRun, listOwnedRuns } from '../../services/runs.js';
9+
import { rebootRun } from '../../services/migrations.js';
910
import { isTerminalJobStatus } from '../../services/job-status.js';
1011
import { runDto } from '../dto/actors.js';
1112
import type { ApiServerDeps } from '../server.js';
@@ -61,6 +62,19 @@ export function mountRuns(router: Router, deps: ApiServerDeps): void {
6162
}),
6263
);
6364

65+
router.post(
66+
'/actor-runs/:runId/reboot',
67+
h(async (req, res) => {
68+
const run = await getOwnedRun(requireUser(req).id, req.params.runId as string);
69+
if (!run) throw recordNotFound();
70+
// Mirrors `apify-core`'s reboot route: a finished run is rejected 403 job-finished; anything
71+
// non-terminal is accepted. The SDKs call this endpoint from their default `migrating` handler.
72+
if (isTerminalJobStatus(run.status)) throw jobAlreadyFinished();
73+
const updated = await rebootRun(deps.driver, run);
74+
sendData(res, runDto(updated ?? run));
75+
}),
76+
);
77+
6478
router.get(
6579
'/actor-runs/:runId/log',
6680
h(async (req, res) => serveLog(req, res, req.params.runId as string)),

src/api/server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { mountRuns } from './routes/runs.js';
1414
import { mountLogs } from './routes/logs.js';
1515
import { mountRunStorageAliases } from './routes/run-storage-aliases.js';
1616
import { mountDevFolder } from './routes/dev-folder.js';
17+
import { mountMigrate } from './routes/migrate.js';
1718
import { mountApiFallback } from './routes/api-fallback.js';
1819
import { attemptFallback, type LocalError } from '../services/api-fallback.js';
1920
import type { Driver } from '../driver/types.js';
@@ -42,12 +43,13 @@ export function createApiServer(deps: ApiServerDeps): Express {
4243
// `/actor-runtime/*` - a deliberately non-Apify, local-runtime-only namespace (`api.md`), registered
4344
// before the `v2` router (and its own `auth()`) below entirely, so it gets its own sub-router with its
4445
// own `auth()` rather than inheriting `v2.use(auth())`. Registered once here, shared by every route
45-
// module mounted on this router (`mountDevFolder`, `mountApiFallback`) rather than each registering
46+
// module mounted on this router (`mountDevFolder`, `mountMigrate`, `mountApiFallback`) rather than each registering
4647
// its own - they are the same router instance, so a second registration would just run `auth()`
4748
// twice per request for no benefit.
4849
const actorRuntime = express.Router();
4950
actorRuntime.use(auth());
5051
mountDevFolder(actorRuntime, deps);
52+
mountMigrate(actorRuntime, deps);
5153
mountApiFallback(actorRuntime);
5254
app.use('/actor-runtime', actorRuntime);
5355
// Also served at `/v2/actor-runtime/*` - the *same* router instance, no duplicated route logic - solely

src/api/spec-table.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export const SPEC_TABLE: SpecTableEntry[] = [
9191
pathTemplate('GET', 'v2/actor-runs/:runId', true),
9292
pathTemplate('DELETE', 'v2/actor-runs/:runId', true),
9393
pathTemplate('POST', 'v2/actor-runs/:runId/abort', true),
94+
pathTemplate('POST', 'v2/actor-runs/:runId/reboot', true),
9495
pathTemplate('GET', 'v2/actor-runs/:runId/log', true),
9596

9697
// --- Datasets ---

0 commit comments

Comments
 (0)