166 lines
5.7 KiB
Python
166 lines
5.7 KiB
Python
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Callable, Dict, Optional, Tuple
|
|
|
|
from ray.autoscaler._private.constants import (
|
|
AUTOSCALER_NODE_AVAILABILITY_MAX_STALENESS_S,
|
|
)
|
|
from ray.autoscaler.node_launch_exception import NodeLaunchException
|
|
|
|
|
|
@dataclass
|
|
class UnavailableNodeInformation:
|
|
category: str
|
|
description: str
|
|
|
|
|
|
@dataclass
|
|
class NodeAvailabilityRecord:
|
|
node_type: str
|
|
is_available: bool
|
|
last_checked_timestamp: float
|
|
unavailable_node_information: Optional[UnavailableNodeInformation]
|
|
|
|
|
|
@dataclass
|
|
class NodeAvailabilitySummary:
|
|
node_availabilities: Dict[
|
|
str, NodeAvailabilityRecord
|
|
] # Mapping from node type to node availability record.
|
|
|
|
@classmethod
|
|
def from_fields(cls, **fields) -> Optional["NodeAvailabilitySummary"]:
|
|
"""Implement marshalling from nested fields. pydantic isn't a core dependency
|
|
so we're implementing this by hand instead."""
|
|
parsed = {}
|
|
|
|
node_availabilites_dict = fields.get("node_availabilities", {})
|
|
|
|
for node_type, node_availability_record_dict in node_availabilites_dict.items():
|
|
unavailable_information_dict = node_availability_record_dict.pop(
|
|
"unavailable_node_information", None
|
|
)
|
|
unavaiable_information = None
|
|
if unavailable_information_dict is not None:
|
|
unavaiable_information = UnavailableNodeInformation(
|
|
**unavailable_information_dict
|
|
)
|
|
|
|
parsed[node_type] = NodeAvailabilityRecord(
|
|
unavailable_node_information=unavaiable_information,
|
|
**node_availability_record_dict,
|
|
)
|
|
|
|
return NodeAvailabilitySummary(node_availabilities=parsed)
|
|
|
|
def __eq__(self, other: "NodeAvailabilitySummary"):
|
|
return self.node_availabilities == other.node_availabilities
|
|
|
|
def __bool__(self) -> bool:
|
|
return bool(self.node_availabilities)
|
|
|
|
|
|
class NodeProviderAvailabilityTracker:
|
|
"""A thread safe, TTL cache of node provider availability. We don't use
|
|
cachetools.TTLCache because it always sets the expiration time relative to
|
|
insertion time, but in our case, we want entries to expire relative to when
|
|
the node creation was attempted (and entries aren't necessarily added in
|
|
order). We want the entries to expire because the information grows stale
|
|
over time.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
timer: Callable[[], float] = time.time,
|
|
ttl: float = AUTOSCALER_NODE_AVAILABILITY_MAX_STALENESS_S,
|
|
):
|
|
"""A cache that tracks the availability of nodes and throw away
|
|
entries which have grown too stale.
|
|
|
|
Args:
|
|
timer: A function that returns the current time in seconds.
|
|
ttl: The ttl from the insertion timestamp of an entry.
|
|
"""
|
|
self.timer = timer
|
|
self.ttl = ttl
|
|
# Mapping from node type to (eviction_time, record)
|
|
self.store: Dict[str, Tuple[float, NodeAvailabilityRecord]] = {}
|
|
# A global lock to simplify thread safety handling.
|
|
self.lock = threading.RLock()
|
|
|
|
def _update_node_availability_requires_lock(
|
|
self,
|
|
node_type: str,
|
|
timestamp: int,
|
|
node_launch_exception: Optional[NodeLaunchException],
|
|
) -> None:
|
|
if node_launch_exception is None:
|
|
record = NodeAvailabilityRecord(
|
|
node_type=node_type,
|
|
is_available=True,
|
|
last_checked_timestamp=timestamp,
|
|
unavailable_node_information=None,
|
|
)
|
|
else:
|
|
info = UnavailableNodeInformation(
|
|
category=node_launch_exception.category,
|
|
description=node_launch_exception.description,
|
|
)
|
|
record = NodeAvailabilityRecord(
|
|
node_type=node_type,
|
|
is_available=False,
|
|
last_checked_timestamp=timestamp,
|
|
unavailable_node_information=info,
|
|
)
|
|
|
|
expiration_time = timestamp + self.ttl
|
|
|
|
# TODO (Alex): In theory it would be nice to make this dictionary
|
|
# ordered by expiration time, unfortunately that's a bit difficult
|
|
# since `update_node_availability` can be called with out of order
|
|
# timestamps.
|
|
self.store[node_type] = (expiration_time, record)
|
|
|
|
self._remove_old_entries()
|
|
|
|
def update_node_availability(
|
|
self,
|
|
node_type: str,
|
|
timestamp: int,
|
|
node_launch_exception: Optional[NodeLaunchException],
|
|
) -> None:
|
|
"""
|
|
Update the availability and details of a single ndoe type.
|
|
|
|
Args:
|
|
node_type: The node type.
|
|
timestamp: The timestamp that this information is accurate as of.
|
|
node_launch_exception: Details about why the node launch failed. If
|
|
empty, the node type will be considered available."""
|
|
with self.lock:
|
|
self._update_node_availability_requires_lock(
|
|
node_type, timestamp, node_launch_exception
|
|
)
|
|
|
|
def summary(self) -> NodeAvailabilitySummary:
|
|
"""
|
|
Returns a summary of node availabilities and their staleness.
|
|
|
|
Returns
|
|
A summary of node availabilities and their staleness.
|
|
"""
|
|
with self.lock:
|
|
self._remove_old_entries()
|
|
return NodeAvailabilitySummary(
|
|
{node_type: record for node_type, (_, record) in self.store.items()}
|
|
)
|
|
|
|
def _remove_old_entries(self):
|
|
"""Remove any expired entries from the cache."""
|
|
cur_time = self.timer()
|
|
with self.lock:
|
|
for key, (expiration_time, _) in list(self.store.items()):
|
|
if expiration_time < cur_time:
|
|
del self.store[key]
|