Skip to content

fix(executor): enforce TaskMetadata.timeout as a per-attempt max runtime - #7910

Open
shaon-chowdhury-euc wants to merge 3 commits into
flyteorg:mainfrom
shaon-chowdhury-euc:fix/flyte2-max-runtime-enforcement
Open

fix(executor): enforce TaskMetadata.timeout as a per-attempt max runtime#7910
shaon-chowdhury-euc wants to merge 3 commits into
flyteorg:mainfrom
shaon-chowdhury-euc:fix/flyte2-max-runtime-enforcement

Conversation

@shaon-chowdhury-euc

@shaon-chowdhury-euc shaon-chowdhury-euc commented Aug 25, 2026

Copy link
Copy Markdown

Summary

  • Enforce TaskMetadata.timeout (max_runtime) in the TaskAction executor: persist AttemptStartedAt / TimeoutAt, abort and finalize the plugin at the deadline, emit TIMED_OUT, and retry only within the existing attempt budget.
  • Fixes #7901. The SDK already serializes the bound; v2.0.42 ignored it, so a 30s task could run for minutes and finish SUCCEEDED.
  • Do not use activeDeadlineSeconds as a substitute — on current builds the kubelet kill leaves the Pod object behind and the executor recreates it without consuming an attempt.

The bound is enforced against the controller clock. A terminal transition observed before the deadline wins whatever timestamp it carries; from the deadline onward it wins only if TaskInfo.OccurredAt proves it finished in time. A terminal report with no usable timestamp is indistinguishable from an overrun, so the bound takes precedence.

TIMED_OUT was already terminal for condition actions but rare in practice. Two places that special-cased ACTION_PHASE_FAILED are updated to keep behaviour consistent now that tasks reach it: action-detail error resolution and task-group fail rate.

Test plan

Verified:

  • go test -race across ./executor/..., ./actions/..., ./runs/...
  • Controller envtest specs covering: attempt-clock anchoring (plugin-reported start, history bootstrap after an upgrade mid-attempt, rejection of a start time predating the attempt); requeue landing on the exact deadline; terminal-vs-deadline races in both directions; per-attempt clock reset across retries with the full event sequence asserted; retry exhaustion becoming terminal failure; resumption after Abort, Finalize, event-publish and status-write failures; unbounded and zero timeouts left unlimited; invalid durations rejected as InvalidSpec; ordinary failures not misclassified as timeouts; system retries not consuming a user attempt; serialized cache reservations held until terminal.
  • make manifests generate produces no drift; all four CRD copies carry the new status fields.

Not yet verified on a live cluster:

  • A flyte.Timeout(max_runtime=30s) task sleeping 300s, end to end through the SDK.

The SDK already serializes max_runtime onto the TaskAction, but the
executor never read it, so hung tasks ran to success. Abort and finalize
the plugin at the deadline, emit TIMED_OUT, and retry only within the
existing attempt budget.

Closes flyteorg#7901

Signed-off-by: shaon-chowdhury-euc <shaon.chowdhury@eucalyptus.vc>
Anchor the attempt clock on the plugin's reported start rather than
inferring it from PhaseHistory, ignoring reports that predate the
attempt. Collapse three deadline predicates into one, and persist the
resume marker before cleanup.

Fix DeepEqual on *metav1.Time, and resolve error info and fail rate
for TIMED_OUT actions.

Signed-off-by: shaon-chowdhury-euc <shaon.chowdhury@eucalyptus.vc>
@shaon-chowdhury-euc
shaon-chowdhury-euc force-pushed the fix/flyte2-max-runtime-enforcement branch from a13073e to 43a77c7 Compare August 25, 2026 06:37
// service dedupes on insert (see recordEvent), so this stays at-least-once
// end to end rather than pushing the problem onto consumers.
timeoutEvent := r.buildActionEvent(ctx, taskAction, timeoutInfo)
if err := r.recordEvent(ctx, timeoutEvent); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When we record timeout event here, the run service will treat the action as terminal and close UI watch stream here. I think we should set a gate in run service to terminate the stream only if all retries are completed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

agreed, this closes the stream mid-retry. i'd keep the per-attempt TIMED_OUT event though (silent overruns are the bug this fixes) and gate the close in run service instead: only close when the terminal attempt is also the action's current attempt. status.Attempts moves to N+1 in the same reconcile that emits TIMED_OUT(N) so a retrying attempt never satisfies that. will add the gate plus a timeout-then-retry watch test. also want to check the actions table doesn't treat the intermediate TIMED_OUT as sticky-terminal and reject the Queued that follows.

@shaon-chowdhury-euc shaon-chowdhury-euc Sep 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

pushed the gate in b2f437c ie. close only when the action phase is terminal and the last event attempt is terminal and equals status.Attempts (same equality buildActionDetails already uses). added a unit table for both write orderings plus an api test that replays the executor's event-then-row sequence; it fails on the old predicate. also checked the actions-table side: TIMED_OUT is already in retryablePhases so the Queued that follows is accepted.

if deadline, hasDeadline := taskAttemptDeadline(taskAction, maxRuntime); hasDeadline {
var overran bool
if handleErr != nil {
overran = !r.now().Before(deadline)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need this if branch? I think attemptOverran is enough to tell if we are overran when we have handleErr?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

they compute the same thing today (with handleErr set the transition is the zero value, so attemptOverran degenerates to !now.Before(deadline)). the branch exists because transition isn't meaningful when Handle returns an error, and attemptOverran's OccurredAt escape (a terminal result proving it finished before the deadline) only makes sense for a successful Handle. didn't want timeout enforcement depending on what a zero-value Transition happens to return. can inline it behind a comment if you'd rather.

…is done

A timed-out attempt emits a terminal TIMED_OUT event for attempt N
before the action restarts as attempt N+1, and closing on attempt
terminality alone ended the WatchActionDetails stream mid-retry. Close
only when the action's phase is terminal and action_events has caught
up with it: the highest-numbered attempt is terminal and is the
action's current attempt.

Checked the other half of the concern too: the actions-table updater
already lists TIMED_OUT as a retryable phase, so the Queued row that
follows a mid-retry timeout is accepted — no stickiness to fix there.

Signed-off-by: shaon-chowdhury-euc <shaon.chowdhury@eucalyptus.vc>
@shaon-chowdhury-euc
shaon-chowdhury-euc force-pushed the fix/flyte2-max-runtime-enforcement branch from c376411 to b2f437c Compare September 3, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[flyte2] TaskMetadata.timeout (max_runtime) is serialised but never enforced

2 participants