Repository navigation
[core][gcs] Do not FATAL when an actor is missing its task spec on GCS init - #66073
Conversation
There was a problem hiding this comment.
Code Review
This pull request prevents GCS startup crashes caused by missing actor task specifications (e.g., due to torn writes or head crashes during actor teardown). Instead of calling map_find_or_die and aborting, OnInitializeActorShouldLoad now safely handles missing task specs by logging an error and treating the actor as dead (not loadable). A regression test has been added to verify this behavior. I have no feedback to provide.
| "as dead. This indicates the actor table and actor task spec table were " | ||
| "left inconsistent by a torn write or a head crash during actor teardown."; | ||
| return false; | ||
| } |
There was a problem hiding this comment.
PR needs clearer description
Low Severity
This violates the Clear PR Descriptions and Titles rule. The description is blank and does not explain what problem is present or how it is fixed.
To help reviewers, please ensure your PR includes:
- Title: A concise summary of the change
- Description:
- What problem does this solve?
- How does this PR solve it?
- Any relevant context for reviewers such as:
- Why is the problem important to solve?
- Why was this approach chosen over others?
See this list of PRs as examples for PRs that have gone above and beyond:
- [Core] Introduce local port service discovery #59613
- [Core] Improve Large-Scale Resource View Synchronization Through Sync Message Batching #57641
- Remove node observability information from hot path of core components #56474
- [core][rdt] Support out-of-order actors by extracting metadata when creating #59610
- [core] fix open leak for plasma store memory (shm/fallback) by workers #52622
Triggered by project rule: Bugbot Rules
Reviewed by Cursor Bugbot for commit 994bb95. Configure here.
994bb95 to
1051b31
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).
Reviewed by Cursor Bugbot for commit 1051b31. Configure here.
1051b31 to
6c9b40f
Compare
|
Thanks for the review. Addressing the Bugbot "medium" finding (isolated actors remained non-DEAD): Previously, an actor whose task spec was missing on GCS init was skipped from loading and parked in the destroyed-actor cache with its original non-DEAD state, and no death was published. If the owner was still alive after a GCS failover, The updated commit now actively resolves the actor during
The regression test was updated accordingly: after |
|
Hi @nataliekung, could you sign off on your commit to make the DCO check pass? |
| const auto *actor_info = GetActorInfo(*test_gcs_actor_manager, actor_id); | ||
| ASSERT_NE(actor_info, nullptr); | ||
| ASSERT_EQ(actor_info->state(), rpc::ActorTableData::DEAD); | ||
| ASSERT_TRUE(actor_info->has_death_cause()); | ||
| ASSERT_TRUE(actor_info->death_cause().has_actor_died_error_context()); |
There was a problem hiding this comment.
Does this actually reload state from the storage?
There was a problem hiding this comment.
Good catch — it didn't. The old assertion only read the in-memory observability cache. I've extended the test to drain the io_context (flushing the async ActorTable().Put) and then read the row back via gcs_table_storage_->ActorTable().Get, asserting it reloads as DEAD with the death cause. That verifies the DEAD state is actually persisted, which is what lets the dangling row self-heal on later restarts.
| } | ||
|
|
||
| // Loadable actors are guaranteed to have a task spec: `Initialize` marks any | ||
| // actor whose task spec is missing as dead before reaching this point. |
There was a problem hiding this comment.
Why don't we also make it return false here if we don't find the task spec?
There was a problem hiding this comment.
Done. OnInitializeActorShouldLoad now looks the task spec up with find() and returns false when it's missing, instead of map_find_or_die(), so the helper decides "should this actor load?" on its own and can never FATAL.
Initialize then uses that false to route the actor into the not-loaded branch, where — if the spec is missing for an otherwise-loadable actor — it marks the row DEAD, persists it, and publishes the death (so a client waiting on it whose owner is still alive after a GCS failover unblocks). I dropped the earlier top-of-loop pre-check, so the missing-spec handling now lives in one place, driven by the helper's return value.
6c9b40f to
a8a17cf
Compare
| // Crash consistency: the actor table and the actor task spec table are | ||
| // written as separate, non-atomic entries, so a torn write across the two | ||
| // or a head crash during actor teardown can leave a loadable (non-DEAD, or | ||
| // dead-but-restartable) actor whose task spec is gone. |
There was a problem hiding this comment.
I think your fix is correct, but this comment is not true currently.
Yes, the actor table and the actor task spec table are written as separate, non-atomic entries, but I think we currently avoid the missing task spec by writing the task spec first, then writing the actor table. The only exception is the above dead_actors.push_back(actor_id) during the GCS Initialization. That is why your fix, marking dead actors as actually DEAD in the table, works. Could you make the comments accurate? In the logs and error messages, we can just not mention "so a torn write across the two or a head crash during actor teardown..."
There was a problem hiding this comment.
You're right, thanks — the comment, log, and death cause were inaccurate, and I've corrected them.
The task spec is always written before the actor-table row, so the normal write ordering never leaves a loadable actor without its spec. The only way an actor becomes non-DEAD with a missing spec is this de-selection path in Initialize() itself: when an actor should not be loaded (for example, its owner is dead), it is appended to dead_actors and its task spec is later BatchDeleted, but the actor-table row was previously left in its non-DEAD state. On the next restart that row is reloaded as non-DEAD with its spec already gone, and map_find_or_die() aborts GCS — which is exactly why marking the row DEAD fixes it.
I removed the "torn write" / "crash during actor teardown" wording from the code comment, the log message, the death-cause error_message (also renamed the helper to GenActorNotLoadableAtInitCause), the commit message, and the PR description, and rewrote them to describe this de-selection mechanism.
| const bool actor_is_dead_and_not_restartable = | ||
| actor_table_data.state() == ray::rpc::ActorTableData::DEAD && | ||
| !IsActorRestartable(actor_table_data); | ||
| if (!actor_is_dead_and_not_restartable && !actor_task_specs.contains(actor_id)) { |
There was a problem hiding this comment.
do we need the !actor_task_specs.contains(actor_id) check? Isn't !actor_is_dead_and_not_restartable enough?
There was a problem hiding this comment.
Agreed — dropped the !actor_task_specs.contains(actor_id) check. The trigger is now just !actor_is_dead_and_not_restartable.
Every de-selected actor that is not already permanently DEAD is about to have its task spec BatchDeleted, so leaving its row non-DEAD is exactly what creates the future FATAL. Marking all such actors DEAD, persisting the row, and publishing the death before the spec is deleted proactively prevents the landmine instead of only reacting once the spec is already gone.
4fed1e0 to
69943df
Compare
|
@nataliekung, please fix these build errors: |
…S init
When GCS fault tolerance is enabled with external Redis,
GcsActorManager::Initialize() iterates the actor table and, for every actor it
decides to load, looks up its task spec with map_find_or_die(). If the spec is
missing, that helper aborts the whole gcs_server:
[C] (gcs_server) container_util.h:163: Key <actor_id_hex> doesn't exist
RuntimeError: Failed to start GCS.
The head then CrashLoopBackOffs, and because GCS owns cluster-level metadata,
the entire shared cluster is down until an operator manually resets GCS state.
Root cause: the task spec is always persisted before the actor-table row, so
the normal write ordering never leaves a loadable actor without a spec. The
only way an actor becomes non-DEAD with a missing spec is Initialize() itself:
when an actor should not be loaded (for example, its owning job or root
detached actor is dead), Initialize() de-selects it and deletes its task spec,
but previously left the actor-table row in its non-DEAD state. On the next
restart that row is reloaded as non-DEAD with its spec already gone, and
map_find_or_die() aborts GCS.
Fix:
- OnInitializeActorShouldLoad() looks up the spec with find() and returns false
when it is missing, so Initialize() isolates such a row instead of aborting.
- When Initialize() de-selects an actor that is not already permanently DEAD,
mark it DEAD, persist the row, publish the death, and only then delete its
task spec, from inside the ActorTable().Put() completion callback (mirroring
DestroyActor()). Ordering the spec delete after the DEAD row is durable means
a crash in between can leave at most a DEAD row (survivable; it converges on
the next restart), never a non-DEAD row without a spec. Already-permanently-
dead de-selected actors keep the existing behavior: their expected-absent
spec is cleaned up by the end-of-loop batch delete.
Tests: added coverage for the real root-cause path (owner job dead, spec
present -> actor marked DEAD, row persisted, spec deleted) and for reloading
the persisted DEAD row with a fresh GcsActorManager (no abort, stays DEAD, not
re-registered); the missing-spec find() guard remains covered.
Signed-off-by: nataliekung <4054699+nataliekung@users.noreply.github.com>
69943df to
ee79093
Compare
|
Thanks for catching this. Root cause: in Fixed by switching that lambda to an init-capture that copies real variables — Verified the target now builds and the test passes under clang + libc++ ( |
…66692) ## Description Building Ray from source on current master fails with Apple clang 17 (Xcode 26) because of a `-Wshadow -Werror` error introduced in #66073: ``` src/ray/gcs/actor/gcs_actor_manager.cc:1872:21: error: declaration shadows a structured binding [-Werror,-Wshadow] 1872 | {[this, actor_id = actor_id, observability_data = observability_data]( | ^ src/ray/gcs/actor/gcs_actor_manager.cc:1784:21: note: previous declaration is here 1784 | for (const auto &[actor_id, actor_table_data] : gcs_init_data.Actors()) { | ^ 1 error generated. ``` This PR fixes the issue by renaming the inner `actor_id` variable to eliminate the shadowing. ### Verification The build passes with this fix applied. ## Related issues Fixes #66691 --------- Signed-off-by: dancingactor <s990346@gmail.com> Signed-off-by: Ding-Xin Chen <s990346@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…S init (ray-project#66073) # [core][gcs] Do not FATAL when an actor is missing its task spec on GCS init ## Why are these changes needed? When GCS fault tolerance is enabled with external Redis, `GcsActorManager::Initialize()` iterates the actor table and, for every actor it decides to load, looks up its task spec with `map_find_or_die()`. If the spec is missing, that helper aborts the whole `gcs_server`: ``` [C] (gcs_server) container_util.h:163: Key <actor_id_hex> doesn't exist RuntimeError: Failed to start GCS. ``` The head then CrashLoopBackOffs, and because GCS owns cluster-level metadata, the **entire shared cluster is down** until an operator manually resets GCS state. A single dangling actor record takes down the whole cluster with no self-heal. ### Root cause The task spec (`@ACTOR_TASK_SPEC`) is always persisted before the actor-table row (`@ACTOR`), so the normal write ordering never leaves a loadable actor without a spec. The only way an actor becomes non-DEAD with a missing spec is `Initialize()` itself: - When an actor should not be loaded (for example, its owning job or root detached actor is dead), `Initialize()` de-selects it, appends it to `dead_actors`, and later `ActorTaskSpecTable().BatchDelete(dead_actors)` removes its task spec. - Previously the actor-table row was left in its original **non-DEAD** state (e.g. `DEPENDENCIES_UNREADY`, `ALIVE`). - On the next restart that row is reloaded as non-DEAD, but its task spec is already gone, so `map_find_or_die()` aborts GCS. ## What is changed? 1. `OnInitializeActorShouldLoad()` looks up the task spec with `find()` and returns `false` when it is missing, so `Initialize()` isolates such a row instead of aborting the whole GCS with a fatal lookup. 2. When `Initialize()` de-selects an actor that is not already permanently DEAD, it now marks the actor **DEAD**, persists the row, publishes the death, and only then deletes its task spec — from inside the `ActorTable().Put()` completion callback, mirroring `DestroyActor()`. Ordering the spec delete after the DEAD row is durable means a crash in between can leave at most a DEAD row (survivable; it converges on the next restart), never a non-DEAD row without a spec. The trigger is simply `!actor_is_dead_and_not_restartable`, so every de-selected actor about to lose its spec is made consistent first. Already-permanently-dead de-selected actors keep the existing behavior: their expected-absent spec is cleaned up by the end-of-loop batch delete. Effects: - GCS starts successfully; only the de-selected actors are isolated, all other actors load normally. - The dangling actor-table row is persisted as DEAD before its spec is deleted, so it reloads as DEAD (never as a non-DEAD row with a missing spec) on later restarts. - Any client still waiting on the actor (its owner may still be alive after a GCS failover) is unblocked with a death cause instead of hanging. ## Risk Low and localized: - Only actors that are de-selected at init (owner dead, or a pre-existing dangling non-DEAD row with no spec) change behavior; the normal load path (spec present, owner alive) is unchanged. - A de-selected actor cannot be reconstructed anyway, so marking it DEAD is the correct terminal state — strictly better than aborting GCS or leaving an inconsistent row. - Routes through the existing "should not load" branch; no new state-machine paths are introduced. ## Testing Added C++ unit tests in `gcs_actor_manager_test.cc`: - `TestInitializeMarksDeadWhenOwnerJobDeadAndReloads` — the real root-cause path: an actor whose owning job is dead but whose task spec is still present. Asserts `Initialize()` marks it DEAD, persists the DEAD row, and deletes its task spec from storage; then a fresh `GcsActorManager` initialized from the persisted state does not abort, still reports the actor DEAD, and does not re-register it. - `TestInitializeIsolatesActorWithMissingTaskSpec` — the `find()` guard: a non-DEAD actor whose task spec is already gone does not abort `Initialize()` and is isolated as DEAD (reloadable from storage). Run them with: ``` bazel test //src/ray/gcs/actor/tests:gcs_actor_manager_test --test_output=all ``` To confirm the guard actually prevents the crash, revert only the `gcs_actor_manager.cc` hunk (keep the tests) and re-run: `Initialize()` aborts with `container_util.h: Key <actor_id> doesn't exist`. With the fix applied the tests pass. Co-authored-by: nataliekung <4054699+nataliekung@users.noreply.github.com>
…ay-project#66692) ## Description Building Ray from source on current master fails with Apple clang 17 (Xcode 26) because of a `-Wshadow -Werror` error introduced in ray-project#66073: ``` src/ray/gcs/actor/gcs_actor_manager.cc:1872:21: error: declaration shadows a structured binding [-Werror,-Wshadow] 1872 | {[this, actor_id = actor_id, observability_data = observability_data]( | ^ src/ray/gcs/actor/gcs_actor_manager.cc:1784:21: note: previous declaration is here 1784 | for (const auto &[actor_id, actor_table_data] : gcs_init_data.Actors()) { | ^ 1 error generated. ``` This PR fixes the issue by renaming the inner `actor_id` variable to eliminate the shadowing. ### Verification The build passes with this fix applied. ## Related issues Fixes ray-project#66691 --------- Signed-off-by: dancingactor <s990346@gmail.com> Signed-off-by: Ding-Xin Chen <s990346@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>


[core][gcs] Do not FATAL when an actor is missing its task spec on GCS init
Why are these changes needed?
When GCS fault tolerance is enabled with external Redis,
GcsActorManager::Initialize()iterates the actor table and, for every actor itdecides to load, looks up its task spec with
map_find_or_die(). If the spec ismissing, that helper aborts the whole
gcs_server:The head then CrashLoopBackOffs, and because GCS owns cluster-level metadata,
the entire shared cluster is down until an operator manually resets GCS
state. A single dangling actor record takes down the whole cluster with no
self-heal.
Root cause
The task spec (
@ACTOR_TASK_SPEC) is always persisted before the actor-tablerow (
@ACTOR), so the normal write ordering never leaves a loadable actorwithout a spec. The only way an actor becomes non-DEAD with a missing spec is
Initialize()itself:detached actor is dead),
Initialize()de-selects it, appends it todead_actors, and laterActorTaskSpecTable().BatchDelete(dead_actors)removes its task spec.
(e.g.
DEPENDENCIES_UNREADY,ALIVE).already gone, so
map_find_or_die()aborts GCS.What is changed?
OnInitializeActorShouldLoad()looks up the task spec withfind()andreturns
falsewhen it is missing, soInitialize()isolates such a rowinstead of aborting the whole GCS with a fatal lookup.
Initialize()de-selects an actor that is not already permanently DEAD,it now marks the actor DEAD, persists the row, publishes the death, and
only then deletes its task spec — from inside the
ActorTable().Put()completion callback, mirroring
DestroyActor(). Ordering the spec deleteafter the DEAD row is durable means a crash in between can leave at most a
DEAD row (survivable; it converges on the next restart), never a non-DEAD row
without a spec. The trigger is simply
!actor_is_dead_and_not_restartable,so every de-selected actor about to lose its spec is made consistent first.
Already-permanently-dead de-selected actors keep the existing behavior: their
expected-absent spec is cleaned up by the end-of-loop batch delete.
Effects:
actors load normally.
so it reloads as DEAD (never as a non-DEAD row with a missing spec) on later
restarts.
GCS failover) is unblocked with a death cause instead of hanging.
Risk
Low and localized:
dangling non-DEAD row with no spec) change behavior; the normal load path
(spec present, owner alive) is unchanged.
correct terminal state — strictly better than aborting GCS or leaving an
inconsistent row.
paths are introduced.
Testing
Added C++ unit tests in
gcs_actor_manager_test.cc:TestInitializeMarksDeadWhenOwnerJobDeadAndReloads— the real root-causepath: an actor whose owning job is dead but whose task spec is still present.
Asserts
Initialize()marks it DEAD, persists the DEAD row, and deletes itstask spec from storage; then a fresh
GcsActorManagerinitialized from thepersisted state does not abort, still reports the actor DEAD, and does not
re-register it.
TestInitializeIsolatesActorWithMissingTaskSpec— thefind()guard: anon-DEAD actor whose task spec is already gone does not abort
Initialize()and is isolated as DEAD (reloadable from storage).
Run them with:
To confirm the guard actually prevents the crash, revert only the
gcs_actor_manager.cchunk (keep the tests) and re-run:Initialize()abortswith
container_util.h: Key <actor_id> doesn't exist. With the fix applied thetests pass.