Skip to content

Commit 1cea431

Browse files
authored
🐛🎨External Clusters: improve work stealing (#5474)
1 parent 1588bd2 commit 1cea431

File tree

16 files changed

+134
-50
lines changed

16 files changed

+134
-50
lines changed

.env-devel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ CATALOG_SERVICES_DEFAULT_SPECIFICATIONS='{}'
3636

3737
CLUSTERS_KEEPER_COMPUTATIONAL_BACKEND_DOCKER_IMAGE_TAG=master-github-latest
3838
CLUSTERS_KEEPER_DASK_NTHREADS=0
39+
CLUSTERS_KEEPER_DASK_WORKER_SATURATION=inf
3940
CLUSTERS_KEEPER_EC2_ACCESS=null
4041
CLUSTERS_KEEPER_MAX_MISSED_HEARTBEATS_BEFORE_CLUSTER_TERMINATION=5
4142
CLUSTERS_KEEPER_PRIMARY_EC2_INSTANCES=null

Makefile

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -523,14 +523,13 @@ nodenv: node_modules ## builds node_modules local environ (TODO)
523523

524524
pylint: ## python linting
525525
# pylint version info
526-
@/bin/bash -c "pylint --version"
526+
@pylint --version
527527
# Running linter in packages and services (except director)
528528
@folders=$$(find $(CURDIR)/services $(CURDIR)/packages -type d -not -path "*/director/*" -name 'src' -exec dirname {} \; | sort -u); \
529529
exit_status=0; \
530530
for folder in $$folders; do \
531-
pushd "$$folder"; \
532-
make pylint || exit_status=1; \
533-
popd; \
531+
echo "Linting $$folder"; \
532+
$(MAKE_C) "$$folder" pylint || exit_status=1; \
534533
done;\
535534
exit $$exit_status
536535
# Running linter elsewhere

packages/settings-library/src/settings_library/base.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
)
1414
from pydantic.error_wrappers import ErrorList, ErrorWrapper
1515
from pydantic.fields import ModelField, Undefined
16+
from pydantic.typing import is_literal_type
1617

17-
logger = logging.getLogger(__name__)
18+
_logger = logging.getLogger(__name__)
1819

1920
_DEFAULTS_TO_NONE_MSG: Final[
2021
str
@@ -39,7 +40,7 @@ def _default_factory():
3940
except ValidationError as err:
4041
if field.allow_none:
4142
# e.g. Optional[PostgresSettings] would warn if defaults to None
42-
logger.warning(
43+
_logger.warning(
4344
_DEFAULTS_TO_NONE_MSG,
4445
field.name,
4546
)
@@ -101,8 +102,14 @@ def prepare_field(cls, field: ModelField) -> None:
101102
is_not_composed = (
102103
get_origin(field_type) is None
103104
) # is not composed as dict[str, Any] or Generic[Base]
104-
105-
if is_not_composed and issubclass(field_type, BaseCustomSettings):
105+
# avoid literals raising TypeError
106+
is_not_literal = is_literal_type(field.type_) is False
107+
108+
if (
109+
is_not_literal
110+
and is_not_composed
111+
and issubclass(field_type, BaseCustomSettings)
112+
):
106113
if auto_default_from_env:
107114
assert field.field_info.default is Undefined
108115
assert field.field_info.default_factory is None
@@ -112,7 +119,11 @@ def prepare_field(cls, field: ModelField) -> None:
112119
field.default = None
113120
field.required = False # has a default now
114121

115-
elif is_not_composed and issubclass(field_type, BaseSettings):
122+
elif (
123+
is_not_literal
124+
and is_not_composed
125+
and issubclass(field_type, BaseSettings)
126+
):
116127
msg = f"{cls}.{field.name} of type {field_type} must inherit from BaseCustomSettings"
117128
raise ConfigError(msg)
118129

packages/settings-library/tests/test_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# pylint: disable=unused-argument
33
# pylint: disable=unused-variable
44
# pylint: disable=too-many-arguments
5+
# pylint: disable=protected-access
56

67
import inspect
78
import json
@@ -196,7 +197,7 @@ def test_auto_default_to_none_logs_a_warning(
196197
create_settings_class: Callable[[str], type[BaseCustomSettings]],
197198
mocker: MockerFixture,
198199
):
199-
logger_warn = mocker.spy(settings_library.base.logger, "warning")
200+
logger_warn = mocker.spy(settings_library.base._logger, "warning") # noqa: SLF001
200201

201202
S = create_settings_class("S")
202203

services/autoscaling/src/simcore_service_autoscaling/modules/auto_scaling_mode_computational.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,30 @@ async def compute_node_used_resources(
115115
app: FastAPI, instance: AssociatedInstance
116116
) -> Resources:
117117
try:
118-
num_results_in_memory = await dask.get_worker_still_has_results_in_memory(
118+
resource = await dask.get_worker_used_resources(
119119
_scheduler_url(app), _scheduler_auth(app), instance.ec2_instance
120120
)
121-
if num_results_in_memory > 0:
122-
# NOTE: this is a trick to consider the node still useful
123-
return Resources(cpus=0, ram=ByteSize(1024 * 1024 * 1024))
124-
return await dask.get_worker_used_resources(
125-
_scheduler_url(app), _scheduler_auth(app), instance.ec2_instance
121+
if resource == Resources.create_as_empty():
122+
num_results_in_memory = (
123+
await dask.get_worker_still_has_results_in_memory(
124+
_scheduler_url(app), _scheduler_auth(app), instance.ec2_instance
125+
)
126+
)
127+
if num_results_in_memory > 0:
128+
_logger.debug(
129+
"found %s for %s",
130+
f"{num_results_in_memory=}",
131+
f"{instance.ec2_instance.id}",
132+
)
133+
# NOTE: this is a trick to consider the node still useful
134+
return Resources(cpus=0, ram=ByteSize(1024 * 1024 * 1024))
135+
136+
_logger.debug(
137+
"found %s for %s", f"{resource=}", f"{instance.ec2_instance.id}"
126138
)
139+
return resource
127140
except (DaskWorkerNotFoundError, DaskNoWorkersError):
141+
_logger.debug("no resource found for %s", f"{instance.ec2_instance.id}")
128142
return Resources.create_as_empty()
129143

130144
@staticmethod

services/autoscaling/src/simcore_service_autoscaling/modules/dask.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections
12
import contextlib
23
import logging
34
import re
@@ -217,30 +218,39 @@ async def get_worker_used_resources(
217218
DaskNoWorkersError
218219
"""
219220

220-
def _get_worker_used_resources(
221+
def _list_processing_tasks_on_worker(
221222
dask_scheduler: distributed.Scheduler, *, worker_url: str
222-
) -> dict[str, float] | None:
223-
for worker_name, worker_state in dask_scheduler.workers.items():
224-
if worker_url != worker_name:
225-
continue
226-
if worker_state.status is distributed.Status.closing_gracefully:
227-
# NOTE: when a worker was retired it is in this state
228-
return {}
229-
return dict(worker_state.used_resources)
230-
return None
223+
) -> list[tuple[DaskTaskId, DaskTaskResources]]:
224+
processing_tasks = []
225+
for task_key, task_state in dask_scheduler.tasks.items():
226+
if task_state.processing_on and (
227+
task_state.processing_on.address == worker_url
228+
):
229+
processing_tasks.append((task_key, task_state.resource_restrictions))
230+
return processing_tasks
231231

232232
async with _scheduler_client(scheduler_url, authentication) as client:
233233
worker_url, _ = _dask_worker_from_ec2_instance(client, ec2_instance)
234234

235+
_logger.debug("looking for processing tasksfor %s", f"{worker_url=}")
236+
235237
# now get the used resources
236-
worker_used_resources: dict[str, Any] | None = await _wrap_client_async_routine(
237-
client.run_on_scheduler(_get_worker_used_resources, worker_url=worker_url),
238+
worker_processing_tasks: list[
239+
tuple[DaskTaskId, DaskTaskResources]
240+
] = await _wrap_client_async_routine(
241+
client.run_on_scheduler(
242+
_list_processing_tasks_on_worker, worker_url=worker_url
243+
),
238244
)
239-
if worker_used_resources is None:
240-
raise DaskWorkerNotFoundError(worker_host=worker_url, url=scheduler_url)
245+
246+
total_resources_used: collections.Counter[str] = collections.Counter()
247+
for _, task_resources in worker_processing_tasks:
248+
total_resources_used.update(task_resources)
249+
250+
_logger.debug("found %s for %s", f"{total_resources_used=}", f"{worker_url=}")
241251
return Resources(
242-
cpus=worker_used_resources.get("CPU", 0),
243-
ram=parse_obj_as(ByteSize, worker_used_resources.get("RAM", 0)),
252+
cpus=total_resources_used.get("CPU", 0),
253+
ram=parse_obj_as(ByteSize, total_resources_used.get("RAM", 0)),
244254
)
245255

246256

services/autoscaling/src/simcore_service_autoscaling/utils/utils_docker.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,23 +59,41 @@
5959
_PENDING_DOCKER_TASK_MESSAGE: Final[str] = "pending task scheduling"
6060
_INSUFFICIENT_RESOURCES_DOCKER_TASK_ERR: Final[str] = "insufficient resources on"
6161
_NOT_SATISFIED_SCHEDULING_CONSTRAINTS_TASK_ERR: Final[str] = "no suitable node"
62+
_OSPARC_SERVICE_READY_LABEL_KEY: Final[DockerLabelKey] = parse_obj_as(
63+
DockerLabelKey, "io.simcore.osparc-services-ready"
64+
)
65+
_OSPARC_SERVICES_READY_DATETIME_LABEL_KEY: Final[DockerLabelKey] = parse_obj_as(
66+
DockerLabelKey, f"{_OSPARC_SERVICE_READY_LABEL_KEY}-last-changed"
67+
)
68+
_OSPARC_SERVICE_READY_LABEL_KEYS: Final[list[DockerLabelKey]] = [
69+
_OSPARC_SERVICE_READY_LABEL_KEY,
70+
_OSPARC_SERVICES_READY_DATETIME_LABEL_KEY,
71+
]
6272

6373

6474
async def get_monitored_nodes(
6575
docker_client: AutoscalingDocker, node_labels: list[DockerLabelKey]
6676
) -> list[Node]:
77+
node_label_filters = [f"{label}=true" for label in node_labels] + [
78+
f"{label}" for label in _OSPARC_SERVICE_READY_LABEL_KEYS
79+
]
6780
return parse_obj_as(
6881
list[Node],
69-
await docker_client.nodes.list(
70-
filters={"node.label": [f"{label}=true" for label in node_labels]}
71-
),
82+
await docker_client.nodes.list(filters={"node.label": node_label_filters}),
7283
)
7384

7485

7586
async def get_worker_nodes(docker_client: AutoscalingDocker) -> list[Node]:
7687
return parse_obj_as(
7788
list[Node],
78-
await docker_client.nodes.list(filters={"role": ["worker"]}),
89+
await docker_client.nodes.list(
90+
filters={
91+
"role": ["worker"],
92+
"node.label": [
93+
f"{label}" for label in _OSPARC_SERVICE_READY_LABEL_KEYS
94+
],
95+
}
96+
),
7997
)
8098

8199

@@ -550,14 +568,6 @@ def is_node_ready_and_available(node: Node, *, availability: Availability) -> bo
550568
)
551569

552570

553-
_OSPARC_SERVICE_READY_LABEL_KEY: Final[DockerLabelKey] = parse_obj_as(
554-
DockerLabelKey, "io.simcore.osparc-services-ready"
555-
)
556-
_OSPARC_SERVICES_READY_DATETIME_LABEL_KEY: Final[DockerLabelKey] = parse_obj_as(
557-
DockerLabelKey, f"{_OSPARC_SERVICE_READY_LABEL_KEY}-last-changed"
558-
)
559-
560-
561571
def is_node_osparc_ready(node: Node) -> bool:
562572
if not is_node_ready_and_available(node, availability=Availability.active):
563573
return False

services/autoscaling/tests/unit/test_utils_docker.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from simcore_service_autoscaling.modules.docker import AutoscalingDocker
3838
from simcore_service_autoscaling.utils.utils_docker import (
3939
_OSPARC_SERVICE_READY_LABEL_KEY,
40+
_OSPARC_SERVICES_READY_DATETIME_LABEL_KEY,
4041
Node,
4142
_by_created_dt,
4243
attach_node,
@@ -133,7 +134,13 @@ async def test_get_monitored_nodes_with_valid_label(
133134
create_node_labels: Callable[[list[str]], Awaitable[None]],
134135
):
135136
labels = faker.pylist(allowed_types=(str,))
136-
await create_node_labels(labels)
137+
await create_node_labels(
138+
[
139+
*labels,
140+
_OSPARC_SERVICE_READY_LABEL_KEY,
141+
_OSPARC_SERVICES_READY_DATETIME_LABEL_KEY,
142+
]
143+
)
137144
monitored_nodes = await get_monitored_nodes(autoscaling_docker, node_labels=labels)
138145
assert len(monitored_nodes) == 1
139146

services/clusters-keeper/src/simcore_service_clusters_keeper/core/settings.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import datetime
22
from functools import cached_property
3-
from typing import Any, ClassVar, Final, cast
3+
from typing import Any, ClassVar, Final, Literal, cast
44

55
from aws_library.ec2.models import EC2InstanceBootSpecific, EC2Tags
66
from fastapi import FastAPI
@@ -11,7 +11,14 @@
1111
VersionTag,
1212
)
1313
from models_library.clusters import InternalClusterAuthentication
14-
from pydantic import Field, NonNegativeInt, PositiveInt, parse_obj_as, validator
14+
from pydantic import (
15+
Field,
16+
NonNegativeFloat,
17+
NonNegativeInt,
18+
PositiveInt,
19+
parse_obj_as,
20+
validator,
21+
)
1522
from settings_library.base import BaseCustomSettings
1623
from settings_library.docker_registry import RegistrySettings
1724
from settings_library.ec2 import EC2Settings
@@ -270,7 +277,9 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings):
270277
description="defines the image tag to use for the computational backend sidecar image (NOTE: it currently defaults to use itisfoundation organisation in Dockerhub)",
271278
)
272279

273-
CLUSTERS_KEEPER_COMPUTATIONAL_BACKEND_DEFAULT_CLUSTER_AUTH: InternalClusterAuthentication = Field(
280+
CLUSTERS_KEEPER_COMPUTATIONAL_BACKEND_DEFAULT_CLUSTER_AUTH: (
281+
InternalClusterAuthentication
282+
) = Field(
274283
...,
275284
description="defines the authentication of the clusters created via clusters-keeper (can be None or TLS)",
276285
)
@@ -280,6 +289,12 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings):
280289
description="overrides the default number of threads in the dask-sidecars, setting it to 0 will use the default (see description in dask-sidecar)",
281290
)
282291

292+
CLUSTERS_KEEPER_DASK_WORKER_SATURATION: NonNegativeFloat | Literal["inf"] = Field(
293+
default="inf",
294+
description="override the dask scheduler 'worker-saturation' field"
295+
", see https://selectfrom.dev/deep-dive-into-dask-distributed-scheduler-9fdb3b36b7c7",
296+
)
297+
283298
SWARM_STACK_NAME: str = Field(
284299
..., description="Stack name defined upon deploy (see main Makefile)"
285300
)

services/clusters-keeper/src/simcore_service_clusters_keeper/data/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ services:
1111
DASK_TLS_KEY: ${DASK_TLS_KEY}
1212
DASK_SCHEDULER_URL: tls://dask-scheduler:8786
1313
DASK_START_AS_SCHEDULER: 1
14+
DASK_WORKER_SATURATION: ${DASK_WORKER_SATURATION}
1415
LOG_LEVEL: ${LOG_LEVEL}
1516
ports:
1617
- 8786:8786 # dask-scheduler access
@@ -49,6 +50,7 @@ services:
4950
DASK_TLS_CA_FILE: ${DASK_TLS_CA_FILE}
5051
DASK_TLS_CERT: ${DASK_TLS_CERT}
5152
DASK_TLS_KEY: ${DASK_TLS_KEY}
53+
DASK_WORKER_SATURATION: ${DASK_WORKER_SATURATION}
5254
LOG_LEVEL: ${LOG_LEVEL}
5355
SIDECAR_COMP_SERVICES_SHARED_FOLDER: /home/scu/computational_shared_data
5456
SIDECAR_COMP_SERVICES_SHARED_VOLUME_NAME: computational_shared_data

0 commit comments

Comments
 (0)