Skip to content

RHOAIENG-98942: share the RayCluster spec builders (no behaviour change) - #1182

Merged
pawelpaszki merged 10 commits into
mainfrom
RHOAIENG-98942-unify-raycluster-spec
Oct 12, 2026
Merged

pawelpaszki merged 10 commits into
mainfrom
RHOAIENG-98942-unify-raycluster-spec

Conversation

@szaher

@szaher szaher commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Why are these changes needed?

ClusterConfiguration feeds two independent RayCluster spec builders — ray/cluster/build_ray_cluster.py for a standalone cluster, ray/rayjobs/config.py for the rayClusterSpec embedded in a RayJob. They were written separately and had drifted. A field wired into one and forgotten in the other raises nothing; the cluster just comes up without it.

The bug this fixes. enable_gcs_ft, redis_address, redis_password_secret and external_storage_namespace appeared nowhere in ray/rayjobs/config.py — zero occurrences, against nine in the standalone builder. ClusterConfiguration accepted and validated them and the RayJob builder ignored them, so a job configured for fault tolerance came up with no Redis to recover from, reported nothing, and failed only when the head node died.

labels was a second, quieter case: _build_additional_worker_group_spec merged config.labels into its pod template while the head and default worker templates passed annotations and no labels, so ClusterConfiguration(labels=...) landed on extra worker groups and nowhere else. Not previously tracked by any ticket.

This shares the duplicated pieces, fixes the five dropped fields, and adds a test that fails when they drift again.

Related issue number

This PR changes no cluster or job behaviour. It shares duplicated spec-building code and records the differences. Both behavioural fixes have been split out.

  • Partially addresses RHOAIENG-98942 — shares the spec leaves, not the whole builder. Do not close on merge; it is back In Progress.
  • RHOAIENG-98943 (GCS fault tolerance) — reopened, not fixed here. See below.
  • RHOAIENG-98942: put config.labels on RayJob head and worker pods #1184 carries the config.labels fix for RayJob head and worker pods — two lines, independent of this refactor.
  • Follow-up RHOAIENG-99560 owns the remaining spec assembly.
  • RHOAIENG-98949 (local_queue precedence) stays open — classified CONFLICTED in the audit, not resolved here.

Why GCS fault tolerance is no longer part of this PR

An earlier revision emitted gcsFaultToleranceOptions on the RayJob path, on the assumption the fields were being dropped by drift. They are not.

52a351a — "RHOAIENG-30720: Remove GCS FT for Lifecycled RayClusters", August 2025 — removed those four fields from the lifecycled path: fields, validation and emission. RHOAIENG-30720 is "Head pod persistence doesn't work"; GCS FT was broken, state was lost on head-pod restart, and the ticket states the RayJob implementation was out of scope for the fix.

So emitting them again would re-enable a feature nobody has validated on that path. What #1091 genuinely broke is narrower: making ClusterConfiguration shared means the four fields are now accepted and validated on the RayJob path and then silently ignored. Rejecting them or emitting them after validation is RHOAIENG-98943's decision; it should not be a side effect of a refactor.

The asymmetry is pinned by test_gcs_fault_tolerance_is_standalone_only, which fails if either side changes.

What is not done

The spec tree is still assembled twice, which is why 98942 stays open:

  • headGroupSpec / workerGroupSpecs and their rayStartParams are built independently by each builder
  • _build_worker_group_spec and _build_additional_worker_group_spec are two copies of additional worker group assembly
  • labels is PARTIAL: a presence check passes on both paths because the RayJob builder merges config.labels into additional worker group pods, but the default head and worker pods differ. RHOAIENG-98942: put config.labels on RayJob head and worker pods #1184 fixes the RayJob side; aligning the standalone side changes existing output and belongs with the rest of the unification.
  • The parity test enumerates ClusterConfiguration only, so WorkerGroup.extended_resource_requests (added by RHOAIENG-59978 while this PR was open) has no parity guard yet.

