Skip to content

fix(slurm): fail over after GCE capacity errors#5909

Draft
reidlevesque wants to merge 3 commits into
GoogleCloudPlatform:developfrom
reidlevesque:reid/slurm-capacity-circuit-breaker
Draft

fix(slurm): fail over after GCE capacity errors#5909
reidlevesque wants to merge 3 commits into
GoogleCloudPlatform:developfrom
reidlevesque:reid/slurm-capacity-circuit-breaker

Conversation

@reidlevesque

@reidlevesque reidlevesque commented Jul 6, 2026

Copy link
Copy Markdown

Why

A GCE capacity stockout currently marks the requested Slurm nodes DOWN, but the rest of that nodeset remains schedulable. In a partition containing several machine families, Slurm can therefore keep selecting the unavailable family instead of shifting work to another nodeset. Static node weights cannot adapt when any family may stock out.

What changed

  • add an opt-in capacity_circuit_breaker controller setting (disabled by default)
  • classify only structured GCE capacity codes/reasons; quota errors and message-only matches do not trip the circuit
  • open a per-nodeset circuit after a stockout, drain eligible powered-down siblings, and release a small probe set after exponential cooldown
  • close and restore circuit-owned nodes after a successful probe
  • use the maximum applicable partition/global ResumeTimeout, plus a bounded hard deadline, so in-flight probes are not reset prematurely and stale probes cannot block recovery forever
  • persist write-ahead circuit state with file locking, atomic replacement, UUID/generation race protection, and reconciliation after controller restarts
  • keep SlurmSync from undoing circuit-owned state while still draining newly-idle siblings during a half-open probe
  • cache ownership by atomically replaced state-file version, avoiding per-node JSON parsing without stale cross-process state
  • resume only nodes whose current reason is circuit-owned, preserving operator drains
  • bound circuit-owned scontrol calls and safely ignore targets removed by a nodeset resize

Validation

  • pre-commit run --files <changed files>
  • pytest: 202 passed
  • mypy .../scripts --check-untyped-defs: 27 files clean
  • Terraform fmt, tflint, init, and validate
  • make gcluster
  • backport compatibility checked against the v1.87.0 controller used by the downstream cluster

@google-cla

google-cla Bot commented Jul 6, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions github-actions Bot added the external PR from external contributor label Jul 6, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a circuit breaker mechanism for GCE nodesets to mitigate issues caused by capacity stockouts. By automatically draining and probing nodesets that encounter specific capacity errors, the controller prevents Slurm from repeatedly attempting to schedule work on unavailable machine families, improving overall cluster reliability and scheduling efficiency.

Highlights

  • Capacity Circuit Breaker: Added an opt-in capacity_circuit_breaker controller setting to automatically fail over from GCE nodesets experiencing capacity errors.
  • Circuit Logic: Implemented a controller that drains affected nodesets, probes after an exponential cooldown, and restores schedulability upon success.
  • State Persistence: Added file-locked, write-ahead state management to ensure circuit state is maintained across controller restarts.
  • Infrastructure: Updated Terraform configurations to expose the new capacity_circuit_breaker variable with validation logic.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an automatic capacity circuit breaker feature to the Slurm GCP v6 controller, which drains affected nodesets after GCE capacity errors and uses a small probe set to retry after an exponential cooldown. The implementation includes a new capacity_circuit.py script, integration with resume.py and slurmsync.py, and corresponding unit tests. The review feedback highlights critical performance and robustness improvements: implementing a process-level lazy cache for owns_node to avoid O(N^2) disk I/O operations, invalidating this cache on writes, clearing it in test fixtures to prevent test pollution, and defensively guarding against potential None values in partition configurations and API error responses.

Comment on lines +451 to +461
def owns_node(node: str, lkp: Optional[util.Lookup] = None) -> bool:
lkp = lkp or util.lookup()
if not STATE_FILE.exists():
return False
try:
nodeset_name = lkp.node_nodeset_name(node)
except Exception:
return False
with _locked_state(write=False) as state:
record = state["nodesets"].get(nodeset_name)
return bool(record and node in record.get("target_nodes", []))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

owns_node is called for every node in slurmsync.py during sync_instances(). Without caching, this results in $O(N^2)$ disk I/O operations (opening, locking, reading, and parsing the JSON state file on every single call). For a cluster with 1000 nodes, this will cause massive performance degradation and potential timeouts. Implement a process-level lazy cache to reduce the complexity to $O(1)$ after the first load.

def owns_node(node: str, lkp: Optional[util.Lookup] = None) -> bool:
    global _owned_nodes_cache
    if _owned_nodes_cache is None:
        _owned_nodes_cache = set()
        if STATE_FILE.exists():
            with _locked_state(write=False) as state:
                for record in state.get("nodesets", {}).values():
                    _owned_nodes_cache.update(record.get("target_nodes", []))
    return node in _owned_nodes_cache

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[codex] Fixed in 5dbbb59. owns_node now parses the circuit state once per file version and does O(1) membership checks thereafter. The cache is partitioned by nodeset to preserve the existing lookup semantics and is keyed by the atomically replaced file identity/metadata so writes from a separate resume process are detected.

LOCK_FILE = util.slurmdirs.state / "capacity_circuits.lock"
TRANSIENT_INSTANCE_STATES = frozenset({"PROVISIONING", "STAGING"})
TRANSIENT_SLURM_BASE_STATES = frozenset({"ALLOCATED", "MIXED"})
TRANSIENT_SLURM_FLAGS = frozenset({"CONFIGURING", "POWERING_UP"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Define _owned_nodes_cache at the module level to support lazy caching of owned nodes and prevent redundant disk I/O.

Suggested change
TRANSIENT_SLURM_FLAGS = frozenset({"CONFIGURING", "POWERING_UP"})
TRANSIENT_SLURM_FLAGS = frozenset({"CONFIGURING", "POWERING_UP"})
_owned_nodes_cache: Optional[set[str]] = None

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[codex] Fixed in 5dbbb59. Added a typed module-level cache containing the state-file version and the per-nodeset owned-node sets.

Comment on lines +149 to +160
def _write_state_unlocked(state: dict[str, Any]) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
if not state.get("nodesets"):
STATE_FILE.unlink(missing_ok=True)
return
temporary = STATE_FILE.with_name(f"{STATE_FILE.name}.{os.getpid()}.tmp")
with temporary.open("w", encoding="utf-8") as stream:
json.dump(state, stream, indent=2, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, STATE_FILE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Invalidate the _owned_nodes_cache cache on any write operation to ensure consistency with the state file.

Suggested change
def _write_state_unlocked(state: dict[str, Any]) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
if not state.get("nodesets"):
STATE_FILE.unlink(missing_ok=True)
return
temporary = STATE_FILE.with_name(f"{STATE_FILE.name}.{os.getpid()}.tmp")
with temporary.open("w", encoding="utf-8") as stream:
json.dump(state, stream, indent=2, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, STATE_FILE)
def _write_state_unlocked(state: dict[str, Any]) -> None:
global _owned_nodes_cache
_owned_nodes_cache = None
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
if not state.get("nodesets"):
STATE_FILE.unlink(missing_ok=True)
return
temporary = STATE_FILE.with_name(f"{STATE_FILE.name}.{os.getpid()}.tmp")
with temporary.open("w", encoding="utf-8") as stream:
json.dump(state, stream, indent=2, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, STATE_FILE)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[codex] Fixed in 5dbbb59. Writes invalidate the process-local cache. The read path also fingerprints the atomically replaced state file, which prevents stale cache entries when another controller process writes the file.

Comment on lines +213 to +215
for partition in getattr(lkp.cfg, "partitions", {}).values():
partition_nodesets = set(getattr(partition, "partition_nodeset", []))
partition_nodesets.update(getattr(partition, "partition_nodeset_dyn", []))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use defensive programming to guard against None values for partition_nodeset and partition_nodeset_dyn attributes, which can be None if explicitly set to null or left unpopulated.

Suggested change
for partition in getattr(lkp.cfg, "partitions", {}).values():
partition_nodesets = set(getattr(partition, "partition_nodeset", []))
partition_nodesets.update(getattr(partition, "partition_nodeset_dyn", []))
for partition in getattr(lkp.cfg, "partitions", {}).values():
partition_nodesets = set(getattr(partition, "partition_nodeset", []) or [])
partition_nodesets.update(getattr(partition, "partition_nodeset_dyn", []) or [])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[codex] Fixed in 5dbbb59. Both partition nodeset lists now use an empty-list fallback for explicit null values, with a regression test covering a null static list and populated dynamic list.

# across all bulkInserts (goes one level above this function)
failed = _get_failed_instance_inserts(op, util.lookup())
if not failed and created == 0:
errors = op.get("error", {}).get("errors", [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

op.get("error", {}) can return None if the "error" key is explicitly set to null in the API response. Calling .get("errors", []) on None will raise an AttributeError. Use (op.get("error") or {}) to safely handle None values.

Suggested change
errors = op.get("error", {}).get("errors", [])
errors = (op.get("error") or {}).get("errors", [])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[codex] Fixed in 5dbbb59. The handler now normalises top-level operation errors once with (op.get("error") or {}), reuses that list for logging and circuit detection, and has a regression test for an explicit null error.

Comment on lines +29 to +31
def state_files(tmp_path, monkeypatch):
monkeypatch.setattr(capacity_circuit, "STATE_FILE", tmp_path / "state.json")
monkeypatch.setattr(capacity_circuit, "LOCK_FILE", tmp_path / "state.lock")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Clear the _owned_nodes_cache in the state_files fixture to prevent test pollution and ensure tests remain independent.

Suggested change
def state_files(tmp_path, monkeypatch):
monkeypatch.setattr(capacity_circuit, "STATE_FILE", tmp_path / "state.json")
monkeypatch.setattr(capacity_circuit, "LOCK_FILE", tmp_path / "state.lock")
def state_files(tmp_path, monkeypatch):
monkeypatch.setattr(capacity_circuit, "STATE_FILE", tmp_path / "state.json")
monkeypatch.setattr(capacity_circuit, "LOCK_FILE", tmp_path / "state.lock")
monkeypatch.setattr(capacity_circuit, "_owned_nodes_cache", None)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[codex] Fixed in 5dbbb59. The state-file fixture now resets the module cache for every test; additional tests verify one parse per unchanged version and reload after an external atomic replacement.

@reidlevesque
reidlevesque force-pushed the reid/slurm-capacity-circuit-breaker branch from f019958 to 46d94c6 Compare July 6, 2026 18:35
@siddhartha-quad

siddhartha-quad commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Hi @reidlevesque
Could you please look into the gemini suggestions and also sign the CLA access.

Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external PR from external contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants