From 2469b391ec7ff7a7c6e8389436326eadffb4fab4 Mon Sep 17 00:00:00 2001 From: Nilanjan Chatterjee Date: Sun, 11 Oct 2026 02:30:19 -0500 Subject: [PATCH] fix(dashboard): scale worker table warning colors with worker-ttl The worker table marked a worker red once its last heartbeat was more than 60s old, regardless of distributed.scheduler.worker-ttl. With the default 5 minute TTL that flags healthy-but-busy workers (e.g. ones holding the GIL) long before the scheduler would act, and users had no way to tune it. Color rows orange after 25% and red after 50% of the effective TTL, computed the same way as Scheduler.check_worker_ttl. When worker-ttl is disabled, keep the old 60s red threshold. Closes #8749 Co-Authored-By: Claude Opus 5.5 --- distributed/http/scheduler/info.py | 36 +++++++++++++++- .../scheduler/tests/test_scheduler_http.py | 43 +++++++++++++++++++ distributed/http/templates/worker-table.html | 3 +- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/distributed/http/scheduler/info.py b/distributed/http/scheduler/info.py index 78ad7f89e89..94cfacd4300 100644 --- a/distributed/http/scheduler/info.py +++ b/distributed/http/scheduler/info.py @@ -22,10 +22,44 @@ if TYPE_CHECKING: from distributed import Scheduler + from distributed.scheduler import WorkerState + +#: Row colors in the worker table for workers that have not sent a heartbeat for a +#: while, as (fraction of the worker TTL, color) in decreasing order of fraction. +WORKER_TTL_COLORS = [(0.5, "#ffcdd2"), (0.25, "#ffe0b2")] +#: Threshold in seconds used when ``distributed.scheduler.worker-ttl`` is disabled +WORKER_NO_TTL_THRESHOLD = 60 + + +def worker_row_color(scheduler: Scheduler, ws: WorkerState) -> str | None: + """Background color for a worker in the worker table, or None. + + The thresholds scale with the timeout after which the scheduler removes a + worker that stopped sending heartbeats (``distributed.scheduler.worker-ttl``), + so that the table flags a worker before the scheduler gives up on it. + """ + from distributed.scheduler import heartbeat_interval + + since = time() - ws.last_seen + if not scheduler.worker_ttl: + return WORKER_TTL_COLORS[0][1] if since > WORKER_NO_TTL_THRESHOLD else None + # Same effective TTL as Scheduler.check_worker_ttl + ttl = max(scheduler.worker_ttl, 10 * heartbeat_interval(len(scheduler.workers))) + for fraction, color in WORKER_TTL_COLORS: + if since > fraction * ttl: + return color + return None + ns = { func.__name__: func - for func in [format_bytes, format_time, datetime.fromtimestamp, time] + for func in [ + format_bytes, + format_time, + datetime.fromtimestamp, + time, + worker_row_color, + ] } rel_path_statics = {"rel_path_statics": "../../.."} diff --git a/distributed/http/scheduler/tests/test_scheduler_http.py b/distributed/http/scheduler/tests/test_scheduler_http.py index b7aac70121a..02fdf693d3c 100644 --- a/distributed/http/scheduler/tests/test_scheduler_http.py +++ b/distributed/http/scheduler/tests/test_scheduler_http.py @@ -15,6 +15,7 @@ from distributed import Event, Lock, Scheduler from distributed.client import Client, wait from distributed.core import Status +from distributed.metrics import time from distributed.utils import is_valid_xml, url_escape from distributed.utils_test import ( async_poll_for, @@ -67,6 +68,48 @@ async def test_connect(c, s, a, b): assert not re.search("href=./", body) # no absolute links +async def _worker_table_colors(s): + http_client = AsyncHTTPClient() + response = await http_client.fetch( + f"http://localhost:{s.http_server.port}/info/main/workers.html" + ) + body = response.body.decode() + colors = {} + for row in re.findall(r"]*>\s*]*>[^<]*", body): + addr = re.search(r">([^<]*)$", row).group(1) + color = re.search(r"background-color:(#[0-9a-f]+)", row) + colors[addr] = color.group(1) if color else None + return colors + + +@gen_cluster( + nthreads=[("", 1)] * 3, + worker_kwargs={"heartbeat_interval": "100s"}, + config={"distributed.scheduler.worker-ttl": "100s"}, +) +async def test_worker_table_color_scales_with_worker_ttl(s, a, b, c): + s.workers[b.address].last_seen = time() - 30 + s.workers[c.address].last_seen = time() - 70 + colors = await _worker_table_colors(s) + assert colors == { + a.address: None, + b.address: "#ffe0b2", + c.address: "#ffcdd2", + } + + +@gen_cluster( + nthreads=[("", 1)] * 2, + worker_kwargs={"heartbeat_interval": "100s"}, + config={"distributed.scheduler.worker-ttl": None}, +) +async def test_worker_table_color_without_worker_ttl(s, a, b): + s.workers[a.address].last_seen = time() - 30 + s.workers[b.address].last_seen = time() - 70 + colors = await _worker_table_colors(s) + assert colors == {a.address: None, b.address: "#ffcdd2"} + + @gen_cluster(client=True, nthreads=[]) async def test_worker_404(c, s): http_client = AsyncHTTPClient() diff --git a/distributed/http/templates/worker-table.html b/distributed/http/templates/worker-table.html index 925473f7b2d..70adde25a05 100644 --- a/distributed/http/templates/worker-table.html +++ b/distributed/http/templates/worker-table.html @@ -20,7 +20,8 @@ {% for ws in worker_list %} - 60 else ""}}> + {% set row_color = worker_row_color(scheduler, ws) %} + {{ws.address}} {{ ws.name if ws.name is not None else "" }} {{ ws.nthreads }}