Checks

  • I've made sure the tests are passing.
  • Testing Strategy
    • Unit tests
    • Manual tests
    • This PR is not tested :(

src/codeflare_sdk/ray/test_builder_parity.py sets every ClusterConfiguration field to a sentinel, renders both specs and asserts each sentinel reaches both. Fields with no distinctive string of their own — booleans and counts — are asserted against the sanitised spec dicts instead. A new field that is neither given a sentinel nor classified fails test_every_field_is_classified, so "no field may be silently ignored" is enforced rather than asserted.

Mutation-checked; each of these fails the suite: adding a ClusterConfiguration field, changing a shared type, dropping an overload, and dropping additional worker groups from the RayJob path.

Local run: 558 passed, 2 pre-existing failures (test_cluster_creation_local_queue, test_default_cluster_creation — Python 3.13 has no default Ray image locally; both pass in CI). mypy clean, lint-imports 4 contracts kept, raycluster_spec.py at 100% coverage.

Architecture

  • This PR does not change component boundaries or API contracts
  • OR: I have updated the relevant design doc (docs/designs/CodeFlare-SDK-design-doc.md) or AGENTS.md

New module ray/cluster/raycluster_spec.py holds the shared, side-effect-free pieces: ODH CA volumes and mounts, resource requirements, GPU counting, extended-resource mapping, the rayStartParams resources string, head and worker containers and replica counts. gcs_fault_tolerance_options() lives there too but is called by the standalone path only. Each was proven identical to both originals by repr comparison before the duplicates were deleted — the ODH volumes were byte-for-byte copies and _cpu_limit_to_num_cpus was a literal one.

Why it is not one function. build_ray_cluster() is not pure: it reaches the Kubernetes API three times while rendering — local_queue_exists(), get_default_local_queue() and validate_autoscaling_with_kueue() — and build_ray_cluster_spec() makes no calls at all. A single builder cannot be the union of the two without the RayJob path inheriting network calls it does not make today, so resolving cluster facts has to be separated from rendering first. That is the main design constraint on RHOAIENG-99560.

Boundary note. .claude/rules/rayjobs.md and .cursor/rules/rayjobs.mdc claimed ray.rayjobs must not import ray.cluster. That stopped being true at #1091 — rayjobs/config.py already imports ClusterConfiguration from ray.cluster.config, and .importlinter has no contract for it, so CI never caught the contradiction. Both files now describe the real boundary.

Docs: docs/raycluster_spec_parity.md classifies all 36 fields — SUPPORTED (24), PARTIAL (1), PENDING DECISION (4), CONTEXT_ONLY (3), NOT_APPLICABLE (3), CONFLICTED (1) — each with a reason. AGENTS.md updated to point at the shared module, the parity test and the no-I/O constraint.

Note on the rebase

1be2418 landed while this was open and changed _build_resource_requirements on both builders identically (memory stringified, CPU written back raw). That change now exists once, in the shared module. Its V1ExecAction(command=...) and V1ObjectMeta(annotations=...) fixes were carried across too.

🤖 Generated with Claude Code

@openshift-ci openshift-ci Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Oct 7, 2026
@szaher
szaher force-pushed the RHOAIENG-98942-unify-raycluster-spec branch from d72d44f to 517e2b0 Compare October 7, 2026 14:14
@openshift-ci openshift-ci Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Oct 7, 2026
@codecov

codecov Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.11504% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.06%. Comparing base (04874a9) to head (20b1ff5).

Files with missing lines Patch % Lines
src/codeflare_sdk/ray/cluster/build_ray_cluster.py 95.23% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1182      +/-   ##
==========================================
+ Coverage   96.68%   97.06%   +0.37%     
==========================================
  Files          26       27       +1     
  Lines        2772     2722      -50     
==========================================
- Hits         2680     2642      -38     
+ Misses         92       80      -12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pawelpaszki pawelpaszki added test-guided-notebooks Run PR check to verify Guided notebooks test-ui-notebooks Run PR check to verify UI notebooks test-additional-notebooks labels Oct 7, 2026

@pawelpaszki pawelpaszki 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.

Thanks for the extraction and the field audit — the GCS-FT / labels RayJob bugs are real, and test_every_field_is_classified is the right safety net.

Inline comments below are tagged Blocking or Non-blocking. I would not /lgtm until the blocking items are addressed (or 98942 is explicitly scoped to “shared primitives + audit” with a follow-up for full unification).

Comment thread src/codeflare_sdk/ray/rayjobs/config.py Resolved
Comment thread docs/raycluster_spec_parity.md Resolved
Comment thread src/codeflare_sdk/ray/rayjobs/config.py Resolved
@pawelpaszki

Copy link
Copy Markdown
Contributor

Non-blocking — PR description is still the empty template.

Title is Rhoaieng 98942 unify raycluster spec; the body has none of the Why / Related issue / Checks / Architecture boxes filled in. The four commits already explain the work well — please copy that into the PR body and link RHOAIENG-98942 / RHOAIENG-98943 (and note 98949 is left open). Architecture should record that AGENTS.md was updated and that this is shared primitives rather than a single builder.

@chipspeak chipspeak 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.

I've reviewed it and echo the requests from Pawel. Given how sweeping the under the hood changes to the SDK have been the last few weeks, I'd love if we could have a document or walkthrough to outline the new approaches. It was something I think we had good context on so I don't want that to disappear.

szaher added a commit that referenced this pull request Oct 8, 2026
Review from @pawelpaszki on #1182. All points verified against the code
before changing anything; every one of them held.

Blocking:

- The standalone builder still inlined the GCS fault tolerance block instead
  of calling the helper extracted for it, which is exactly the duplication
  98942/98943 were meant to remove. It now calls
  gcs_fault_tolerance_options(). The uncovered line Codecov flagged in
  build_ray_cluster.py was indeed that leftover ValueError.
- worker_replica_counts() was dead — only its own tests called it. Both
  builders now use it, replacing two copies of the replicas / minReplicas /
  maxReplicas branch.
- format_resources_param() was used on the RayJob side only; the standalone
  path inlined json.dumps. It now calls the shared one.

Non-blocking:

- The parity test claimed more than it checked. Fields whose MARKERS entry
  was None were filtered out of the parametrized test entirely, so
  num_workers, min_workers, max_workers, enable_autoscaling, enable_gcs_ft
  and additional_worker_groups were never parity-checked, and the
  additional_worker_groups sentinel was an empty list. Those fields are now
  listed in STRUCTURAL and asserted against the sanitised spec dicts rather
  than by substring on repr(), and the sentinel is a real WorkerGroup.
  Mutation-checked: dropping GCS FT or additional worker groups from the
  RayJob path fails the suite.
- Bottom-of-file imports moved to the top; there was no cycle, so the
  noqa: E402 pair was covering nothing.
- Deleted the ODH volumes section heading left stranded above
  build_ray_cluster_spec.
- docs: labels moved out of SUPPORTED into a PARTIAL section. Both paths
  carry them, but only the RayJob path puts them on the default head and
  worker pods, so a NetworkPolicy selector still behaves differently.
  Fixing that changes standalone output, so it belongs with the follow-up.
- docs: added an explicit list of what is shared versus what each builder
  still assembles independently.

The remaining blocking point — that the tree is still duplicated and 98942
should not close as done — is answered in the PR description rather than
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@szaher szaher changed the title Rhoaieng 98942 unify raycluster spec RHOAIENG-98942: share the RayCluster spec builders, fix the fields RayJob dropped Oct 8, 2026
@openshift-ci-robot

openshift-ci-robot commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

@szaher: This pull request references RHOAIENG-98942 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the task to target the "5.1.0" version, but no target version was set.

Details

In response to this:

What this does

ClusterConfiguration feeds two independent RayCluster spec builders — ray/cluster/build_ray_cluster.py for a standalone cluster, ray/rayjobs/config.py for the rayClusterSpec embedded in a RayJob. They were written separately and had drifted. A field wired into one and forgotten in the other raises nothing; the cluster just comes up without it.

This shares the duplicated pieces, fixes the five fields the RayJob path was dropping, and puts a test in place that fails when they drift again.

Scope: RHOAIENG-98942 stays open. This PR shares the spec leaves; the tree is still assembled twice. See "What is not done" below — the remainder is RHOAIENG-99560.

The bug this fixes

enable_gcs_ft, redis_address, redis_password_secret and external_storage_namespace appeared nowhere in ray/rayjobs/config.py — zero occurrences, against nine in the standalone builder. ClusterConfiguration accepted and validated them, and the RayJob builder ignored them. A job configured for fault tolerance came up with no Redis to recover from, reported nothing, and failed only when the head node died.

labels was a second, quieter case: _build_additional_worker_group_spec merged config.labels into its pod template, while the head and default worker templates passed annotations and no labels. So ClusterConfiguration(labels=...) landed on extra worker groups and nowhere else. Not previously tracked by any ticket.

Commits

1 Parity test, written before any refactor, with the then-current gaps as xfail(strict=True)
2 Shared ray/cluster/raycluster_spec.py; both builders rewired
3 The five dropped fields fixed; xfails deleted
4 Field audit into docs/, plus a stale rule file corrected
5 Review fixes

Each extracted piece was proven identical to both originals by repr comparison before the duplicates were deleted — the ODH CA volumes and mounts were byte-for-byte copies, and _cpu_limit_to_num_cpus was a literal one.

What is not done

The spec tree is still assembled twice, which is why 98942 stays open:

  • headGroupSpec / workerGroupSpecs and their rayStartParams are built independently by each builder
  • _build_worker_group_spec and _build_additional_worker_group_spec are two copies of additional worker group assembly
  • labels is documented as PARTIAL, not SUPPORTED: both paths carry them, but only the RayJob path puts them on the default head and worker pods. Fixing that changes existing standalone output, so it belongs with the rest of the unification.

A constraint found while doing this, which shapes the follow-up: build_ray_cluster() is not pure. It reaches the Kubernetes API three times while rendering — local_queue_exists(), get_default_local_queue() and validate_autoscaling_with_kueue() — and build_ray_cluster_spec() makes no calls at all. One builder cannot simply be the union of the two; resolving cluster facts has to be separated from rendering, or the RayJob path inherits network calls it does not make today. raycluster_spec.py is deliberately free of I/O for this reason.

Testing

src/codeflare_sdk/ray/test_builder_parity.py sets every ClusterConfiguration field to a sentinel, renders both specs and asserts each sentinel reaches both. Fields with no distinctive string of their own — booleans and counts — are asserted against the sanitised spec dicts instead. A new field that is neither given a sentinel nor classified fails test_every_field_is_classified, so "no field may be silently ignored" is enforced rather than asserted.

The audit it encodes is docs/raycluster_spec_parity.md: all 36 fields as SUPPORTED (28), PARTIAL (1), CONTEXT_ONLY (3), NOT_APPLICABLE (3) or CONFLICTED (1), each with a reason.

Mutation-checked — each of these fails the suite: adding a ClusterConfiguration field, changing a shared type, dropping an overload, removing GCS FT from the RayJob path, and dropping additional worker groups from it.

Local run: 564 passed, 2 pre-existing failures (test_cluster_creation_local_queue, test_default_cluster_creation — Python 3.13 has no default Ray image locally; both pass in CI). mypy clean, lint-imports 4 contracts kept, raycluster_spec.py at 100% coverage.

Note on the rebase

1be2418 landed while this was open and changed _build_resource_requirements on both builders identically (memory stringified, CPU written back raw). That change now exists once, in the shared module. Its V1ExecAction(command=...) and V1ObjectMeta(annotations=...) fixes were carried across too.

🤖 Generated with Claude Code

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@szaher

szaher commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks both — all seven points verified against the code before changing anything, and every one held. Pushed as 7bc90b4.

Rescoped, per @pawelpaszki's third blocking point. RHOAIENG-98942 stays open and is back In Progress; the remaining spec-tree assembly is RHOAIENG-99560. This PR closes RHOAIENG-98943 only. The description now has a "What is not done" section so the split is explicit.

Blocking

  1. Standalone GCS FT now calls gcs_fault_tolerance_options() — and that leftover ValueError was indeed the uncovered line Codecov flagged.
  2. worker_replica_counts() is wired into both builders; format_resources_param() too, which was RayJob-only.
  3. Rescoped as above rather than rushed.

Non-blocking — all four fixed. The parity-test one was the sharpest: six fields really were being filtered out of the parametrized test, and the additional_worker_groups sentinel was an empty list, so extra groups had no coverage at all. They are now asserted against sanitised spec dicts with a real WorkerGroup sentinel, and I mutation-checked that dropping either GCS FT or additional worker groups from the RayJob path now fails the suite.

@chipspeak on the walkthrough — agreed, and it is bigger than this PR: #1176 (client isolation), #1178 (docs/demos), #1179 (facade typing) and this one all changed how the SDK is put together. What landed here is docs/raycluster_spec_parity.md, which covers the spec builders specifically. I would rather write the broader walkthrough as its own PR than bolt it on here — happy to pick that up next if you want it under the same epic.

One gap deliberately left open and recorded on RHOAIENG-99560: the parity test enumerates ClusterConfiguration only, so the extended_resource_requests field added to WorkerGroup in RHOAIENG-59978 has no parity guard yet.

@pawelpaszki

pawelpaszki commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Re-reviewed 7bc90b4. The previous code-level blockers are addressed: both paths now call gcs_fault_tolerance_options() / worker_replica_counts(), default groups use format_resources_param(), and the parity test actually covers the structural fields.

Still blocking /lgtm:

  • PR body is still the empty template (the fix commit said this was answered there). Please fill Why / Related issue / Architecture, link RHOAIENG-98942 / 98943, and note 98949 stays open.
  • RHOAIENG-98942 AC is still only half-met: shared leaves, not one canonical builder. Head/worker assembly and _build_worker_group_spec vs _build_additional_worker_group_spec remain duplicated. Do not close 98942 on merge.

Nit: standalone extra groups still inline json.dumps instead of format_resources_param().

@chipspeak

Copy link
Copy Markdown
Contributor

Thanks both — all seven points verified against the code before changing anything, and every one held. Pushed as 7bc90b4.

Rescoped, per @pawelpaszki's third blocking point. RHOAIENG-98942 stays open and is back In Progress; the remaining spec-tree assembly is RHOAIENG-99560. This PR closes RHOAIENG-98943 only. The description now has a "What is not done" section so the split is explicit.

Blocking

  1. Standalone GCS FT now calls gcs_fault_tolerance_options() — and that leftover ValueError was indeed the uncovered line Codecov flagged.
  2. worker_replica_counts() is wired into both builders; format_resources_param() too, which was RayJob-only.
  3. Rescoped as above rather than rushed.

Non-blocking — all four fixed. The parity-test one was the sharpest: six fields really were being filtered out of the parametrized test, and the additional_worker_groups sentinel was an empty list, so extra groups had no coverage at all. They are now asserted against sanitised spec dicts with a real WorkerGroup sentinel, and I mutation-checked that dropping either GCS FT or additional worker groups from the RayJob path now fails the suite.

@chipspeak on the walkthrough — agreed, and it is bigger than this PR: #1176 (client isolation), #1178 (docs/demos), #1179 (facade typing) and this one all changed how the SDK is put together. What landed here is docs/raycluster_spec_parity.md, which covers the spec builders specifically. I would rather write the broader walkthrough as its own PR than bolt it on here — happy to pick that up next if you want it under the same epic.

One gap deliberately left open and recorded on RHOAIENG-99560: the parity test enumerates ClusterConfiguration only, so the extended_resource_requests field added to WorkerGroup in RHOAIENG-59978 has no parity guard yet.

Thanks @szaher and yeah for sure we can do the walkthrough elsewhere, just wanted to flag it here for posterity prior to merge. Happy to LGTM once Pawel's re-review is addressed.

@szaher

szaher commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Re-review addressed — bf1618a.

PR body — rewritten against the repo template: Why are these changes needed? / Related issue number / Checks / Architecture, with the boxes filled. Worth noting it was not empty when you looked — I had set a description a minute before your comment landed, but it used my own headings rather than the template's, so the ask stands either way. It now links 98943 (closes), 98942 (partial, do not close on merge), 99560 (follow-up) and records that 98949 stays open — local_queue is classified CONFLICTED in the audit, not resolved here.

98942 AC — agreed, and nothing in this PR claims otherwise. The description has a "What is not done" section naming the same three items you did: head/worker assembly, the two _build_*_worker_group_spec copies, and labels as PARTIAL. The ticket is back In Progress and RHOAIENG-99560 carries the remainder with its acceptance criteria.

Nit — real, fixed. _build_worker_group_spec was the last inline json.dumps for rayStartParams; it now calls format_resources_param(). The one remaining json.dumps in that module is the YAML file writer, which is unrelated.

354 tests pass in ray/, lint and mypy clean.

@chipspeak understood, and thanks for flagging it on the record — I will open the walkthrough as a separate PR under the same epic.

@pawelpaszki

Copy link
Copy Markdown
Contributor

happy to approve once the PR is squashed

@chipspeak

Copy link
Copy Markdown
Contributor

happy to approve once the PR is squashed

+1

@szaher

szaher commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

/hold

@openshift-ci openshift-ci Bot added the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label Oct 8, 2026
szaher added a commit that referenced this pull request Oct 9, 2026
Review from @pawelpaszki on #1182. All points verified against the code
before changing anything; every one of them held.

Blocking:

- The standalone builder still inlined the GCS fault tolerance block instead
  of calling the helper extracted for it, which is exactly the duplication
  98942/98943 were meant to remove. It now calls
  gcs_fault_tolerance_options(). The uncovered line Codecov flagged in
  build_ray_cluster.py was indeed that leftover ValueError.
- worker_replica_counts() was dead — only its own tests called it. Both
  builders now use it, replacing two copies of the replicas / minReplicas /
  maxReplicas branch.
- format_resources_param() was used on the RayJob side only; the standalone
  path inlined json.dumps. It now calls the shared one.

Non-blocking:

- The parity test claimed more than it checked. Fields whose MARKERS entry
  was None were filtered out of the parametrized test entirely, so
  num_workers, min_workers, max_workers, enable_autoscaling, enable_gcs_ft
  and additional_worker_groups were never parity-checked, and the
  additional_worker_groups sentinel was an empty list. Those fields are now
  listed in STRUCTURAL and asserted against the sanitised spec dicts rather
  than by substring on repr(), and the sentinel is a real WorkerGroup.
  Mutation-checked: dropping GCS FT or additional worker groups from the
  RayJob path fails the suite.
- Bottom-of-file imports moved to the top; there was no cycle, so the
  noqa: E402 pair was covering nothing.
- Deleted the ODH volumes section heading left stranded above
  build_ray_cluster_spec.
- docs: labels moved out of SUPPORTED into a PARTIAL section. Both paths
  carry them, but only the RayJob path puts them on the default head and
  worker pods, so a NetworkPolicy selector still behaves differently.
  Fixing that changes standalone output, so it belongs with the follow-up.
- docs: added an explicit list of what is shared versus what each builder
  still assembles independently.

The remaining blocking point — that the tree is still duplicated and 98942
should not close as done — is answered in the PR description rather than
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@szaher
szaher force-pushed the RHOAIENG-98942-unify-raycluster-spec branch from bf1618a to 2fa3fe1 Compare October 9, 2026 06:54
@szaher szaher changed the title RHOAIENG-98942: share the RayCluster spec builders, fix the fields RayJob dropped RHOAIENG-98942: share the RayCluster spec builders (no behaviour change) Oct 9, 2026
@szaher

szaher commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Split, and the scope has narrowed — 2fa3fe1. This PR now changes no cluster or job behaviour.

We are releasing without this, so I went back over it for release risk and found something that changes the GCS FT story.

GCS fault tolerance is out of this PR, and 98943 is reopened

52a351a — "RHOAIENG-30720: Remove GCS FT for Lifecycled RayClusters", August 2025 — removed those four fields from the lifecycled path: fields, validation and emission. RHOAIENG-30720 is "Head pod persistence doesn't work" — GCS FT was broken, state was lost on head-pod restart — and that ticket says explicitly:

This issue is scoped only to the RayCluster logic; the RayJob implementation is unaffected and out of scope for this ticket.

So the RayJob path is not dropping GCS FT by accident. I read the audit's "absent on one side" as drift and did not check why. Emitting it would have re-enabled, on a path nobody has validated, a feature that was removed because it did not work.

What #1091 genuinely broke is narrower: making ClusterConfiguration shared means the four fields are now accepted and validated on the RayJob path and then ignored. Rejecting them or emitting them after validation is RHOAIENG-98943's call — reopened with the history, not closed by this PR. The asymmetry is pinned by test_gcs_fault_tolerance_is_standalone_only so neither side moves by accident.

labels moved to #1184

Two lines, no dependency on this refactor, reviewable on its own.

It is also reclassified PARTIAL rather than a gap, and the reason is worth recording: once the sentinel config gained a real WorkerGroup (per your review), a presence check started passing on both paths, because the RayJob builder merges config.labels into additional worker group pods. What actually differs is the default head and worker pods. The earlier xfail would have XPASSed. test_labels_land_where_each_path_puts_them now pins where they land on each path instead.

What is left here

Pure consolidation: the shared raycluster_spec.py, both builders rewired, the field audit, and the stale rayjobs rule correction. docs/raycluster_spec_parity.md gains a PENDING DECISION section; counts still total 36 — SUPPORTED 24, PARTIAL 1, PENDING DECISION 4, CONTEXT_ONLY 3, NOT_APPLICABLE 3, CONFLICTED 1.

558 passed, mypy clean, lint-imports 4/4, raycluster_spec.py 100%.

@pawelpaszki your "do not close 98942" still holds and the description says so. @chipspeak the walkthrough is still coming as its own PR.

Comment thread docs/raycluster_spec_parity.md Resolved
openshift-merge-bot Bot pushed a commit that referenced this pull request Oct 9, 2026
ClusterConfiguration(labels=...) reached additional worker groups and
nowhere else on the RayJob path. _build_additional_worker_group_spec merged
config.labels into its pod template, while the head and default worker
templates passed annotations and no labels at all. No error, no warning —
a NetworkPolicy selector or cost-allocation label simply did not appear on
the pods it was meant to select.

Found while auditing the two RayCluster spec builders for RHOAIENG-98942;
not previously tracked by any ticket.

Split out of #1182 so it can ship on its own: two lines of production code,
no change to cluster behaviour beyond pod metadata, and independent of the
builder consolidation that PR is doing.

Mutation-checked: reverting either line fails the new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
szaher and others added 7 commits October 9, 2026 15:09
ClusterConfiguration feeds two independent spec builders — the standalone
RayCluster in ray/cluster/build_ray_cluster.py and the embedded
rayClusterSpec in ray/rayjobs/config.py. A field wired into one and
forgotten in the other raises nothing; the cluster just comes up without
it. Unifying them is a large refactor, so the parity check lands first and
the refactor is held to it.

The test sets every ClusterConfiguration field to a recognisable sentinel,
renders both specs and asserts each sentinel reaches both. Anything that
legitimately does not reach a builder is listed with a reason:

- CONTEXT_ONLY (3): overwrite_default_resource_mapping and
  enable_usage_stats are consumed by __post_init__ (the latter flows on via
  envs); verify_tls governs dashboard calls, not the CR.
- NOT_APPLICABLE (3): name, namespace and write_to_file are meaningless
  inside a RayJob, which derives '<job_name>-cluster' and embeds a spec
  with no metadata block.
- CONFLICTED (1): local_queue is set on both sides with undefined
  precedence (RHOAIENG-98949).

test_every_field_is_classified fails on a new field that is neither given a
sentinel nor classified, so "no field may be silently ignored" is enforced
rather than asserted.

Four fields the RayJob path drops today are xfail(strict=True) rather than
exempt, since they are bugs and the unification has to remove them:

- redis_address, redis_password_secret, external_storage_namespace —
  GCS fault tolerance is entirely absent from the RayJob builder
  (RHOAIENG-98943).
- labels — reaches additional worker groups only; the head and default
  worker pod templates at rayjobs/config.py:146,166 pass annotations and no
  labels. Not previously tracked by any ticket.

Also recorded in the test: build_ray_cluster() is not pure. It reaches the
Kubernetes API three times while rendering — local_queue_exists(),
get_default_local_queue() and validate_autoscaling_with_kueue() — which the
RayJob builder never does. The canonical builder therefore has to split a
pure renderer from the resolve-cluster-facts step, or the RayJob path
inherits network calls it does not make today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The standalone and RayJob builders each carried their own copy of the same
primitives, which is how they drifted in the first place. Both now delegate
to ray/cluster/raycluster_spec.py.

Proven identical to both originals before being removed: the ODH trusted-CA
volumes and mounts (byte-for-byte duplicates), cpu_limit_to_num_cpus (a
literal copy), the storage merge, resource requirements, GPU counting,
extended-resource mapping, and the head and worker containers — the last two
compared by repr against both implementations.

The module is deliberately side-effect-free. build_ray_cluster() reaches the
Kubernetes API three times while rendering — local_queue_exists(),
get_default_local_queue() and validate_autoscaling_with_kueue() — and the
RayJob builder makes no calls at all, so anything needing the cluster stays
in the caller. Lifting that I/O out is the next step.

Two pieces are added here for the fixes that follow rather than used yet:
gcs_fault_tolerance_options() and worker_replica_counts(), both unit tested.

build_ray_cluster.py keeps its existing function names as one-line
delegations, so no call site moved and the diff stays reviewable. Two spec
builders totalling 1,186 lines are now 838 plus a 279-line shared module.

Test patch targets for update_image moved with it, except in
test_build_spec_additional_group_inherits_image: additional worker groups
resolve their own image in rayjobs/config.py and still patch there.

Not yet fixed, still xfail: the five fields the RayJob path drops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The five fields the parity test had marked xfail now reach both builders,
so KNOWN_GAPS is empty and the xfails are gone.

GCS fault tolerance (RHOAIENG-98943) was the dangerous one. enable_gcs_ft,
redis_address, redis_password_secret and external_storage_namespace were
accepted and validated by ClusterConfiguration, then ignored entirely by
the RayJob builder — the four names did not appear anywhere in
rayjobs/config.py. A job configured for fault tolerance came up with no
Redis to recover from, reported nothing, and only failed when the head node
died. build_ray_cluster_spec() now emits gcsFaultToleranceOptions from the
shared helper, the same one the standalone path uses.

labels reached additional worker groups only. _build_additional_worker_group_spec
merged config.labels into its pod template while the head and default worker
templates passed annotations and nothing else, so ClusterConfiguration(labels=...)
landed on extra worker groups and nowhere else. Both templates now pass them.

The asymmetry that remains is deliberate: the standalone path also puts
labels on the RayCluster metadata, which the embedded spec has no room for.
Pod templates are where they can land here, and where this path already put
them for additional worker groups.

Regression tests cover the RayJob path directly rather than relying on the
parity markers, including that metadata stays None when no labels are set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rule

docs/raycluster_spec_parity.md is the audit the ticket asks for: all 36
ClusterConfiguration fields classified SUPPORTED (29), CONTEXT_ONLY (3),
NOT_APPLICABLE (3) or CONFLICTED (1), each with a reason. The page is not
the enforcement — ray/test_builder_parity.py is — but it explains the
decisions the test encodes, including why raycluster_spec.py has to stay
free of Kubernetes API calls.

The rayjobs rule files claimed "Must NOT import from ray.cluster or
ray.client", which stopped being true at #1091: rayjobs/config.py:46 and
rayjob.py:36 both import ClusterConfiguration from ray.cluster.config, and
.importlinter has no contract for it, so CI never caught the contradiction.
Both files now describe the real boundary — cluster.config and
cluster.raycluster_spec are allowed, everything else in ray.cluster is not,
and it is prose-only — and point at the shared module as the place to add a
field.

AGENTS.md's two-builder hazard note said a field added to one builder is
dropped by the other, with no mention of where to put one instead. It now
names raycluster_spec.py, the parity test and the audit page, and carries
the no-I/O constraint.

Every repo path and symbol cited across the four files was checked to
resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review from @pawelpaszki on #1182. All points verified against the code
before changing anything; every one of them held.

Blocking:

- The standalone builder still inlined the GCS fault tolerance block instead
  of calling the helper extracted for it, which is exactly the duplication
  98942/98943 were meant to remove. It now calls
  gcs_fault_tolerance_options(). The uncovered line Codecov flagged in
  build_ray_cluster.py was indeed that leftover ValueError.
- worker_replica_counts() was dead — only its own tests called it. Both
  builders now use it, replacing two copies of the replicas / minReplicas /
  maxReplicas branch.
- format_resources_param() was used on the RayJob side only; the standalone
  path inlined json.dumps. It now calls the shared one.

Non-blocking:

- The parity test claimed more than it checked. Fields whose MARKERS entry
  was None were filtered out of the parametrized test entirely, so
  num_workers, min_workers, max_workers, enable_autoscaling, enable_gcs_ft
  and additional_worker_groups were never parity-checked, and the
  additional_worker_groups sentinel was an empty list. Those fields are now
  listed in STRUCTURAL and asserted against the sanitised spec dicts rather
  than by substring on repr(), and the sentinel is a real WorkerGroup.
  Mutation-checked: dropping GCS FT or additional worker groups from the
  RayJob path fails the suite.
- Bottom-of-file imports moved to the top; there was no cycle, so the
  noqa: E402 pair was covering nothing.
- Deleted the ODH volumes section heading left stranded above
  build_ray_cluster_spec.
- docs: labels moved out of SUPPORTED into a PARTIAL section. Both paths
  carry them, but only the RayJob path puts them on the default head and
  worker pods, so a NetworkPolicy selector still behaves differently.
  Fixing that changes standalone output, so it belongs with the follow-up.
- docs: added an explicit list of what is shared versus what each builder
  still assembles independently.

The remaining blocking point — that the tree is still duplicated and 98942
should not close as done — is answered in the PR description rather than
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review nit from @pawelpaszki: _build_worker_group_spec still inlined
json.dumps for its rayStartParams resources while every other call site
used the shared helper. The remaining json.dumps in this module is the
YAML file writer, which is unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch now changes no cluster or job behaviour. It shares duplicated
spec-building code and records the differences; the two behavioural fixes
have moved out.

GCS fault tolerance is no longer emitted on the RayJob path. Investigating
the release risk turned up 52a351a, "RHOAIENG-30720: Remove GCS FT for
Lifecycled RayClusters", which stripped those four fields from the
lifecycled path in August 2025 because the feature did not work — head pod
restarts lost state — and whose ticket says the RayJob implementation was
out of scope for the fix. So the absence was a decision, not drift, and
emitting them here would re-enable something nobody has validated on that
path. The real bug #1091 introduced is narrower: making
ClusterConfiguration shared means the fields are now accepted and validated
on the RayJob path and then ignored. Rejecting them or emitting them is
RHOAIENG-98943's call; the asymmetry is pinned by
test_gcs_fault_tolerance_is_standalone_only so neither side moves by
accident.

config.labels on the head and default worker pods moves to #1184, two lines
that can ship without this refactor.

labels is reclassified PARTIAL rather than a gap. A presence check passes on
both paths because the RayJob builder merges config.labels into additional
worker group pods; what differs is the default head and worker pods. The
earlier xfail would have XPASSed once the sentinel config gained a real
WorkerGroup, which is how this surfaced.

docs/raycluster_spec_parity.md gains a PENDING DECISION section and the
counts still total 36: SUPPORTED 24, PARTIAL 1, PENDING DECISION 4,
CONTEXT_ONLY 3, NOT_APPLICABLE 3, CONFLICTED 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@szaher
szaher force-pushed the RHOAIENG-98942-unify-raycluster-spec branch from 2fa3fe1 to 8b9e045 Compare October 9, 2026 14:17
Review from @chipspeak: the WorkerGroup sentinel set only group_name and
replicas, so envs, labels, tolerations, image, the GPU settings and
extended resources could all drift between the two builders while this
suite stayed green. Correct — additional_worker_groups is assembled by a
second pair of duplicated functions, _build_worker_group_spec and
_build_additional_worker_group_spec, which can diverge independently of
the main builders this file was written to guard.

All 15 fields now carry sentinels, and the four inherit-or-override
rules get named tests on both paths, because a presence check cannot
express them: image and tolerations are overrides that fall back to the
cluster value, while envs and labels are merges where the group wins a
key collision. The inherit case is asserted separately from the override
case, so dropping either direction fails.

test_every_worker_group_field_is_classified mirrors the existing
ClusterConfiguration gate: a new WorkerGroup field must be given a
sentinel or routed to a named test via WORKER_GROUP_STRUCTURAL.

Also recorded in AGENTS.md, both rayjobs rule files and the audit doc,
as asked.

Mutation-checked, six ways: dropping the cluster-env merge, dropping the
cluster-label merge, ignoring wg.tolerations, ignoring wg.image,
dropping wg extended resources, and adding an unclassified field each
fail the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@szaher
szaher force-pushed the RHOAIENG-98942-unify-raycluster-spec branch from 8b9e045 to ea39c95 Compare October 9, 2026 14:20
Review from @chipspeak: either move it under docs/designs/History or
drop the language about the previous implementation, since a doc that
narrates what changed has to be maintained as a changelog forever.

Keeping it in place and taking the second option. docs/designs/History
holds one superseded design doc, so it is an archive — and this page is
live: AGENTS.md sends contributors here before adding a field, and
test_builder_parity.py names it in a failure message. Filing it under
History would make both pointers misleading.

So the framing is now current-state. Dropped "they were written
separately and drifted", "now live in", "the subject of the follow-up
to this work" and the @-attribution, retitled off "audit", and led with
what the reader needs: read this before adding a field, here is where
each one lands, here is which asymmetries are deliberate. The
duplicated assemblers are stated as a present constraint with the
ticket that tracks them rather than as history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chipspeak

Copy link
Copy Markdown
Contributor

/retest

@chipspeak
chipspeak self-requested a review October 12, 2026 12:19
@openshift-ci openshift-ci Bot added lgtm Indicates that a PR is ready to be merged. approved Indicates a PR has been approved by an approver from all required OWNERS files. labels Oct 12, 2026
@openshift-ci

openshift-ci Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: chipspeak, pawelpaszki

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:
  • OWNERS [chipspeak,pawelpaszki]

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@pawelpaszki
pawelpaszki merged commit d5ac647 into main Oct 12, 2026
25 of 31 checks passed
@pawelpaszki
pawelpaszki deleted the RHOAIENG-98942-unify-raycluster-spec branch October 12, 2026 12:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. jira/valid-reference lgtm Indicates that a PR is ready to be merged. test-additional-notebooks test-guided-notebooks Run PR check to verify Guided notebooks test-ui-notebooks Run PR check to verify UI notebooks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants