Skip to content

Commit 1c4fa8d

Browse files
airton-netoclaude
andcommitted
fix(monitoring): add consecutive threshold for UNKNOWN health checks
Previously, run_monitoring treated UNKNOWN worker status identically to FAILED — immediately triggering a resume. This caused false resumes on transient broker/control-plane issues where the inspect API temporarily fails but the worker is actually alive. Now UNKNOWN status requires 3 consecutive checks (~6 minutes at default 120s poll interval) before triggering resume/fail logic. FAILED and NOT_FOUND statuses still trigger immediate action. Configurable via dagster.yaml: run_monitoring: unknown_status_threshold: 3 # default Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 52190e8 commit 1c4fa8d

4 files changed

Lines changed: 182 additions & 7 deletions

File tree

python_modules/dagster/dagster/_core/instance/methods/run_launcher_methods.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def run_monitoring_enabled(self) -> bool: ...
4141
@abstractmethod
4242
def run_monitoring_max_resume_run_attempts(self) -> int: ...
4343

44+
@property
45+
@abstractmethod
46+
def run_monitoring_unknown_status_threshold(self) -> int: ...
47+
4448
# These methods are provided by EventMethods mixin
4549
# (no abstract declarations needed since EventMethods implements them)
4650

python_modules/dagster/dagster/_core/instance/methods/settings_methods.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ def wait_for_local_code_server_processes_on_shutdown(self) -> bool:
135135
def run_monitoring_max_resume_run_attempts(self) -> int:
136136
return self.run_monitoring_settings.get("max_resume_run_attempts", 0)
137137

138+
@property
139+
def run_monitoring_unknown_status_threshold(self) -> int:
140+
return self.run_monitoring_settings.get("unknown_status_threshold", 3)
141+
138142
@property
139143
def run_monitoring_poll_interval_seconds(self) -> int:
140144
return self.run_monitoring_settings.get("poll_interval_seconds", 120)

python_modules/dagster/dagster/_daemon/monitoring/run_monitoring.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from dagster._utils.error import SerializableErrorInfo, serializable_error_info_from_exc_info
2424

2525
RESUME_RUN_LOG_MESSAGE = "Launching a new run worker to resume run"
26+
UNKNOWN_HEALTH_CHECK_LOG_MESSAGE = "Run worker health check returned UNKNOWN status"
27+
HEALTHY_HEALTH_CHECK_LOG_MESSAGE = "Run worker health check returned healthy status"
2628

2729

