chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
import asyncio
|
||||
import random
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent
|
||||
from ray.dashboard.modules.aggregator.multi_consumer_event_buffer import (
|
||||
MultiConsumerEventBuffer,
|
||||
)
|
||||
|
||||
|
||||
def _create_test_event(
|
||||
event_id: bytes = b"test",
|
||||
event_type_enum=RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
message: str = "test message",
|
||||
):
|
||||
"""Helper function to create a test RayEvent."""
|
||||
event = RayEvent()
|
||||
event.event_id = event_id
|
||||
event.source_type = RayEvent.SourceType.CORE_WORKER
|
||||
event.event_type = event_type_enum
|
||||
event.severity = RayEvent.Severity.INFO
|
||||
event.message = message
|
||||
event.session_name = "test_session"
|
||||
|
||||
# Set timestamp
|
||||
timestamp = Timestamp()
|
||||
timestamp.GetCurrentTime()
|
||||
event.timestamp.CopyFrom(timestamp)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
class TestMultiConsumerEventBuffer:
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_and_consume_event_basic(self):
|
||||
"""Test basic event addition."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
assert await buffer.size() == 0
|
||||
|
||||
event = _create_test_event(b"event1")
|
||||
await buffer.add_event(event)
|
||||
|
||||
assert await buffer.size() == 1
|
||||
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0)
|
||||
assert len(batch) == 1
|
||||
assert batch[0] == event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_event_buffer_overflow(self):
|
||||
"""Test buffer overflow behavior and eviction logic."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=3, max_batch_size=2)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
# Add events to fill buffer
|
||||
events = []
|
||||
event_types = [
|
||||
RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
RayEvent.EventType.TASK_LIFECYCLE_EVENT,
|
||||
RayEvent.EventType.ACTOR_TASK_DEFINITION_EVENT,
|
||||
]
|
||||
for i in range(3):
|
||||
event = _create_test_event(f"event{i}".encode(), event_types[i])
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
assert await buffer.size() == 3
|
||||
|
||||
# Add one more event to trigger eviction
|
||||
overflow_event = _create_test_event(
|
||||
b"overflow", RayEvent.EventType.TASK_PROFILE_EVENT
|
||||
)
|
||||
await buffer.add_event(overflow_event)
|
||||
|
||||
assert await buffer.size() == 3 # Still max size
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_batch_multiple_events(self):
|
||||
"""Test waiting for batch when multiple events are immediately available and when when not all events are available."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=3)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
# Add multiple events
|
||||
events = []
|
||||
for i in range(5):
|
||||
event = _create_test_event(f"event{i}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Should get max_batch_size events immediately
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0.1)
|
||||
assert len(batch) == 3 # max_batch_size
|
||||
assert batch == events[:3]
|
||||
# should now get the leftover events (< max_batch_size)
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0.1)
|
||||
assert len(batch) == 2
|
||||
assert batch == events[3:]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_batch_unknown_consumer(self):
|
||||
"""Test error handling for unknown consumer."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
|
||||
with pytest.raises(KeyError, match="unknown consumer"):
|
||||
await buffer.wait_for_batch("nonexistent_consumer", timeout_seconds=0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_consumer_duplicate(self):
|
||||
"""Test error handling for duplicate consumer registration."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
with pytest.raises(
|
||||
ValueError, match="consumer 'test_consumer' already registered"
|
||||
):
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_consumers_independent_cursors(self):
|
||||
"""Test that multiple consumers have independent cursors."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=2)
|
||||
consumer_name_1 = "test_consumer_1"
|
||||
consumer_name_2 = "test_consumer_2"
|
||||
await buffer.register_consumer(consumer_name_1)
|
||||
await buffer.register_consumer(consumer_name_2)
|
||||
|
||||
# Add events
|
||||
events = []
|
||||
for i in range(10):
|
||||
event = _create_test_event(f"event{i}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Consumer 1 reads first batch
|
||||
batch1 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch1 == events[:2]
|
||||
|
||||
# Consumer 2 reads from beginning
|
||||
batch2 = await buffer.wait_for_batch(consumer_name_2, timeout_seconds=0.1)
|
||||
assert batch2 == events[:2]
|
||||
|
||||
# consumer 1 reads another batch
|
||||
batch3 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch3 == events[2:4]
|
||||
|
||||
# more events are added leading to events not consumed by consumer 2 getting evicted
|
||||
# 4 events get evicted, consumer 1 has processed all 4 evicted events previously
|
||||
# but consumer 2 has only processed 2 out of the 4 evicted events
|
||||
for i in range(4):
|
||||
event = _create_test_event(f"event{i + 10}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Just ensure buffer remains at max size
|
||||
assert await buffer.size() == 10
|
||||
|
||||
# consumer 1 will read the next 2 events, not affected by the evictions
|
||||
# consumer 1's cursor is adjusted internally to account for the evicted events
|
||||
batch4 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch4 == events[4:6]
|
||||
|
||||
# consumer 2 will read 2 events, skipping the evicted events
|
||||
batch5 = await buffer.wait_for_batch(consumer_name_2, timeout_seconds=0.1)
|
||||
assert batch5 == events[4:6] # events[2:4] are lost
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_batch_blocks_until_event_available(self):
|
||||
"""Test that wait_for_batch blocks until at least one event is available."""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=5)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
# Start waiting for batch (should block)
|
||||
async def wait_for_batch():
|
||||
return await buffer.wait_for_batch(consumer_name, timeout_seconds=2.0)
|
||||
|
||||
wait_task = asyncio.create_task(wait_for_batch())
|
||||
|
||||
# Wait a bit to ensure the task is waiting
|
||||
await asyncio.sleep(4.0)
|
||||
assert not wait_task.done()
|
||||
|
||||
# Add an event
|
||||
event = _create_test_event(b"event1")
|
||||
await buffer.add_event(event)
|
||||
|
||||
# Now the task should complete
|
||||
batch = await wait_task
|
||||
assert len(batch) == 1
|
||||
assert batch[0] == event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_producer_consumer_random_sleeps_with_overall_timeout(
|
||||
self,
|
||||
):
|
||||
"""Producer with random sleeps and consumer reading until all events are received.
|
||||
|
||||
Uses an overall asyncio timeout to ensure the test fails if it hangs
|
||||
before consuming all events.
|
||||
"""
|
||||
total_events = 40
|
||||
max_batch_size = 2
|
||||
buffer = MultiConsumerEventBuffer(max_size=100, max_batch_size=max_batch_size)
|
||||
consumer_name = "test_consumer"
|
||||
await buffer.register_consumer(consumer_name)
|
||||
|
||||
produced_events = []
|
||||
consumed_events = []
|
||||
|
||||
random.seed(0)
|
||||
|
||||
async def producer():
|
||||
for i in range(total_events):
|
||||
event = _create_test_event(f"e{i}".encode())
|
||||
produced_events.append(event)
|
||||
await buffer.add_event(event)
|
||||
await asyncio.sleep(random.uniform(0.0, 0.02))
|
||||
|
||||
async def consumer():
|
||||
while len(consumed_events) < total_events:
|
||||
batch = await buffer.wait_for_batch(consumer_name, timeout_seconds=0.1)
|
||||
consumed_events.extend(batch)
|
||||
|
||||
# The test should fail if this times out before all events are consumed
|
||||
await asyncio.wait_for(asyncio.gather(producer(), consumer()), timeout=5.0)
|
||||
|
||||
assert len(consumed_events) == total_events
|
||||
assert consumed_events == produced_events
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_are_evicted_once_consumed_by_all_consumers(self):
|
||||
"""Test events are evicted from the buffer once they are consumed by all consumers"""
|
||||
buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=2)
|
||||
consumer_name_1 = "test_consumer_1"
|
||||
consumer_name_2 = "test_consumer_2"
|
||||
await buffer.register_consumer(consumer_name_1)
|
||||
await buffer.register_consumer(consumer_name_2)
|
||||
|
||||
# Add events
|
||||
events = []
|
||||
for i in range(10):
|
||||
event = _create_test_event(f"event{i}".encode())
|
||||
events.append(event)
|
||||
await buffer.add_event(event)
|
||||
|
||||
assert await buffer.size() == 10
|
||||
# Consumer 1 reads first batch
|
||||
batch1 = await buffer.wait_for_batch(consumer_name_1, timeout_seconds=0.1)
|
||||
assert batch1 == events[:2]
|
||||
|
||||
# buffer size does not change as consumer 2 is yet to consume these events
|
||||
assert await buffer.size() == 10
|
||||
|
||||
# Consumer 2 reads from beginning
|
||||
batch2 = await buffer.wait_for_batch(consumer_name_2, timeout_seconds=0.1)
|
||||
assert batch2 == events[:2]
|
||||
|
||||
# size reduces by 2 as both consumers have consumed 2 events
|
||||
assert await buffer.size() == 8
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,125 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_ACTOR_EVENT_PORT = 12346
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _ACTOR_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_actor_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_ACTOR_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "ACTOR_DEFINITION_EVENT,ACTOR_LIFECYCLE_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
head_node_id = cluster.head_node.node_id
|
||||
all_nodes_ids = [node.node_id for node in cluster.list_all_nodes()]
|
||||
|
||||
class A:
|
||||
def ping(self):
|
||||
return "pong"
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Create an actor to trigger definition + lifecycle events
|
||||
a = ray.remote(A).options(name="actor-test").remote()
|
||||
ray.get(a.ping.remote())
|
||||
|
||||
# Check that an actor definition and a lifecycle event are published.
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1)
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
# We expect batched events containing definition then lifecycle
|
||||
assert len(req_json) >= 2
|
||||
# Verify event types and IDs exist
|
||||
assert (
|
||||
base64.b64decode(req_json[0]["actorDefinitionEvent"]["actorId"]).hex()
|
||||
== a._actor_id.hex()
|
||||
)
|
||||
assert base64.b64decode(req_json[0]["nodeId"]).hex() == head_node_id
|
||||
# Verify ActorId and state for ActorLifecycleEvents
|
||||
has_alive_state = False
|
||||
for actorLifeCycleEvent in req_json[1:]:
|
||||
assert base64.b64decode(actorLifeCycleEvent["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(
|
||||
actorLifeCycleEvent["actorLifecycleEvent"]["actorId"]
|
||||
).hex()
|
||||
== a._actor_id.hex()
|
||||
)
|
||||
for stateTransition in actorLifeCycleEvent["actorLifecycleEvent"][
|
||||
"stateTransitions"
|
||||
]:
|
||||
assert stateTransition["state"] in [
|
||||
"DEPENDENCIES_UNREADY",
|
||||
"PENDING_CREATION",
|
||||
"ALIVE",
|
||||
"RESTARTING",
|
||||
"DEAD",
|
||||
]
|
||||
if stateTransition["state"] == "ALIVE":
|
||||
has_alive_state = True
|
||||
assert (
|
||||
base64.b64decode(stateTransition["nodeId"]).hex() in all_nodes_ids
|
||||
)
|
||||
assert base64.b64decode(stateTransition["workerId"]).hex() != ""
|
||||
assert has_alive_state
|
||||
|
||||
# Kill the actor and verify we get a DEAD state with death cause
|
||||
ray.kill(a)
|
||||
|
||||
# Wait for the death event to be published
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 2)
|
||||
|
||||
has_dead_state = False
|
||||
for death_req, _ in httpserver.log:
|
||||
death_req_json = json.loads(death_req.data)
|
||||
|
||||
for actorLifeCycleEvent in death_req_json:
|
||||
if "actorLifecycleEvent" in actorLifeCycleEvent:
|
||||
assert (
|
||||
base64.b64decode(
|
||||
actorLifeCycleEvent["actorLifecycleEvent"]["actorId"]
|
||||
).hex()
|
||||
== a._actor_id.hex()
|
||||
)
|
||||
|
||||
for stateTransition in actorLifeCycleEvent["actorLifecycleEvent"][
|
||||
"stateTransitions"
|
||||
]:
|
||||
if stateTransition["state"] == "DEAD":
|
||||
has_dead_state = True
|
||||
assert (
|
||||
stateTransition["deathCause"]["actorDiedErrorContext"][
|
||||
"reason"
|
||||
]
|
||||
== "RAY_KILL"
|
||||
)
|
||||
|
||||
assert has_dead_state
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from ray._common.test_utils import async_wait_for_condition
|
||||
from ray.core.generated import events_base_event_pb2
|
||||
from ray.dashboard.modules.aggregator.multi_consumer_event_buffer import (
|
||||
MultiConsumerEventBuffer,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.async_publisher_client import (
|
||||
PublisherClientInterface,
|
||||
PublishStats,
|
||||
)
|
||||
from ray.dashboard.modules.aggregator.publisher.ray_event_publisher import (
|
||||
NoopPublisher,
|
||||
RayEventPublisher,
|
||||
)
|
||||
|
||||
|
||||
class MockPublisherClient(PublisherClientInterface):
|
||||
"""Test implementation of PublisherClientInterface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
batch_size: int = 1,
|
||||
side_effect=lambda batch: PublishStats(True, 1, 0),
|
||||
):
|
||||
self.batch_size = batch_size
|
||||
self.publish_calls = []
|
||||
self._side_effect = side_effect
|
||||
|
||||
async def publish(self, batch) -> PublishStats:
|
||||
self.publish_calls.append(batch)
|
||||
return self._side_effect(batch)
|
||||
|
||||
def count_num_events_in_batch(self, batch) -> int:
|
||||
return self.batch_size
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_kwargs():
|
||||
"""Common kwargs for publisher initialization."""
|
||||
return {
|
||||
"name": "test",
|
||||
"max_retries": 2,
|
||||
"initial_backoff": 0,
|
||||
"max_backoff": 0,
|
||||
"jitter_ratio": 0,
|
||||
"enable_publisher_stats": True,
|
||||
}
|
||||
|
||||
|
||||
class TestRayEventPublisher:
|
||||
"""Test the main RayEventsPublisher functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_with_retries_failure_then_success(self, base_kwargs):
|
||||
"""Test publish that fails then succeeds."""
|
||||
call_count = {"count": 0}
|
||||
|
||||
# fail the first publish call but succeed on retry
|
||||
def side_effect(batch):
|
||||
call_count["count"] += 1
|
||||
if call_count["count"] == 1:
|
||||
return PublishStats(False, 0, 0)
|
||||
return PublishStats(True, 1, 0)
|
||||
|
||||
client = MockPublisherClient(side_effect=side_effect)
|
||||
event_buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=10)
|
||||
publisher = RayEventPublisher(
|
||||
name=base_kwargs["name"] + str(uuid.uuid4()),
|
||||
publish_client=client,
|
||||
event_buffer=event_buffer,
|
||||
max_retries=base_kwargs["max_retries"],
|
||||
initial_backoff=base_kwargs["initial_backoff"],
|
||||
max_backoff=base_kwargs["max_backoff"],
|
||||
jitter_ratio=base_kwargs["jitter_ratio"],
|
||||
)
|
||||
|
||||
task = asyncio.create_task(publisher.run_forever())
|
||||
try:
|
||||
# ensure consumer is registered
|
||||
assert await publisher.wait_until_running(2.0)
|
||||
# Enqueue one event into buffer
|
||||
e = events_base_event_pb2.RayEvent(
|
||||
event_id=b"1",
|
||||
source_type=events_base_event_pb2.RayEvent.SourceType.CORE_WORKER,
|
||||
event_type=events_base_event_pb2.RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
timestamp=Timestamp(seconds=123, nanos=0),
|
||||
severity=events_base_event_pb2.RayEvent.Severity.INFO,
|
||||
message="hello",
|
||||
)
|
||||
await event_buffer.add_event(e)
|
||||
|
||||
# wait for two publish attempts (failure then success)
|
||||
await async_wait_for_condition(lambda: len(client.publish_calls) == 2)
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_with_retries_max_retries_exceeded(self, base_kwargs):
|
||||
"""Test publish that fails all retries and records failed events."""
|
||||
client = MockPublisherClient(
|
||||
side_effect=lambda batch: PublishStats(False, 0, 0)
|
||||
)
|
||||
event_buffer = MultiConsumerEventBuffer(max_size=10, max_batch_size=10)
|
||||
publisher = RayEventPublisher(
|
||||
name=base_kwargs["name"] + str(uuid.uuid4()),
|
||||
publish_client=client,
|
||||
event_buffer=event_buffer,
|
||||
max_retries=2, # override to finite retries
|
||||
initial_backoff=0,
|
||||
max_backoff=0,
|
||||
jitter_ratio=0,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(publisher.run_forever())
|
||||
try:
|
||||
# ensure consumer is registered
|
||||
assert await publisher.wait_until_running(2.0)
|
||||
e = events_base_event_pb2.RayEvent(
|
||||
event_id=b"1",
|
||||
source_type=events_base_event_pb2.RayEvent.SourceType.CORE_WORKER,
|
||||
event_type=events_base_event_pb2.RayEvent.EventType.TASK_DEFINITION_EVENT,
|
||||
timestamp=Timestamp(seconds=123, nanos=0),
|
||||
severity=events_base_event_pb2.RayEvent.Severity.INFO,
|
||||
message="hello",
|
||||
)
|
||||
await event_buffer.add_event(e)
|
||||
|
||||
# wait for publish attempts (initial + 2 retries)
|
||||
await async_wait_for_condition(lambda: len(client.publish_calls) == 3)
|
||||
assert len(client.publish_calls) == 3
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
class TestNoopPublisher:
|
||||
"""Test no-op publisher implementation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_methods_noop(self):
|
||||
"""Test that run_forever can be cancelled and metrics return expected values."""
|
||||
publisher = NoopPublisher()
|
||||
|
||||
# Start and cancel run_forever
|
||||
task = asyncio.create_task(publisher.run_forever())
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,58 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_RAY_EVENT_PORT = 12345
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _RAY_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_job_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_RAY_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "DRIVER_JOB_DEFINITION_EVENT,DRIVER_JOB_LIFECYCLE_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Submit a ray job
|
||||
@ray.remote
|
||||
def f():
|
||||
return 1
|
||||
|
||||
ray.get(f.remote())
|
||||
|
||||
# Check that a driver job event with the correct job id is published.
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1)
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
head_node_id = cluster.head_node.node_id
|
||||
assert base64.b64decode(req_json[0]["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(req_json[0]["driverJobDefinitionEvent"]["jobId"]).hex()
|
||||
== ray.get_runtime_context().get_job_id()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,75 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_RAY_EVENT_PORT = 12345
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _RAY_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_node_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
node_name="test-head-node",
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_RAY_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "NODE_DEFINITION_EVENT,NODE_LIFECYCLE_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
head_node_id = cluster.head_node.node_id
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Check that a node definition and a node lifecycle event are published.
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1)
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
assert len(req_json) == 2
|
||||
assert base64.b64decode(req_json[0]["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(req_json[0]["nodeDefinitionEvent"]["nodeId"]).hex()
|
||||
== cluster.head_node.node_id
|
||||
)
|
||||
|
||||
node_def_event = req_json[0]["nodeDefinitionEvent"]
|
||||
assert node_def_event["hostname"] == socket.gethostname()
|
||||
assert node_def_event["nodeName"] == "test-head-node"
|
||||
# instanceId and instanceTypeName are set via env vars by cloud providers.
|
||||
# In local/CI environments these are typically empty.
|
||||
assert node_def_event["instanceId"] == os.environ.get("RAY_CLOUD_INSTANCE_ID", "")
|
||||
assert node_def_event["instanceTypeName"] == os.environ.get(
|
||||
"RAY_CLOUD_INSTANCE_TYPE_NAME", ""
|
||||
)
|
||||
assert base64.b64decode(req_json[1]["nodeId"]).hex() == head_node_id
|
||||
assert (
|
||||
base64.b64decode(req_json[1]["nodeLifecycleEvent"]["nodeId"]).hex()
|
||||
== cluster.head_node.node_id
|
||||
)
|
||||
assert req_json[1]["nodeLifecycleEvent"]["stateTransitions"][0]["state"] == "ALIVE"
|
||||
assert (
|
||||
req_json[1]["nodeLifecycleEvent"]["stateTransitions"][0]["aliveSubState"]
|
||||
== "UNSPECIFIED"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,117 @@
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray._private.test_utils import (
|
||||
wait_for_condition,
|
||||
wait_for_dashboard_agent_available,
|
||||
)
|
||||
from ray.dashboard.tests.conftest import * # noqa
|
||||
|
||||
_PLATFORM_EVENT_PORT = 12348
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def httpserver_listen_address():
|
||||
return ("127.0.0.1", _PLATFORM_EVENT_PORT)
|
||||
|
||||
|
||||
def test_ray_platform_events(ray_start_cluster, httpserver):
|
||||
cluster = ray_start_cluster
|
||||
cluster.add_node(
|
||||
env_vars={
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EVENTS_EXPORT_ADDR": f"http://127.0.0.1:{_PLATFORM_EVENT_PORT}",
|
||||
"RAY_DASHBOARD_AGGREGATOR_AGENT_EXPOSABLE_EVENT_TYPES": "PLATFORM_EVENT",
|
||||
"RAY_ENABLE_PYTHON_RAY_EVENT_TYPES": "PLATFORM_EVENT",
|
||||
},
|
||||
_system_config={
|
||||
"enable_ray_event": True,
|
||||
},
|
||||
)
|
||||
cluster.wait_for_nodes()
|
||||
head_node_id = cluster.head_node.node_id
|
||||
|
||||
ray.init(address=cluster.address)
|
||||
wait_for_dashboard_agent_available(cluster)
|
||||
|
||||
# Define a task that explicitly initializes and emits a platform event via EventRecorder
|
||||
@ray.remote
|
||||
def emit_test_platform_event(aggregator_port, node_ip, node_id):
|
||||
from ray._common.observability.platform_events import PlatformEventBuilder
|
||||
from ray._raylet import EventRecorder
|
||||
from ray.core.generated.events_base_event_pb2 import RayEvent
|
||||
from ray.core.generated.platform_event_pb2 import Source
|
||||
|
||||
EventRecorder.initialize(
|
||||
aggregator_port=aggregator_port,
|
||||
node_ip=node_ip,
|
||||
node_id_hex=node_id,
|
||||
max_buffer_size=1000,
|
||||
metric_source="platform_events",
|
||||
)
|
||||
|
||||
builder = PlatformEventBuilder(
|
||||
event_uid="uid-test-platform-e2e",
|
||||
platform=Source.Platform.KUBERNETES,
|
||||
object_kind="Pod",
|
||||
object_name="test-pod-name",
|
||||
reason="OOMKilled",
|
||||
message="Container exited with code 137",
|
||||
severity=RayEvent.Severity.WARNING,
|
||||
component="kubelet",
|
||||
)
|
||||
cython_event = builder.build(
|
||||
event_id=b"uid-test-platform-e2e",
|
||||
timestamp_ns=int(time.time() * 1e9),
|
||||
)
|
||||
EventRecorder.emit(cython_event)
|
||||
EventRecorder.shutdown()
|
||||
return True
|
||||
|
||||
# Expect the POST request on the HTTP server
|
||||
httpserver.expect_request("/", method="POST").respond_with_data("", status=200)
|
||||
|
||||
# Fetch the aggregator agent's address from GCS
|
||||
from ray._private.test_utils import GcsClient, get_dashboard_agent_address
|
||||
|
||||
gcs_client = GcsClient(address=cluster.address)
|
||||
agent_address = get_dashboard_agent_address(gcs_client, head_node_id)
|
||||
ip, port_str = agent_address.split(":")
|
||||
aggregator_port = int(port_str)
|
||||
|
||||
# Execute the remote task to emit the event on the node
|
||||
ray.get(emit_test_platform_event.remote(aggregator_port, ip, head_node_id))
|
||||
|
||||
# Wait for the HTTP log collector to receive the batched payload
|
||||
wait_for_condition(lambda: len(httpserver.log) >= 1, timeout=20)
|
||||
|
||||
# Validate the captured POST payload
|
||||
req, _ = httpserver.log[0]
|
||||
req_json = json.loads(req.data)
|
||||
|
||||
assert len(req_json) >= 1
|
||||
platform_event_entry = None
|
||||
for entry in req_json:
|
||||
if "platformEvent" in entry:
|
||||
platform_event_entry = entry
|
||||
break
|
||||
|
||||
assert platform_event_entry is not None
|
||||
assert platform_event_entry["eventType"] == "PLATFORM_EVENT"
|
||||
assert base64.b64decode(platform_event_entry["nodeId"]).hex() == head_node_id
|
||||
|
||||
pe_data = platform_event_entry["platformEvent"]
|
||||
assert pe_data["objectKind"] == "Pod"
|
||||
assert pe_data["objectName"] == "test-pod-name"
|
||||
assert pe_data["reason"] == "OOMKilled"
|
||||
assert pe_data["message"] == "Container exited with code 137"
|
||||
assert pe_data["source"]["platform"] == "KUBERNETES"
|
||||
assert pe_data["source"]["component"] == "kubelet"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,115 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.core.generated.events_event_aggregator_service_pb2 import TaskEventsMetadata
|
||||
from ray.dashboard.modules.aggregator.task_events_metadata_buffer import (
|
||||
TaskEventsMetadataBuffer,
|
||||
)
|
||||
|
||||
|
||||
def _create_test_metadata(dropped_task_ids: list = None, attempt_number=1):
|
||||
"""Helper function to create test metadata"""
|
||||
metadata = TaskEventsMetadata()
|
||||
if dropped_task_ids:
|
||||
for task_id in dropped_task_ids:
|
||||
attempt = metadata.dropped_task_attempts.add()
|
||||
attempt.task_id = task_id.encode()
|
||||
attempt.attempt_number = attempt_number
|
||||
return metadata
|
||||
|
||||
|
||||
def _result_to_attempts_list(result):
|
||||
"""Normalize return value from buffer.get() to a python list of attempts."""
|
||||
if hasattr(result, "dropped_task_attempts"):
|
||||
attempts = result.dropped_task_attempts
|
||||
else:
|
||||
attempts = result
|
||||
return list(attempts)
|
||||
|
||||
|
||||
def _drain_all_attempts(buffer: TaskEventsMetadataBuffer):
|
||||
"""Drain the buffer completely via public API and return list of bytes task_ids.
|
||||
|
||||
Continues calling get() until it returns an empty set of attempts.
|
||||
"""
|
||||
collected_ids = []
|
||||
num_metadata_entries = 0
|
||||
while True:
|
||||
result = buffer.get()
|
||||
attempts = _result_to_attempts_list(result)
|
||||
if len(attempts) == 0:
|
||||
break
|
||||
|
||||
num_metadata_entries += 1
|
||||
collected_ids.extend([a.task_id for a in attempts])
|
||||
return collected_ids, num_metadata_entries
|
||||
|
||||
|
||||
class TestTaskMetadataBuffer:
|
||||
"""tests for TaskMetadataBuffer class"""
|
||||
|
||||
def test_merge_and_get(self):
|
||||
"""Test merging multiple metadata objects and verify task attempts are combined."""
|
||||
buffer = TaskEventsMetadataBuffer(
|
||||
max_buffer_size=100, max_dropped_attempts_per_metadata_entry=10
|
||||
)
|
||||
|
||||
# Create two separate metadata objects with different task IDs
|
||||
metadata1 = _create_test_metadata(["task_1", "task_2"])
|
||||
metadata2 = _create_test_metadata(["task_3", "task_4"])
|
||||
|
||||
# Merge both metadata objects
|
||||
buffer.merge(metadata1)
|
||||
buffer.merge(metadata2)
|
||||
|
||||
# Get the merged results
|
||||
result = buffer.get()
|
||||
attempts = _result_to_attempts_list(result)
|
||||
|
||||
# Verify we have all 4 task attempts
|
||||
assert len(attempts) == 4
|
||||
|
||||
# Verify all expected task IDs are present
|
||||
task_ids = [attempt.task_id for attempt in attempts]
|
||||
assert sorted(task_ids) == [b"task_1", b"task_2", b"task_3", b"task_4"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"max_attempts_per_metadata_entry,num_tasks,max_buffer_size,expected_drop_attempts,expected_num_metadata_entries",
|
||||
[
|
||||
# No overflow, two metadata entries should be created
|
||||
(2, 3, 100, 0, 2),
|
||||
# No overflow, three metadata entries should be created
|
||||
(5, 15, 100, 0, 3),
|
||||
# Overflow scenario: buffer too small, ensure drop count is tracked.
|
||||
(1, 4, 2, 2, 2),
|
||||
],
|
||||
)
|
||||
def test_buffer_merge_and_overflow(
|
||||
self,
|
||||
max_attempts_per_metadata_entry,
|
||||
num_tasks,
|
||||
max_buffer_size,
|
||||
expected_drop_attempts,
|
||||
expected_num_metadata_entries,
|
||||
):
|
||||
buffer = TaskEventsMetadataBuffer(
|
||||
max_buffer_size=max_buffer_size,
|
||||
max_dropped_attempts_per_metadata_entry=max_attempts_per_metadata_entry,
|
||||
)
|
||||
|
||||
for i in range(num_tasks):
|
||||
test_metadata = _create_test_metadata([f"task_{i}"])
|
||||
buffer.merge(test_metadata)
|
||||
|
||||
# Drain everything and verify number of attempts in buffer is as expected
|
||||
drained_ids, num_metadata_entries = _drain_all_attempts(buffer)
|
||||
assert len(drained_ids) == num_tasks - expected_drop_attempts
|
||||
assert num_metadata_entries == expected_num_metadata_entries
|
||||
|
||||
# Buffer should now be empty
|
||||
assert len(_result_to_attempts_list(buffer.get())) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
Reference in New Issue
Block a user