[core][taskEvents out of GCS][5/n] In-memory task event store implementation on task events head - #65123
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new in-memory task event storage and reconciliation system on the Ray dashboard head, including the TaskEventsHead module, a priority-based garbage collection policy, and an HTTP publisher client to send events from aggregator agents. The review feedback is highly constructive, highlighting critical areas for improvement: optimizing set eviction in TaskEventStorage to avoid blocking the event loop, adding a sleep delay in subscription loops to prevent tight-spinning on persistent failures, wrapping the event processing pipeline in a try-except block for robust error handling, and addressing a potential memory leak in job summary tracking.
| to_evict = list(self._dropped_task_attempts)[:num_to_evict] | ||
| self._dropped_task_attempts.difference_update(to_evict) |
There was a problem hiding this comment.
Converting the entire _dropped_task_attempts set (which can contain up to 1,000,000 elements) to a list just to slice the first num_to_evict elements is highly inefficient and will block the single-threaded asyncio event loop. Instead, use an iterator to retrieve only the required number of elements in O(num_to_evict) time.
| to_evict = list(self._dropped_task_attempts)[:num_to_evict] | |
| self._dropped_task_attempts.difference_update(to_evict) | |
| iterator = iter(self._dropped_task_attempts) | |
| to_evict = [next(iterator) for _ in range(num_to_evict)] | |
| self._dropped_task_attempts.difference_update(to_evict) |
| while True: | ||
| try: | ||
| for _, worker_delta in await subscriber.poll( | ||
| batch_size=_SUBSCRIBER_POLL_BATCH_SIZE | ||
| ): | ||
| self._handle_worker_delta(worker_delta) | ||
| except Exception: | ||
| logger.exception("Failed handling worker-death notifications.") |
There was a problem hiding this comment.
If subscriber.poll or the message handling raises an exception (e.g., due to GCS connection issues), this loop will tight-spin and spam the logs at maximum speed, leading to high CPU usage and log disk exhaustion. Adding a short sleep (e.g., asyncio.sleep(1)) in the except block prevents tight-looping during persistent failures.
| while True: | |
| try: | |
| for _, worker_delta in await subscriber.poll( | |
| batch_size=_SUBSCRIBER_POLL_BATCH_SIZE | |
| ): | |
| self._handle_worker_delta(worker_delta) | |
| except Exception: | |
| logger.exception("Failed handling worker-death notifications.") | |
| while True: | |
| try: | |
| for _, worker_delta in await subscriber.poll( | |
| batch_size=_SUBSCRIBER_POLL_BATCH_SIZE | |
| ): | |
| self._handle_worker_delta(worker_delta) | |
| except Exception: | |
| logger.exception("Failed handling worker-death notifications.") | |
| await asyncio.sleep(1) |
| while True: | ||
| try: | ||
| for _, job_data in await subscriber.poll( | ||
| batch_size=_SUBSCRIBER_POLL_BATCH_SIZE | ||
| ): | ||
| self._handle_job_update(job_data) | ||
| except Exception: | ||
| logger.exception("Failed handling job-finished notifications.") |
There was a problem hiding this comment.
If subscriber.poll or the message handling raises an exception (e.g., due to GCS connection issues), this loop will tight-spin and spam the logs at maximum speed, leading to high CPU usage and log disk exhaustion. Adding a short sleep (e.g., asyncio.sleep(1)) in the except block prevents tight-looping during persistent failures.
| while True: | |
| try: | |
| for _, job_data in await subscriber.poll( | |
| batch_size=_SUBSCRIBER_POLL_BATCH_SIZE | |
| ): | |
| self._handle_job_update(job_data) | |
| except Exception: | |
| logger.exception("Failed handling job-finished notifications.") | |
| while True: | |
| try: | |
| for _, job_data in await subscriber.poll( | |
| batch_size=_SUBSCRIBER_POLL_BATCH_SIZE | |
| ): | |
| self._handle_job_update(job_data) | |
| except Exception: | |
| logger.exception("Failed handling job-finished notifications.") | |
| await asyncio.sleep(1) |
| body = await request.read() | ||
| try: | ||
| add_events_request = self._deserialize_request(body) | ||
| except Exception as e: | ||
| logger.warning(f"Failed to deserialize task events request: {e}") | ||
| return dashboard_optional_utils.rest_response( | ||
| status_code=dashboard_utils.HTTPStatusCode.INTERNAL_ERROR, | ||
| message=f"Failed to deserialize task events request: {e}", | ||
| ) | ||
|
|
||
| task_events, dropped_task_attempts = convert_to_task_events(add_events_request) | ||
| self._store.record_data_loss_from_worker(dropped_task_attempts) | ||
| for task_event in task_events: | ||
| self._store.add_or_replace_task_event(task_event) |
There was a problem hiding this comment.
The parsing, conversion, and storage of task events are executed outside of the try-except block. If convert_to_task_events or any of the store methods raise an exception (e.g., ValueError from invalid IDs or AssertionError from unsupported event types), it will propagate unhandled, causing the HTTP request to fail with a 500 error without graceful logging. Wrapping the entire processing pipeline in the try-except block ensures robust error handling.
| body = await request.read() | |
| try: | |
| add_events_request = self._deserialize_request(body) | |
| except Exception as e: | |
| logger.warning(f"Failed to deserialize task events request: {e}") | |
| return dashboard_optional_utils.rest_response( | |
| status_code=dashboard_utils.HTTPStatusCode.INTERNAL_ERROR, | |
| message=f"Failed to deserialize task events request: {e}", | |
| ) | |
| task_events, dropped_task_attempts = convert_to_task_events(add_events_request) | |
| self._store.record_data_loss_from_worker(dropped_task_attempts) | |
| for task_event in task_events: | |
| self._store.add_or_replace_task_event(task_event) | |
| body = await request.read() | |
| try: | |
| add_events_request = self._deserialize_request(body) | |
| task_events, dropped_task_attempts = convert_to_task_events(add_events_request) | |
| self._store.record_data_loss_from_worker(dropped_task_attempts) | |
| for task_event in task_events: | |
| self._store.add_or_replace_task_event(task_event) | |
| except Exception as e: | |
| logger.warning(f"Failed to process task events request: {e}") | |
| return dashboard_optional_utils.rest_response( | |
| status_code=dashboard_utils.HTTPStatusCode.INTERNAL_ERROR, | |
| message=f"Failed to process task events request: {e}", | |
| ) |
| self._task_index: Dict[bytes, Set[TaskAttempt]] = {} | ||
| self._job_index: Dict[bytes, Set[TaskAttempt]] = {} | ||
| self._worker_index: Dict[bytes, Set[TaskAttempt]] = {} | ||
| self._job_task_summary: Dict[bytes, JobTaskSummary] = {} |
There was a problem hiding this comment.
The _job_task_summary dictionary grows indefinitely as new jobs are executed, because there is currently no mechanism to clean up or evict summaries for finished jobs. Although JobTaskSummary.on_job_ends is defined and tested, it is never called in production code. Consider implementing a cleanup mechanism (e.g., when job finished notifications are processed) to prevent a memory leak over time.
There was a problem hiding this comment.
this is taken care of in future PRs. This is the first PR just introducing stuff.
| from ray.core.generated.common_pb2 import TaskStatus, TaskType | ||
|
|
||
|
|
||
| def is_task_finished(task_event: gcs_pb2.TaskEvents) -> bool: |
There was a problem hiding this comment.
can this be a private member function instead of a module level function if its not being used anywhere?
| return TaskStatus.FINISHED in task_event.state_updates.state_ts_ns | ||
|
|
||
|
|
||
| def is_actor_task(task_event: gcs_pb2.TaskEvents) -> bool: |
| """Buckets task events into priority tiers; a higher tier is evicted later.""" | ||
|
|
||
| # Number of priority tiers, i.e. valid tiers are 0 .. MAX_PRIORITY - 1. | ||
| MAX_PRIORITY = 3 |
There was a problem hiding this comment.
should we expose via a static getter instead of direct access?
sampan-s-nayak
left a comment
There was a problem hiding this comment.
we should probably test this as well by enabling flags and running all unit tests once the state api changes are ready.
b42066f to
ac27fdd
Compare
ac27fdd to
67a84b4
Compare
True. Triggered a test from this PR: #65248 |
610aacb to
dd3e4b4
Compare
67a84b4 to
f8c8d41
Compare
| to_drop = num_profile - MAX_NUM_PROFILE_EVENTS_PER_TASK | ||
| del existing.profile_events.events[:to_drop] | ||
| self._summary(existing.job_id).record_profile_events_dropped(to_drop) | ||
| self._stats[STAT_TOTAL_PROFILE_DROPPED] += to_drop |
There was a problem hiding this comment.
Unlimited profile cap mishandled
Low Severity
MAX_NUM_PROFILE_EVENTS_PER_TASK documents -1 as unlimited, but _update_existing truncates whenever num_profile > MAX_NUM_PROFILE_EVENTS_PER_TASK. With -1, that condition is always true, so profile events are incorrectly dropped. The same module already treats max_num_task_events <= 0 as unlimited.
Reviewed by Cursor Bugbot for commit f8c8d41. Configure here.
|
|
||
| c_bool enable_task_events_to_dashboard_head() const | ||
|
|
||
| int64_t task_events_max_num_task_in_gcs() const |
There was a problem hiding this comment.
we should probably deprecate this in the future, maybe we can add a todo so we dont forget
There was a problem hiding this comment.
and have dashboard specific configs (just needs a rename)
There was a problem hiding this comment.
same for all the other configs which we are reusing from GCS
There was a problem hiding this comment.
Right. I did it right now for an easier migration for users. Once we deprecate the GcsTaskManager, we can get rid of the flags or change their name.
|
might be worth looking into the automated bot comments |
dd3e4b4 to
09fcfc7
Compare
f8c8d41 to
d311e8a
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d311e8a. Configure here.
6bd709f to
39b9368
Compare
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
Signed-off-by: Kartica Modi <karticamodi@gmail.com>
- Copy each set TaskLogInfo field into the state update, matching the C++ converter - Field-level presence copy so a later end-offset event doesn't clobber start offsets - Add a converter round-trip test Signed-off-by: Kartica Modi <karticamodi@gmail.com>
39b9368 to
d8f53f0
Compare
- The GCS-specific cap is obsolete once task events move out of GCS Signed-off-by: Kartica Modi <karticamodi@gmail.com>
d8f53f0 to
2a2df09
Compare


Part of the effort to move task events out of GCS.
This PR builds on top of #65057
This is the first part of implementing
GcsTaskManagerinsidetask_events_headChanges done:
ray_event_converter.py: the incoming requests areAddEventsRequestbut the storage storesTaskEvents, anddropped_task_attempts. This layer converts the incoming requests to storage friendly format.gc_policy.py:FinishedTaskActorTaskGcPolicy: 3 priority tiers (finished tasks, actor, others) that decide eviction order of task events when needed. Mimics the existing policy inGcsTaskManager.task_event_storage.py: Based onTaskEventStorageinGcsTaskManager.dropped_task_attemptsper job.dropped_task_attemptsmaintained per job which garbage when the job finishes. If per job list grows past a threshold, its pruned every 5 seconds.Followup PRs: