Skip to content
70 changes: 55 additions & 15 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from
import open from 'open';

import { fetchManifest } from '@apify/actor-templates';
import { ACTOR_JOB_STATUSES, ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts';
import {
ACTOR_JOB_STATUSES,
ACTOR_JOB_TERMINAL_STATUSES,
ACTOR_SOURCE_TYPES,
MAX_MULTIFILE_BYTES,
} from '@apify/consts';
import { createHmacSignature } from '@apify/utilities';

import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
Expand All @@ -25,6 +30,7 @@ import {
getLocalUserInfo,
getLoggedClientOrThrow,
outputJobLog,
parseWaitForFinishMillis,
printJsonToStdout,
} from '../../lib/utils.js';

Expand Down Expand Up @@ -190,9 +196,7 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
buildTag = DEFAULT_BUILD_TAG;
}

const waitForFinishMillis = Number.isNaN(this.flags.waitForFinish)
? undefined
: Number.parseInt(this.flags.waitForFinish!, 10) * 1000;
const waitForFinishMillis = parseWaitForFinishMillis(this.flags.waitForFinish);

// User can override actorId of pushing Actor.
// It causes that we push Actor to this id but attributes in localConfig will remain same.
Expand Down Expand Up @@ -359,18 +363,16 @@ Skipping push. Use --force to override.`,
waitForFinish: 2, // NOTE: We need to wait some time to Apify open stream and we can create connection
});

try {
// While the log is streaming, forward interrupt signals to a
// platform-side abort so the build doesn't keep running after the
// user gives up waiting (Ctrl+C, SIGTERM from a parent process,
// SIGHUP from a closing terminal). The `using` binding guarantees
// the listener is removed before we poll for final status.
using _signalHandler = useAbortJobOnSignal({
apifyClient,
kind: 'build',
jobId: build.id,
});
// Forward interrupt signals (Ctrl+C, SIGTERM, SIGHUP) to a platform-side
// abort for the lifetime of log streaming AND status polling, so the
// build doesn't keep running after the user gives up waiting.
using _signalHandler = useAbortJobOnSignal({
apifyClient,
kind: 'build',
jobId: build.id,
});

try {
await outputJobLog({ job: build, timeoutMillis: waitForFinishMillis, apifyClient });
} catch (err) {
warning({ message: 'Can not get log:' });
Expand All @@ -379,6 +381,42 @@ Skipping push. Use --force to override.`,

build = (await apifyClient.build(build.id).get())!;

// `outputJobLog` can return before the build is actually terminal (stream
// ended early, timeout hit). Poll so the status branches below see the
// real outcome. With no --wait-for-finish, the flag documents "waits
// forever", so poll without a deadline.
const deadline = waitForFinishMillis === undefined ? Infinity : Date.now() + waitForFinishMillis;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the deadline should be computed right before starting the build, not after waiting for the outputLog to finalizr/crash.

also, won't this cause a double-wait? Imagine an actor that takes 4minutes to build, if --waitForFinish is set to 30s, we print for 29s then connection dies for the log stream, we wait another 30s after? Ideally we calculate a delta between build start, and log end, and wait out the remaining time, if any, right?

while (!ACTOR_JOB_TERMINAL_STATUSES.includes(build.status as never) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 1000));
build = (await apifyClient.build(build.id).get())!;
}

// Platform updates `taggedBuilds[buildTag]` asynchronously after the
// build finishes. Wait until the tag points at this build so callers
// (including --json automation) that immediately
// `actor.start({ build: buildTag })` don't race it. Capped at 30s so an
// unknown platform delay can't stall push forever.
if (build.status === ACTOR_JOB_STATUSES.SUCCEEDED && buildTag) {
// 30s budget is independent of --wait-for-finish: the build is already
// done, we're only waiting on the platform to update the tag pointer.
const tagDeadline = Date.now() + 30_000;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

30s is wayy too much, i'd bail out after 5 max. Also lets print out a message that we are "applying the build tag" (even tho the console does it) just so users know why their terminal is hanging

let tagApplied = false;
while (Date.now() < tagDeadline) {
const a = await actorClient.get();
if (a?.taggedBuilds?.[buildTag]?.buildId === build.id) {
tagApplied = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!tagApplied) {
warning({
message: `Build succeeded but tag "${buildTag}" did not update within 30s; subsequent calls referencing this tag may race.`,
});
process.exitCode = CommandExitCodes.BuildTimedOut;
}
}

if (this.flags.json) {
printJsonToStdout(build);
return;
Expand All @@ -403,9 +441,11 @@ Skipping push. Use --force to override.`,
// @ts-expect-error FIX THESE TYPES 😢
} else if (build.status === ACTOR_JOB_STATUSES.READY) {
warning({ message: 'Build is waiting for allocation.' });
process.exitCode = CommandExitCodes.BuildTimedOut;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically wrong, if I pass in apify push --waitForFinish=5 it should not give me a non-0 exit code when the timer ran out

// @ts-expect-error FIX THESE TYPES 😢
} else if (build.status === ACTOR_JOB_STATUSES.RUNNING) {
warning({ message: 'Build is still running.' });
process.exitCode = CommandExitCodes.BuildTimedOut;
// @ts-expect-error FIX THESE TYPES 😢
} else if (build.status === ACTOR_JOB_STATUSES.ABORTED || build.status === ACTOR_JOB_STATUSES.ABORTING) {
warning({ message: 'Build was aborted!' });
Expand Down
9 changes: 8 additions & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ export const outputJobLog = async ({
}
});

if (timeoutMillis) {
if (timeoutMillis !== undefined) {
nodeTimeout = setTimeout(() => {
stream.destroy();
resolve('timeouts');
Expand Down Expand Up @@ -840,3 +840,10 @@ export function shellConfigFile(userHomeDirectory: string, shell: ReturnType<typ
}
}
}

export function parseWaitForFinishMillis(flag: string | undefined): number | undefined {
if (flag === undefined) return undefined;
const parsed = Number.parseInt(flag, 10);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmmm, can you compare what happens right now with --waitForFinish=0 vs this PR? It's a use case we should support imo (fire-and-forget)

return parsed * 1000;
}
Loading