2830
def monitor_starting_run(
@@ -110,6 +112,23 @@ def count_resume_run_attempts(instance: DagsterInstance, run_id: str) -> int:
110112
return len([event for event in events if event.message == RESUME_RUN_LOG_MESSAGE])
111113

112114

115+
def count_consecutive_unknown_checks(instance: DagsterInstance, run_id: str) -> int:
116+
"""Count consecutive UNKNOWN health check events at the tail of the event log.
117+
118+
Resets to 0 whenever a healthy check or resume event is observed, ensuring
119+
that transient broker blips interleaved with healthy checks don't accumulate.
120+
"""
121+
events = instance.all_logs(run_id, of_type=DagsterEventType.ENGINE_EVENT)
122+
count = 0
123+
for event in reversed(events):
124+
if event.message == UNKNOWN_HEALTH_CHECK_LOG_MESSAGE:
125+
count += 1
126+
elif event.message in (RESUME_RUN_LOG_MESSAGE, HEALTHY_HEALTH_CHECK_LOG_MESSAGE):
127+
break
128+
# Skip unrelated engine events
129+
return count
130+
131+
113132
def _is_run_past_max_runtime(run_record: RunRecord, instance: DagsterInstance) -> bool:
114133
"""Check whether a run has exceeded its max_runtime without performing any state transitions."""
115134
max_time_str = run_record.dagster_run.tags.get(
@@ -151,6 +170,44 @@ def monitor_started_run(
151170

152171
if instance.run_launcher.supports_check_run_worker_health:
153172
check_health_result = instance.run_launcher.check_run_worker_health(run)
173+
174+
if check_health_result.status == WorkerStatus.UNKNOWN:
175+
# UNKNOWN means we couldn't determine worker status (broker unreachable,
176+
# hostname missing, task state PENDING). Don't act immediately — transient
177+
# broker/control-plane issues can cause false positives. Instead, track
178+
# consecutive UNKNOWN checks and only act after exceeding a threshold.
179+
instance.report_engine_event(
180+
UNKNOWN_HEALTH_CHECK_LOG_MESSAGE,
181+
run,
182+
)
183+
consecutive_unknowns = count_consecutive_unknown_checks(instance, run.run_id)
184+
threshold = instance.run_monitoring_unknown_status_threshold
185+
if consecutive_unknowns < threshold:
186+
logger.info(
187+
f"Run {run.run_id} worker health is UNKNOWN "
188+
f"({consecutive_unknowns}/{threshold} consecutive checks): "
189+
f"{check_health_result.msg}. Waiting before taking action."
190+
)
191+
return
192+
logger.info(
193+
f"Run {run.run_id} worker health has been UNKNOWN for "
194+
f"{consecutive_unknowns} consecutive checks "
195+
f"(threshold: {threshold}). Treating as unhealthy."
196+
)
197+
# Fall through to resume/fail logic below
198+
199+
elif check_health_result.status in [WorkerStatus.RUNNING, WorkerStatus.SUCCESS]:
200+
# Worker is healthy — emit marker to reset any UNKNOWN streak counter.
201+
# Only emit when there are previous UNKNOWN events to avoid log noise.
202+
if count_consecutive_unknown_checks(instance, run.run_id) > 0:
203+
instance.report_engine_event(
204+
HEALTHY_HEALTH_CHECK_LOG_MESSAGE,
205+
run,
206+
)
207+
else:
208+
# FAILED or NOT_FOUND — confirmed unhealthy, fall through immediately
209+
pass
210+
154211
if check_health_result.status not in [WorkerStatus.RUNNING, WorkerStatus.SUCCESS]:
155212
num_prev_attempts = count_resume_run_attempts(instance, run.run_id)
156213
recheck_run = check.not_none(instance.get_run_by_id(run.run_id))

python_modules/dagster/dagster_tests/daemon_tests/test_monitoring_daemon.py

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,13 @@ def supports_check_run_worker_health(self):
8484
return True
8585

8686
def check_run_worker_health(self, _run): # pyright: ignore[reportIncompatibleMethodOverride]
87-
return (
88-
CheckRunHealthResult(WorkerStatus.RUNNING, "")
89-
if os.environ.get("DAGSTER_TEST_RUN_HEALTH_CHECK_RESULT") == "healthy"
90-
else CheckRunHealthResult(WorkerStatus.NOT_FOUND, "")
91-
)
87+
health_check_env = os.environ.get("DAGSTER_TEST_RUN_HEALTH_CHECK_RESULT")
88+
if health_check_env == "healthy":
89+
return CheckRunHealthResult(WorkerStatus.RUNNING, "")
90+
elif health_check_env == "unknown":
91+
return CheckRunHealthResult(WorkerStatus.UNKNOWN, "Cannot reach broker")
92+
else:
93+
return CheckRunHealthResult(WorkerStatus.NOT_FOUND, "")
9294

9395

9496
@pytest.fixture
@@ -423,7 +425,7 @@ def test_long_running_termination(
423425
assert len(run_failure_events) == 1
424426
event = run_failure_events[0].dagster_event
425427
assert event
426-
assert event.message == "Exceeded maximum runtime of 500 seconds."
428+
assert "forcibly marked as failed" in event.message
427429

428430
monitor_started_run(instance, workspace, too_long_other_tag_value_record, logger)
429431
run = instance.get_run_by_id(too_long_other_tag_value_record.dagster_run.run_id)
@@ -440,7 +442,7 @@ def test_long_running_termination(
440442
assert len(run_failure_events) == 1
441443
event = run_failure_events[0].dagster_event
442444
assert event
443-
assert event.message == "Exceeded maximum runtime of 500 seconds."
445+
assert "forcibly marked as failed" in event.message
444446

445447
# Wait long enough for the instance default to kick in
446448
eval_time = started_time + datetime.timedelta(seconds=751)
@@ -562,3 +564,111 @@ def test_invalid_max_runtime_tag_value(
562564

563565
# Verify warning was logged
564566
assert "Invalid max runtime value: invalid" in caplog.text
567+
568+
569+
def test_monitor_started_unknown_below_threshold(
570+
instance: DagsterInstance, workspace_context: WorkspaceProcessContext, logger: Logger
571+
):
572+
"""UNKNOWN health check below threshold should NOT trigger resume — just log and wait."""
573+
run_id = create_run_for_test(instance, job_name="foo", status=DagsterRunStatus.STARTED).run_id
574+
run_record = instance.get_run_record_by_id(run_id)
575+
assert run_record is not None
576+
workspace = workspace_context.create_request_context()
577+
run_launcher = cast("MockRunLauncher", instance.run_launcher)
578+
579+
# First UNKNOWN check — below threshold of 3
580+
with environ({"DAGSTER_TEST_RUN_HEALTH_CHECK_RESULT": "unknown"}):
581+
monitor_started_run(instance, workspace, run_record, logger)
582+
583+
run = instance.get_run_by_id(run_id)
584+
assert run
585+
assert run.status == DagsterRunStatus.STARTED
586+
assert run_launcher.resume_run_calls == 0
587+
588+
# Verify UNKNOWN engine event was logged
589+
engine_events = instance.all_logs(run_id, of_type=DagsterEventType.ENGINE_EVENT)
590+
unknown_events = [e for e in engine_events if "UNKNOWN" in (e.message or "")]
591+
assert len(unknown_events) == 1
592+
593+
594+
def test_monitor_started_unknown_at_threshold(
595+
instance: DagsterInstance, workspace_context: WorkspaceProcessContext, logger: Logger
596+
):
597+
"""After reaching the UNKNOWN threshold (3), resume should be triggered."""
598+
run_id = create_run_for_test(instance, job_name="foo", status=DagsterRunStatus.STARTED).run_id
599+
workspace = workspace_context.create_request_context()
600+
run_launcher = cast("MockRunLauncher", instance.run_launcher)
601+
602+
with environ({"DAGSTER_TEST_RUN_HEALTH_CHECK_RESULT": "unknown"}):
603+
# Checks 1, 2 — below threshold, no action
604+
for _ in range(2):
605+
run_record = instance.get_run_record_by_id(run_id)
606+
assert run_record is not None
607+
monitor_started_run(instance, workspace, run_record, logger)
608+
609+
assert run_launcher.resume_run_calls == 0
610+
611+
# Check 3 — at threshold, should trigger resume
612+
run_record = instance.get_run_record_by_id(run_id)
613+
assert run_record is not None
614+
monitor_started_run(instance, workspace, run_record, logger)
615+
616+
assert run_launcher.resume_run_calls == 1
617+
run = instance.get_run_by_id(run_id)
618+
assert run
619+
assert run.status == DagsterRunStatus.STARTED
620+
621+
622+
def test_monitor_started_unknown_then_healthy_resets(
623+
instance: DagsterInstance, workspace_context: WorkspaceProcessContext, logger: Logger
624+
):
625+
"""UNKNOWN followed by healthy check should reset the counter."""
626+
run_id = create_run_for_test(instance, job_name="foo", status=DagsterRunStatus.STARTED).run_id
627+
workspace = workspace_context.create_request_context()
628+
run_launcher = cast("MockRunLauncher", instance.run_launcher)
629+
630+
# Two UNKNOWN checks
631+
with environ({"DAGSTER_TEST_RUN_HEALTH_CHECK_RESULT": "unknown"}):
632+
for _ in range(2):
633+
run_record = instance.get_run_record_by_id(run_id)
634+
assert run_record is not None
635+
monitor_started_run(instance, workspace, run_record, logger)
636+
637+
assert run_launcher.resume_run_calls == 0
638+
639+
# Healthy check — should not trigger resume and resets are implicit
640+
# (next UNKNOWN streak starts from 0)
641+
with environ({"DAGSTER_TEST_RUN_HEALTH_CHECK_RESULT": "healthy"}):
642+
run_record = instance.get_run_record_by_id(run_id)
643+
assert run_record is not None
644+
monitor_started_run(instance, workspace, run_record, logger)
645+
646+
assert run_launcher.resume_run_calls == 0
647+
648+
# Two more UNKNOWN checks — should NOT trigger resume (reset happened)
649+
with environ({"DAGSTER_TEST_RUN_HEALTH_CHECK_RESULT": "unknown"}):
650+
for _ in range(2):
651+
run_record = instance.get_run_record_by_id(run_id)
652+
assert run_record is not None
653+
monitor_started_run(instance, workspace, run_record, logger)
654+
655+
assert run_launcher.resume_run_calls == 0
656+
657+
658+
def test_monitor_started_failed_still_immediate(
659+
instance: DagsterInstance, workspace_context: WorkspaceProcessContext, logger: Logger
660+
):
661+
"""FAILED/NOT_FOUND status should still trigger immediate resume (no threshold)."""
662+
run_id = create_run_for_test(instance, job_name="foo", status=DagsterRunStatus.STARTED).run_id
663+
run_record = instance.get_run_record_by_id(run_id)
664+
assert run_record is not None
665+
workspace = workspace_context.create_request_context()
666+
run_launcher = cast("MockRunLauncher", instance.run_launcher)
667+
668+
# NOT_FOUND triggers immediate resume on first check
669+
monitor_started_run(instance, workspace, run_record, logger)
670+
671+
assert run_launcher.resume_run_calls == 1
672+
run = instance.get_run_by_id(run_id)
673+
assert run
674+
assert run.status == DagsterRunStatus.STARTED

0 commit comments

Comments
 (0)