Skip to content

fix: show run as 'running' when task heartbeats active but run heartbeat expired - #485

Open
npow wants to merge 4 commits into
Netflix:masterfrom
npow:fix/run-status-task-heartbeat-fallback
Open

fix: show run as 'running' when task heartbeats active but run heartbeat expired#485
npow wants to merge 4 commits into
Netflix:masterfrom
npow:fix/run-status-task-heartbeat-fallback

Conversation

@npow

@npow npow commented Jun 25, 2026

Copy link
Copy Markdown

Problem

In foreach flows, when a transient failure causes the orchestrator to stop updating the run-level heartbeat but individual tasks continue executing, the Metaflow UI shows the run as failed even though tasks are still running.

Concrete example: CoplayBucketBackfillFlow run with 226 tasks — 1 task hit a transient DNS error (SparkGenieStderrUnknownHost), orchestrator heartbeat lapsed, but 30 tasks were still running. UI showed failed and the running filter returned nothing.

Root Cause

The run status SQL in run.py falls to ELSE 'failed' when the run-level last_heartbeat_ts has exceeded RUN_INACTIVE_CUTOFF_TIME (6 min default), regardless of whether individual tasks are still heartbeating.

Fix

Add an EXISTS fallback that checks task-level heartbeats (using HEARTBEAT_THRESHOLD) before the final ELSE 'failed'. Both status and finished_at are updated consistently:

  • status: returns 'running' if any task has a heartbeat within HEARTBEAT_THRESHOLD
  • finished_at: returns NULL (not yet finished) in the same condition

Also adds a new integration test covering this scenario.

Test

New test: test_run_status_running_when_task_heartbeat_active_and_run_heartbeat_expired

  • Creates a run with an expired run-level heartbeat (7 min old, beyond the 6-min cutoff)
  • Adds a task with a fresh heartbeat
  • Asserts status == 'running' and finished_at is None

🤖 Generated with Claude Code

…artbeat expired

When a run's orchestrator-level heartbeat expires (e.g. after a transient failure
in a foreach coordinator) but individual tasks are still heartbeating, the UI was
incorrectly showing the run as 'failed' even with active tasks.

Fix: add a LEFT JOIN LATERAL that pre-computes max(last_heartbeat_ts) across all
tasks for each run, then use it as a fallback in both the `status` and `finished_at`
CASE expressions before the final ELSE 'failed'. Uses the same RUN_INACTIVE_CUTOFF_TIME
threshold (6 min default) as the run-level heartbeat check for consistency.

The join is computed once per run row and the result referenced from both expressions,
avoiding the O(runs×tasks) cost that an inline correlated EXISTS would incur on list
endpoints (which also call find_records with enable_joins=True).

Reproducer: foreach flow with 200+ tasks where one task hits a transient
infrastructure error; orchestrator heartbeat lapses but remaining tasks continue.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@npow
npow force-pushed the fix/run-status-task-heartbeat-fallback branch from 722e355 to e194bf7 Compare June 25, 2026 22:35
@npow

npow commented Jun 25, 2026

Copy link
Copy Markdown
Author

Addressed all three points — force-pushed.

1. Performance (correlated subquery → lateral join)

Switched from an inline correlated EXISTS to a LEFT JOIN LATERAL that pre-computes max(last_heartbeat_ts) across all tasks for each run. The join result is computed once per row and referenced from both the status and finished_at CASE expressions — no double-scan. Confirmed that get_all_runs uses enable_joins=True, so this path does run on list queries; the lateral join is the right pattern (matches the existing end_attempt_ok / end_attempt joins).

An index on (flow_id, run_number, last_heartbeat_ts) in the task table would make this optimal for deployments with large foreach histories. That's a follow-up.

2. Threshold (HEARTBEAT_THRESHOLDRUN_INACTIVE_CUTOFF_TIME)

Changed to RUN_INACTIVE_CUTOFF_TIME (6 min default) throughout — the task-level check now uses the exact same window as the run-level check. HEARTBEAT_THRESHOLD (60s) was too narrow and would have produced false negatives for slow-heartbeating tasks. Both checks are now symmetric.

3. Skipped test

The skipped test (test_run_status_failed_with_heartbeat_expired_and_failed_task) predates this PR and tests the inverse scenario: run heartbeat still alive, task heartbeat expired → 'failed'. That behavior was removed in a prior refactor (hence the skip). My change doesn't affect or worsen that path. Filing a follow-up to either restore or formally retire that behavior.

npow and others added 3 commits June 25, 2026 23:19
…tics

When a task has an active heartbeat, the run now correctly shows 'running'
even if the run-level heartbeat has expired. Update the test that was
asserting 'failed' in this scenario — it was written under the old
semantics where run heartbeat was the sole signal.

Also apply black formatting to pass pre-commit.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The previous fix was too broad: any run with an expired run heartbeat
would check task heartbeats and potentially show 'running', even for
zombie runs dead for days.

Add a staleness bound (RUN_INACTIVE_CUTOFF_TIME * 10 = 3600s): the task-HB
fallback only fires when the run heartbeat expired recently. A 7-day-old
run heartbeat (604800s >> 3600s) still falls through to 'failed'; a
7-minute-old run heartbeat (420s <= 3600s) gets the task-HB check.

Also revert the test assertion that was incorrectly updated to expect
'running' for the zombie-run scenario — it should remain 'failed'.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…oof of liveness

The staleness bound on the run heartbeat was unnecessary. The lateral JOIN
already constrains to task heartbeats within RUN_INACTIVE_CUTOFF_TIME (360s),
so any task HB that passes the check is genuinely fresh. No scenario exists
where a <6-min-old task HB is wrong evidence that a task is alive.

Restore test assertion to 'running' — the prior 'failed' expectation was
documenting the old buggy behavior that this PR is fixing.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

@saikonen saikonen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No immediate objections to the change, only some performance concerns. If the queries work fine in your environments with a bunch of runs, then it should be fine.

I'm wondering about the premise for the change though, where a run level heartbeat goes stale, but tasks are still running. How common is this? To my understanding, task and run heartbeats update in lockstep from the same source

Comment on lines +72 to +79
"""
LEFT JOIN LATERAL (
SELECT max(last_heartbeat_ts) as last_heartbeat_ts
FROM {task_table} t
WHERE t.flow_id = {table_name}.flow_id
AND t.run_number = {table_name}.run_number
) as latest_task_heartbeat ON true
""".format(table_name=table_name, task_table=task_table),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does this perform well at scale for the runs listing?

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.

I measured this locally on PostgreSQL 11 with 50,000 runs and 1,000,000 tasks (20 per run), using warm-cache EXPLAIN ANALYZE with timing overhead disabled. The current max lookup and task index took 0.190 ms for a 50-row listing and 120.791 ms for a status scan across all 50,000 runs. A partial index on (flow_id, run_number, last_heartbeat_ts DESC) for non-null heartbeats, paired with ORDER BY last_heartbeat_ts DESC LIMIT 1, reduced those to 0.076 ms and 63.821 ms. The ordinary paged listing is already cheap because the outer scan stops at the limit; the index mainly protects status-filter and high-scan cases. I pushed the working version at Aryan95614@9b12cc6. It also moves the task-heartbeat rule into the shared run-status definition used by both ui_backend and metadata-service filtering, so the two services do not classify the same run differently. Full integration result: 135 passed, 13 skipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants