KEP-6089: Workload Aware Scheduling Controller APIs
KEP-6089: Workload Aware Scheduling Controller APIs
- Release Signoff Checklist
- Summary
- Motivation
- Proposal
- Design Details
- Core Principles & Assumptions
- Standardized Building Blocks Definitions (
scheduling.k8s.io) - Job Integration (batch/v1)
- Shared workloadbuilder Go Translation Library
- Reference Integration Examples: JobSet (Multi-Level)
- Recommendations for Multi-Level Composite Controllers
- Go Package Placement & Graduation Strategy
- Test Plan
- Graduation Criteria
- Upgrade / Downgrade Strategy
- Version Skew Strategy
- Production Readiness Review Questionnaire
- Implementation History
- Drawbacks
- Alternatives
Release Signoff Checklist
Items marked with (R) are required prior to targeting to a milestone / release.
- (R) Enhancement issue in release milestone, which links to KEP dir in kubernetes/enhancements (not the initial KEP PR)
- (R) KEP approvers have approved the KEP status as
implementable - (R) Design details are appropriately documented
- (R) Test plan is in place, giving consideration to SIG Architecture and SIG Testing input (including test refactors)
- e2e Tests for all Beta API Operations (endpoints)
- (R) Ensure GA e2e tests meet requirements for Conformance Tests
- (R) Minimum Two Week Window for GA e2e tests to prove flake free
- (R) Graduation criteria is in place
- (R) all GA Endpoints must be hit by Conformance Tests within one minor version of promotion to GA
- (R) Production readiness review completed
- (R) Production readiness review approved
- “Implementation History” section is up-to-date for milestone
- User-facing documentation has been created in kubernetes/website, for publication to kubernetes.io
- Supporting documentation—e.g., additional design documents, links to mailing list discussions/SIG meetings, relevant PRs/issues, release notes
Summary
This KEP proposes a standardized set of reusable API building blocks (scheduling.k8s.io),
integration guidelines, and shared libraries to simplify how workload controllers (e.g., JobSet,
TrainJob, RayJob, LWS, SparkApplication, as well as core workloads like Job, Deployment, and StatefulSet)
integrate with Workload-aware Scheduling (WAS).
In v1.37, this KEP and the reusable building blocks entered Alpha alongside the core Job integration (KEP-5547).
For v1.38, this KEP is promoted to Beta. To support embedding into GA v1 workload APIs (batch/v1, apps/v1),
the reusable building block structs graduate directly into scheduling.k8s.io/v1 (with Go type aliases maintained in
scheduling.k8s.io/v1alpha3 for backward compatibility), formalizing stable primitives for the entire Kubernetes ecosystem.
By providing common API primitives (such as topology constraints and disruption policies) and a
shared library (workloadbuilder) to handle boilerplate resource generation, we enable controller developers to
easily expose WAS features natively within their APIs without reinventing the wheel, while
ensuring a consistent user experience across the Kubernetes ecosystem.
Motivation
The Kubernetes ecosystem has steadily evolved its scheduling capabilities from a strictly pod-centric model towards a more robust, workload-centric approach. This transition successfully established foundational features in the recent v1.36 release, such as Gang Scheduling, Topology-aware Scheduling (TAS), and Workload-aware Preemption (WAP).
However, the Workload, PodGroup, and CompositePodGroup resources backing these features were designed primarily as
intermediate, scheduler-facing APIs. We have not yet addressed how end-users of higher-level
workload controllers (such as Job, LWS, JobSet, or RayJob) should express their scheduling
requirements to utilize these features.
For example, in the first alpha release of KEP-5547 (Job Integration), in v1.36 — before this KEP
(KEP-6089) was established — we intentionally bypassed the user-facing API design challenge.
Instead, the integration automatically created a PodGroup with a hardcoded Gang policy under
specific conditions (e.g., for fully parallel static indexed Jobs). While this unblocked initial
adoption, it was fundamentally insufficient. Users have diverse use cases and require the ability
to express explicit intent—such as opting in or out of gang scheduling, requesting specific
topologies, or configuring disruption policies for their workloads.
Without this KEP, there is no standardized way for workload controllers to expose these user intents, nor is there a standard mechanism for controllers to translate user intent into underlying scheduling objects. If every controller authors its own user-facing API structs and custom logic to manage scheduling objects, the ecosystem will suffer from inconsistent UX, duplicate effort, and varied levels of WAS support.
We need a standardized toolkit that provides common scheduling API structures, handles the boilerplate compilation, and establishes architectural guidelines to solve common integration challenges across the ecosystem. This proposal aims to fill these gaps, providing shared tooling and best practices while still allowing controller owners the flexibility to design their root APIs natively.
In v1.37, this KEP (KEP-6089) addressed this gap by introducing standardized API building blocks under scheduling.k8s.io/v1alpha3
and the shared workloadbuilder library in k8s.io/component-helpers, validated via the core Job API (batch/v1) integration.
Ecosystem Momentum and Controller Adoption
Since the v1.37 alpha release, the Kubernetes ecosystem has enthusiastically embraced these building blocks
and the workloadbuilder library. Seven controllers spanning batch, distributed ML training, inference serving,
and core workloads have designed or implemented native integrations:
- JobSet (out-of-tree): KEP-969
and implementation (kubernetes-sigs/jobset#1250) adopt the
workloadbuilderlibrary to compile hierarchicalCompositePodGroupandPodGroupresources using the centralized targeted-policies pattern. - LeaderWorkerSet (LWS) (out-of-tree): KEP-666 embeds the building blocks directly to provide gang scheduling for replica groups in multi-host AI/ML training and inference.
- Kubeflow Trainer (TrainJob) (out-of-tree): KEP-3015 adopts KEP-6089’s
targeted-policy model to create
Workloadblueprints andPodGroupobjects for complex multi-node training runtimes. - Spark Operator (SparkApplication) (out-of-tree): KEP-2962
embeds the leaf building blocks and vendors
workloadbuilderfromk8s.io/component-helpersto compileWorkloadand attempt-scopedPodGroupresources for Spark executor gangs. - Core Deployment (in-tree): KEP-6276 embeds the building
blocks directly into
apps/v1.DeploymentSpecto enable gang scheduling and topology constraints for long-running services. - Core StatefulSet (in-tree): KEP-6277 embeds the building
blocks directly into
apps/v1.StatefulSetSpecto support coordinated gang scheduling and topology placement for stateful sets. - KubeRay (out-of-tree): ray-project/kuberay#4962 explores Workload
Aware Scheduling integration for
RayJobandRayCluster. Promoting the building blocks tov1provides a stable API target for aligning its batch scheduling provider with upstream WAS standards.
Goals
Define reusable API primitives (e.g., Scheduling Policies, Topology Constraints, Disruption Modes) under
scheduling.k8s.ioto be consumed by real-workload controllers.Provide a shared library (
workloadbuilder) to handle the boilerplate of constructing underlying scheduling objects (Workload,PodGroup, orCompositePodGroup) from controller-specific intents.Establish architectural guidelines for workload controllers to expose WAS features consistently.
Validate the building blocks and translation library against real controllers to ensure we are not designing in a vacuum:
- Standard
Job(batch/v1) serves as the reference in-tree implementation for single-level workloads (targeting Beta promotion in v1.38 in KEP-5547, unblocked by this KEP). JobSetserves as the reference out-of-tree composite workload (JobSet KEP-969).
- Standard
Provide reference integration examples demonstrating how workload controllers can adopt WAS Controller APIs, covering both single-level workloads (standard
Job) and multi-level composite workloads (JobSet).
Non-Goals
Define a single, mandatory and rigid scheduling API struct for all Kubernetes workload controllers.
Implement the actual integration of these new API blocks into other complex composite controllers (such as
JobSet,LeaderWorkerSet, or KubeflowTrainJob) as part of this KEP. While this KEP establishes the design guidelines and shared library for their integration, the implementation PRs for these out-of-tree controllers will be pursued independently in their respective repositories.Create or manage the lifecycle of
Workload,PodGroup, orCompositePodGroup.
Proposal
This proposal builds on the enhancements that have been recently introduced in the workload-aware
scheduling space. We assume that the reader is already acquainted with the KEPs listed in the
kep.yaml see-also section.
Reusable API Building Blocks
We propose introducing a set of standard, reusable structs in the scheduling.k8s.io API group.
Controller developers can embed these structs directly into their native APIs. This ensures that
when a user configures a TopologyConstraint on a RayJob, it uses the exact same schema and
semantics as a TopologyConstraint on a TrainJob.
Shared workloadbuilder Library
To prevent every controller from writing custom logic to translate these API blocks into underlying scheduling resources, we will provide a shared Go library. Controller developers will map their custom API surface to an intermediate representation, and the library will handle:
- Generating the correct
Workload,PodGroup, orCompositePodGrouphierarchies. - Applying sane scheduling defaults based on the controller’s semantic purpose (e.g., defaulting
to standard pod-by-pod scheduling for a core
Jobto explicitly prevent breaking existing CI/CD pipelines). - Handling standard validation logic.
Integration Recommendations & Controller Autonomy
Instead of forcing a one-size-fits-all API shape, we provide recommendations on how these building blocks can be exposed, leaving the final design decisions to the controller owners. This approach prioritizes local consistency over global uniformity. While this may introduce a degree of API fragmentation across the ecosystem, it is a necessary and acceptable trade-off to ensure each controller’s API remains idiomatic and intuitive for its specific users.
This autonomy is particularly crucial for complex, multi-level controllers that rely on resource
composition. If we mandated a strict, unified API shape that relied on downward API propagation,
we would introduce severe upstream dependency bottlenecks. For example, TrainJob relies on JobSet,
which in turn relies on the core Job API. Requiring bottom-up integration would block TrainJob
users for months while waiting for the underlying components to adopt the standard. By granting
controllers autonomy, they can implement patterns native to their architecture—such as JobSet
using its established targetReplicatedJobs pattern to apply scheduling constraints to underlying
Jobs—delivering value to users immediately without waiting for the entire dependency chain to
resolve. This pattern was subsequently formalized and successfully materialized in practice in
JobSet (KEP-969)
and Kubeflow Trainer / TrainJob (KEP-3015).
Job Integration - API Usage Examples
This KEP proposes enriching the core Job API to allow users to express their scheduling intents
through a composed scheduling configuration. The following examples show how this API represents
different Workload-aware Scheduling intents:
Example 1: Job with Gang Scheduling, Zone Topology, and Atomic Disruption
A batch ML training Job where all 4 pods must schedule together atomically (All-or-Nothing),
must co-locate within the same availability zone, and must be treated as a single unit for
disruptions (meaning if one pod is preempted, the entire group is disrupted together):
apiVersion: batch/v1
kind: Job
spec:
parallelism: 4
completions: 4
scheduling: # New API field - scheduling intent
schedulingPolicy:
gang: {} # MinCount is omitted: Job defaults MinCount = parallelism (4)
schedulingConstraints:
topology:
- level: "topology.kubernetes.io/zone"
disruptionMode:
all: {} # DisruptionMode resolves to All (entire group must be disrupted together)
template:
spec:
containers:
- name: train-node
image: training-image:v1
Example 2: Backward Compatibility and Sane Defaulting (Implicit Opt-Out)
A standard Job manifest where the scheduling block is omitted entirely. This natively defaults
to standard Kubernetes pod-by-pod scheduling (Basic mode), ensuring 100% backward compatibility
and eliminating the need for an explicit opt-out mechanism:
apiVersion: batch/v1
kind: Job
spec:
parallelism: 10
completions: 10
# The scheduling block is completely omitted (which defaults to Basic scheduling
# and single disruption).
# This effectively acts as an implicit opt-out from gang scheduling in the Job integration.
template:
spec:
containers:
- name: processor
image: processor-image:v1
User Stories
Story 1: The End-User
As a ML engineer submitting distributed training workloads to a cluster, I want to explicitly
define my scheduling requirements — such as requesting that all worker Pods are scheduled together
(gang scheduling) and placed within the same network rack (topology constraint) — directly within
my workload’s YAML manifest. I expect these scheduling configurations to be intuitive,
well-documented, and to use a similar structure and vocabulary whether I am submitting a JobSet,
a LWS resource, or a company-internal batch job.
Story 2: The Controller Maintainer
As a maintainer of a single-level workload controller, such as the core Job API, I want to add
Workload-aware Scheduling capabilities to my API without having to design custom struct fields
from scratch or write reconciliation logic to manage scheduler-specific objects like PodGroup. By
importing standard API primitives from scheduling.k8s.io into my API schema and using a shared
builder library in my controller’s reconcile loop, I can easily expose features like gang
scheduling to my users while ensuring consistency with the rest of the ecosystem.
Story 3: The Multi-Level Controller Maintainer
As a maintainer of a multi-level composite controller (e.g., JobSet which creates Jobs, or a
custom training operator composing LWS), I want to integrate WAS features using the same standard
API primitives. Furthermore, because my controller relies on composing other Kubernetes resources,
I expect this KEP to provide clear architectural guidelines on how to handle nested scheduling
intent. For example, I need recommendations on whether my parent controller should generate the
PodGroup directly, or if it should delegate that creation to the underlying child controllers.
Risks and Mitigations
API Fragmentation and Inconsistent UX: Because this proposal grants controller owners the autonomy to design and integrate their own API schemas to avoid upstream dependency bottlenecks, there is a risk that different controllers expose Workload-aware Scheduling (WAS) features differently, leading to a fragmented user experience across the ecosystem.
- Mitigation: This is a conscious and deliberate trade-off: we prioritize rapid out-of-tree
ecosystem adoption and native local consistency over delayed global uniformity (
local consistency > global uniformity/fragmentation). To minimize fragmentation, we provide strongly-typed, reusable building blocks (likeSchedulingConstraints,DisruptionMode,SchedulingMode) in thescheduling.k8s.ioAPI group. By following our design recommendations and using these building blocks, controller owners ensure that the JSON/YAML schema shapes remain highly consistent and intuitive for users. In practice, real-world data across 7 integrating controllers (Job, JobSet, LWS, TrainJob, Spark Operator, Deployment, StatefulSet) demonstrates that this risk did not materialize, as the ecosystem consistently converged on the standardized building blocks andworkloadbuilder.
- Mitigation: This is a conscious and deliberate trade-off: we prioritize rapid out-of-tree
ecosystem adoption and native local consistency over delayed global uniformity (
Split-Brain Configurations: Because we preserve controller autonomy, a situation can arise where a composite wrapper controller (such as
JobSetorTrainJob) implements its own custom wrapper-level fields or conventions to expose WAS features. In the meantime, the underlying child resource (such as the coreJobAPI) officially integrates with WAS and introduces its own scheduling fields. This creates a “split-brain” configuration problem where a user ofJobSetcan configure scheduling directives in two parallel, potentially conflicting ways: at the wrapper level, or directly inside the child’s nested template (e.g.,spec.replicatedJobs[*].template.spec.scheduling).Mitigation: The composite controller remains in full control of its API and the translation/propagation of its templates. Since the parent controller is the sole “compiler” of the workload tree, it has several flexible options to resolve this duplication without breaking backward compatibility:
- API Translation and Mapping: The parent controller can map its existing wrapper-level
fields to the compiled
Workloadresource, while explicitly stripping or ignoring the child’s nested scheduling fields in the generated templates before applying them to prevent conflicts. - Gradual Deprecation: The parent controller can choose to gradually deprecate its custom duplicate wrapper-level fields over several minor releases in favor of the child’s native embedded fields, guiding users to a unified configuration path.
- Conflict Validation: The parent controller’s validating webhooks can reject requests where a user attempts to populate both wrapper-level and child-template-level scheduling fields for the same workload, preventing ambiguous configurations.
In practice, composite controllers (such as
JobSetandTrainJob) introduce their own wrapper-level fields and avoid split-brain ambiguity by enforcing validation that prevents users from configuring inner controllers’ scheduling fields or annotations in nested child templates.- API Translation and Mapping: The parent controller can map its existing wrapper-level
fields to the compiled
Design Details
Core Principles & Assumptions
Integration of Workload-aware Scheduling (WAS) into workload controllers is guided by the following design principles:
- The Root Controller as the Compiler: Regardless of whether a workload is a simple,
single-level resource (like a core
Job) or a complex, multi-level composite resource (likeJobSetorTrainJob), the low-level scheduler-facingWorkloadresource is always compiled, created, and managed strictly by the root-most controller (the Root Controller):- Full Context Visibility: Only the root-most controller has the complete, high-level view
of the entire workload structure and its logical orchestration (e.g.,
JobSetknows all itsreplicatedJobsand their parallelism, whereas a single childJobonly knows its own pods). - Ownership & Skip Logic: Child controllers (like standard
Job) observe theirOwnerReferencepointing to a registered parent workload and explicitly bypass creating anyWorkloadobjects. This prevents duplicate resource creation and guarantees a single source of truth. However, becausePodGroupis the runtime representation of theWorkloadblueprint, child controllers may still be responsible for instantiating the correspondingPodGroupobjects themselves (or delegating this to the root controller depending on the integration design).
- Full Context Visibility: Only the root-most controller has the complete, high-level view
of the entire workload structure and its logical orchestration (e.g.,
- Separation of Structure and Policy: The integration strictly separates real-workload
structure from scheduling policies:
- The Controller API owns the Structure: The true workload API definition (e.g.,
JobSetorLWSschemas) fully defines its own shape, hierarchy, and replication mechanics. The user does not need to manually repeat this structure to the scheduler. - The User owns the Policy: The user knows how they want the workload to be scheduled based on their specific environment (e.g., “I want gang scheduling”, “I need these workers colocated on the same network rack”).
- The Controller acts as a Translator: The real-workload controller consumes the user’s
high-level policy intent, combines it with its own structural knowledge, and acts as a
compiler to generate the low-level
Workloadobjects for the scheduler.
- The Controller API owns the Structure: The true workload API definition (e.g.,
- Universal Representation: Legacy, standard pod-by-pod scheduling is represented natively as
a first-class citizen (
Basicmode). Controllers always generate the underlyingWorkloadobjects, using basic scheduling as the backward-compatible default for true workloads. - Sane Defaults and Escape Hatches: Controllers balance their native orchestration purpose
with backward compatibility by providing sensible defaults (e.g. standard
Jobdefaulting toBasic,LWSdefaulting to a Set of Gangs). Integrated Controllers must provide explicit escape hatches allowing users to override these default templates (e.g., opting out of LWS’s default local gang back toBasic).
Standardized Building Blocks Definitions (scheduling.k8s.io)
Following the structure of the PodGroup and CompositePodGroup APIs under development, the shared
building block primitives are categorized into distinct levels representing the layers of the
workload tree:
- Leaf Level (
PodGroup): Prefixed withWorkloadPodGroup.... These primitives group pods directly and represent standard execution boundaries. - Composite Level (
CompositePodGroup): Prefixed withWorkloadCompositePodGroup.... These primitives coordinate groups of workloads.
This level-specific categorization allows independent API evolution. As a general design
philosophy, when a structure represents a concrete, physical “real-world” scheduling concept used
verbatim by the scheduling stack (such as TopologyConstraint from KEP-5732), we reuse it
directly across all levels. For higher-level policy abstractions introduced by this WAS layer, we
define distinct level-specific types (such as WorkloadPodGroupSchedulingPolicy) to ensure they
can evolve independently at each hierarchy level.
The WorkloadPodGroup and WorkloadCompositePodGroup prefixes are used to avoid name collisions
with other scheduling field structures defined directly in the scheduling.k8s.io group
(e.g., KEP-5732’s PodGroup structures).
To keep this specification concise and focused, we only define the detailed Go API structs for
the leaf-level PodGroup specific types. An analogous set of types prefixed with
WorkloadCompositePodGroup... is provided under the same API group.
The Go definitions are structured as follows. The shipped types carry declarative-validation (DV) markers and are declared as +union where exactly one member must be set:
// API Group: scheduling.k8s.io/v1alpha3
// WorkloadPodGroupSchedulingConstraints defines leaf-level scheduling constraints, such as topology.
type WorkloadPodGroupSchedulingConstraints struct {
// Topology specifies desired topological placements for all pods
// within the scheduling group.
// +optional
Topology []TopologyConstraint `json:"topology,omitempty"`
}
// WorkloadPodGroupDisruptionMode defines how individual pods within a group can be disrupted.
// Exactly one mode must be set.
type WorkloadPodGroupDisruptionMode struct {
// Single specifies that pods can be disrupted independently from each other.
// +optional
Single *WorkloadPodGroupSingleDisruptionMode `json:"single,omitempty"`
// All specifies that all pods in the group must be disrupted together.
// +optional
All *WorkloadPodGroupAllDisruptionMode `json:"all,omitempty"`
}
// WorkloadPodGroupSingleDisruptionMode indicates that individual pods can be disrupted independently.
type WorkloadPodGroupSingleDisruptionMode struct {
// Intentionally empty for now.
}
// WorkloadPodGroupAllDisruptionMode indicates that all pods in the group must be disrupted together.
type WorkloadPodGroupAllDisruptionMode struct {
// Intentionally empty for now.
}
// WorkloadPodGroupSchedulingPolicy defines the scheduling policy for a group of pods.
// Exactly one policy must be set.
type WorkloadPodGroupSchedulingPolicy struct {
// Basic specifies that standard, pod-by-pod Kubernetes scheduling behavior should be used.
// +optional
Basic *WorkloadPodGroupBasicSchedulingPolicy `json:"basic,omitempty"`
// Gang specifies all-or-nothing scheduling semantics.
// +optional
Gang *WorkloadPodGroupGangSchedulingPolicy `json:"gang,omitempty"`
}
// WorkloadPodGroupBasicSchedulingPolicy indicates standard Kubernetes scheduling behavior.
type WorkloadPodGroupBasicSchedulingPolicy struct {
// Intentionally empty for now.
}
// WorkloadPodGroupGangSchedulingPolicy defines the parameters for gang (all-or-nothing) scheduling.
type WorkloadPodGroupGangSchedulingPolicy struct {
// MinCount is the minimum number of pods that must be scheduled
// at the same time for the scheduler to admit the entire group. must be >= 1 when set
// If omitted, the controller should inject a context-specific sane default.
// +optional
MinCount *int32 `json:"minCount,omitempty"`
}
// WorkloadPodGroupResourceClaim references dynamic resource claims for the group.
// Exactly one of ResourceClaimName or ResourceClaimTemplateName must be set.
type WorkloadPodGroupResourceClaim struct {
// Name uniquely identifies this resource claim inside the group.
Name string `json:"name"`
// ResourceClaimName is the name of a ResourceClaim object in the same namespace.
// +optional
ResourceClaimName *string `json:"resourceClaimName,omitempty"`
// ResourceClaimTemplateName is the name of a ResourceClaimTemplate object.
// +optional
ResourceClaimTemplateName *string `json:"resourceClaimTemplateName,omitempty"`
}
The composite level provides the analogous WorkloadCompositePodGroup... set under the same API
group, following the same shapes. Its policy building block
(WorkloadCompositePodGroupSchedulingPolicy) uses minGroupCount (the minimum number of child
groups, not pods) in place of the leaf’s minCount.
Embedding the building blocks & immutability.
These structs are meant to be embedded verbatim into a controller’s own API next to its other
scheduling fields (for example, in a Job’s spec.scheduling).
The building blocks themselves are not marked +k8s:immutable because this would force that decision
on every embedder, but different controllers make different choices (one may recreate the
Workload/PodGroup on change; another may freeze it). Instead. The scheduling policy is a
special case as the variant (basic/gang) must be frozen while
gang.minCount/gang.minGroupCount stays mutable (to support scaling).
Job Integration (batch/v1)
To deliver native, typed Workload-aware Scheduling support in core Kubernetes, we propose
integrating the standardized building blocks directly into the core Job API (batch/v1).
The new fields in the Job API follow the standard process to graduate to a stable type:
the new fields are gated behind a feature gate and progress through the usual Alpha → Beta → Stable
maturity levels, with the field cleared on write and ignored on read while the gate is disabled.
This integration serves as the foundational implementation (“blazing the path”) that demonstrates the viability of these building blocks before out-of-tree controllers adopt them. More design details are covered in KEP-5547.
API Changes
We will introduce a new Scheduling field inside JobSpec. This field embeds a curated, composed
structure consisting of the standardized building blocks:
// API Group: batch/v1
// JobSpec defines the desired state of a Job.
type JobSpec struct {
// ... existing fields ...
// Scheduling defines the Workload-aware Scheduling configuration for this Job.
// This field is alpha-gated by the WorkloadWithJob feature gate.
// +optional
Scheduling *JobSchedulingConfiguration `json:"scheduling,omitempty"`
}
// JobSchedulingConfiguration composes the reusable WAS building blocks.
type JobSchedulingConfiguration struct {
// SchedulingPolicy defines the gang or basic scheduling rules for this Job.
// +optional
SchedulingPolicy *schedulingv1alpha3.WorkloadPodGroupSchedulingPolicy `json:"schedulingPolicy,omitempty"`
// SchedulingConstraints defines topology co-location constraints for the Job's pods.
// +optional
SchedulingConstraints *schedulingv1alpha3.WorkloadPodGroupSchedulingConstraints `json:"schedulingConstraints,omitempty"`
// DisruptionMode specifies how the pods in this Job should be disrupted (Single vs All).
// +optional
DisruptionMode *schedulingv1alpha3.WorkloadPodGroupDisruptionMode `json:"disruptionMode,omitempty"`
// ResourceClaims specifies dynamic resource claims shared across the Job's pods.
// +optional
ResourceClaims []schedulingv1alpha3.WorkloadPodGroupResourceClaim `json:"resourceClaims,omitempty"`
}
Shared workloadbuilder Go Translation Library
To prevent every workload controller (both core and out-of-tree) from writing custom, translation
and validation logic, we propose providing a shared Go library: workloadbuilder.
Package placement: The library ships from staging as the workloadbuilder package under
k8s.io/component-helpers/scheduling/schedulingv1/workloadbuilder. It is scoped as helpers shared by multiple
core binaries, keeps a minimal dependency surface (no external deps), and is meant for this
kind of scheduling-API translation. k8s.io/kube-scheduler was considered but
carries heavier dependencies and is a less natural import for out-of-tree controllers.
1. Design & Architecture
This library utilizes an Intermediate Representation (IR) tree pattern. The architecture adopts a
Polymorphic Bridge Pattern to reconcile the level-specific K8s API structures (leaf-level
PodGroup vs. composite-level CompositePodGroup) with a single, uniform tree definition inside
the library:
- Hierarchy-Agnostic Library IR: The library defines its own internal, polymorphic structures
(
workloadbuilder.SchedulingConfig,workloadbuilder.SchedulingPolicy, etc.) that represent scheduling configurations in a hierarchy-agnostic way. - Standard Mapping: To prevent controllers from writing custom translation boilerplate to bridge
K8s API types to the library IR, the library maps the public, level-specific building blocks into
its polymorphic IR internally. A controller records its user’s intent on each node’s
Input: aWorkloadInputthat pairs each leaf-level building block with the field path (PathElements) where it lives in the controller’s API, so validation errors are reported at the exact field. The composite level provides the analogous input forCompositePodGroupbuilding blocks.
Controller authors construct a logical tree using WorkloadItem representing their workload
structure, populate each node’s DefaultConfig (the controller’s sane defaults) and Input (the
user’s intent), and invoke the builder.
The library encapsulates the following logic:
- Policy Resolution: Merges default configurations with user-provided overrides (e.g.,
resolving escape hatches uniformly across the ecosystem) into each node’s
ResolvedConfig, then applies that node’sCallbacksso controllers can post-process the resolved configuration (e.g. defaulting gangMinCount). - Structural Resolution: Maps the logical tree hierarchy to the corresponding technical
structures in the low-level scheduler
WorkloadAPI, abstracting version variations (e.g. flat templates vs. nested sub-group templates). - Centralized Validation: Rejects invalid configurations early (e.g. ensuring a nested leaf group does not declare a conflicting disruption mode not supported by its parent).
2. Controller Opt-In for New Scheduling Capabilities
Because the building-block types under scheduling.k8s.io are shared across all controllers, new
scheduling options may be added in future releases (e.g. a new scheduling policy or disruption mode)
that do not make sense for every controller. For example, a new policy added in v1.3x might
be valid for JobSet but not for Job.
To prevent new options from silently leaking into controllers that have not been updated to support
them, the workloadbuilder library adopts an allow-list (opt-in) validation approach rather
than a deny-list (opt-out). Controllers declare the specific set of policies and modes they support,
and the library’s validation helpers reject anything not explicitly allowed. This means new
additions to the building-block API are denied by default until a controller explicitly updates
its allow-list.
A controller declares the policies and modes it supports on the builder’s BuildOptions;
Builder.Validate resolves the config and rejects anything outside the allow-list, reporting at the
offending block’s field path:
// In Job's API validation (pkg/apis/batch/validation):
builder := workloadbuilder.NewBuilder(item, workloadbuilder.BuildOptions{
AllowedPolicies: []workloadbuilder.SchedulingPolicyOption{workloadbuilder.BasicPolicy, workloadbuilder.GangPolicy},
AllowedDisruptionModes: []workloadbuilder.DisruptionModeOption{workloadbuilder.SingleMode, workloadbuilder.AllMode},
// The apiserver already runs declarative validation on the building blocks,
// so in-tree Validate performs only the complex controller-policy checks.
DisableDeclarativeValidation: true,
})
allErrs = append(allErrs, builder.Validate(ctx, fldPath, workloadbuilder.ValidationInput{})...)
This gives controllers opt-in semantics: when a new policy is introduced in a future release,
existing controllers (including Job) will reject it until their allow-list is explicitly updated
to include the new option. Out-of-tree controllers get the same guarantee by updating their vendored
library version and extending their allow-list.
Long-term, this pattern can migrate to Declarative Validation (DV) using +k8s:subfield markers,
eliminating the need for hand-written allow-list calls while preserving the same opt-in semantics:
type JobSpec struct {
// ...
// +k8s:subfield(disruptionMode)=+k8s:allowed=single,all
Scheduling *JobSchedulingConfiguration
}
The structural building-block constraints are already generated as DV validators; until subfield
allow-list markers land in DV, the builder’s allow-list checks in Validate serve as a lightweight,
defensive bridge that keeps the overhead minimal for controller integrators.
3. Library API Definition
package workloadbuilder
import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
schedulingv1alpha3 "k8s.io/api/scheduling/v1alpha3"
)
// SchedulingConfig is the polymorphic, hierarchy-agnostic IR model of the PodGroup/CompositePodGroup.
type SchedulingConfig struct {
Constraints *SchedulingConstraints
DisruptionMode *DisruptionMode
Policy *SchedulingPolicy
ResourceClaims []ResourceClaim
// PriorityClassName is copied onto the compiled PodGroupTemplate so a PodGroup
// materialized from it inherits the group's priority.
PriorityClassName string
}
type SchedulingConstraints struct {
Topology []schedulingv1alpha3.TopologyConstraint
}
type DisruptionMode struct {
Single *SingleDisruptionMode
All *AllDisruptionMode
}
type SingleDisruptionMode struct {
// Intentionally empty for now.
}
type AllDisruptionMode struct {
// Intentionally empty for now.
}
type SchedulingPolicy struct {
Basic *BasicSchedulingPolicy
Gang *GangSchedulingPolicy
}
type BasicSchedulingPolicy struct {
// Intentionally empty for now.
}
type GangSchedulingPolicy struct {
MinCount *int32
}
type ResourceClaim struct {
Name string
ResourceClaimName *string
ResourceClaimTemplateName *string
}
// SchedulingConfigFunc post-processes the merged SchedulingConfig after the
// default/user merge. Controllers use it for defaulting (e.g. gang MinCount) but
// it is general-purpose and may perform any controller-specific adjustment.
type SchedulingConfigFunc func(*SchedulingConfig)
// WorkloadItem represents a logical component of a workload (e.g., the whole JobSet,
// a specific ReplicatedJob role, or a single standalone Job).
type WorkloadItem struct {
// Name is the logical identifier of this component; it becomes the
// PodGroupTemplate name and must be non-empty.
Name string
// DefaultConfig is the controller's "sane defaults" for any field the user
// left unset, based on its orchestration domain logic.
DefaultConfig *SchedulingConfig
// Input holds the user's intent for this node as the versioned building blocks
// paired with the field paths where the controller embeds them. Nil sub-fields
// fall back to DefaultConfig field-by-field.
Input WorkloadInput
// Callbacks run in order against the resolved config after the default/user merge.
Callbacks []SchedulingConfigFunc
// Children contains the logical sub-components of this workload.
// - If len(Children) > 0, the node is a structural group (CompositePodGroupTemplate).
// - If len(Children) == 0, the node is a leaf (PodGroup).
//
// Multi-level compilation (children -> CompositePodGroupTemplate) is the designed
// composite extension; the alpha library compiles a single leaf node into one
// PodGroupTemplate and gains composite-tree compilation in a fast-follow (see the
// note below).
Children []*WorkloadItem
}
// WorkloadInput bundles the leaf-level building blocks a controller embeds in its
// own API, each paired with its field path. The zero value means "nothing set", so
// callers only populate the blocks they care about. The composite level provides an
// analogous input for the WorkloadCompositePodGroup* building blocks.
type WorkloadInput struct {
Policy PolicyInput
Constraints ConstraintsInput
DisruptionMode DisruptionModeInput
ResourceClaims ResourceClaimsInput
}
// PolicyInput pairs the scheduling policy building block with its field path.
// PathElements is the path, relative to the WorkloadItem's rootPath, at which the
// block is embedded: e.g. a rootPath of `spec.scheduling` with PathElements
// []string{"schedulingPolicy"} reports errors at `spec.scheduling.schedulingPolicy`.
type PolicyInput struct {
PodGroupData *schedulingv1alpha3.WorkloadPodGroupSchedulingPolicy
PathElements []string
}
type ConstraintsInput struct {
PodGroupData *schedulingv1alpha3.WorkloadPodGroupSchedulingConstraints
PathElements []string
}
type DisruptionModeInput struct {
PodGroupData *schedulingv1alpha3.WorkloadPodGroupDisruptionMode
PathElements []string
}
type ResourceClaimsInput struct {
PodGroupData []schedulingv1alpha3.WorkloadPodGroupResourceClaim
PathElements []string
}
// SchedulingPolicyOption and DisruptionModeOption enumerate the policies and modes
// a controller opts into. Validate rejects anything outside the allow-list.
type SchedulingPolicyOption int
const (
BasicPolicy SchedulingPolicyOption = iota
GangPolicy
)
type DisruptionModeOption int
const (
SingleMode DisruptionModeOption = iota
AllMode
)
// BuildOptions carries the identity of the compiled Workload plus the controller's
// scheduling allow-lists.
type BuildOptions struct {
Name string
Namespace string
// Owner becomes the Workload's controllerRef, used for discovery and GC.
Owner *metav1.OwnerReference
AllowedPolicies []SchedulingPolicyOption
AllowedDisruptionModes []DisruptionModeOption
// DisableDeclarativeValidation skips declarative validation on the input blocks.
// In-tree controllers set this because the apiserver already runs DV.
DisableDeclarativeValidation bool
}
// Builder turns a controller's WorkloadItem tree into scheduler-facing objects.
// Construct it with NewBuilder (or NewBuilderFromExistingWorkload), then call
// Validate, BuildWorkload, and NewPodGroup.
type Builder struct { /* unexported fields */ }
// NewBuilder returns a Builder for the given WorkloadItem tree and options.
func NewBuilder(root *WorkloadItem, opts BuildOptions) *Builder
// NewBuilderFromExistingWorkload returns a Builder that materializes PodGroups
// from an already-persisted Workload rather than compiling one from a WorkloadItem
// tree. Use it when a parent controller (or a hand-authored template) owns and
// compiled the Workload. BuildWorkload is refused and Validate is a no-op; only
// NewPodGroup is meaningful, using opts.Owner for the PodGroup's controller ownerRef.
func NewBuilderFromExistingWorkload(workload *schedulingv1alpha3.Workload, opts BuildOptions) *Builder
// ValidationInput carries the parameters Validate only consults when declarative
// validation is enabled. Its zero value means a create with no previous object,
// which is the common case; a caller running with DisableDeclarativeValidation can
// always pass the zero value.
type ValidationInput struct {
// OldRoot is the previously persisted WorkloadItem. It is nil for a create and
// non-nil for an update. Validate infers the operation from it, so the
// update-time declarative checks run exactly when OldRoot is set. Only
// OldRoot.Name (to correlate it with the new root) and OldRoot.Input (the
// previous versioned data) are consulted; DefaultConfig, Callbacks, and
// Input.*.PathElements are ignored because the resolved config and error paths
// always come from the new root.
OldRoot *WorkloadItem
}
// Validate runs declarative validation on the input blocks (unless disabled) plus
// the controller-policy checks DV cannot express (allow-lists, and cross-field rules
// such as rejecting `all` disruption with the Basic policy), reporting errors at each
// block's field path. For a create, pass the zero ValidationInput; for an update, set
// OldRoot to the previous tree, and Validate runs the update-time checks. A root name
// mismatch between OldRoot and the new root is an invocation-contract bug and is
// reported as an InternalError.
func (b *Builder) Validate(ctx context.Context, rootPath *field.Path, input ValidationInput) field.ErrorList
// BuildWorkload compiles the tree into a Workload, sets its identity and
// controllerRef, and caches the result so PodGroups can be materialized from it.
func (b *Builder) BuildWorkload() (*schedulingv1alpha3.Workload, error)
// NewPodGroup materializes a runtime PodGroup from the named PodGroupTemplate of the
// Builder's Workload (compiled via BuildWorkload or supplied via
// NewBuilderFromExistingWorkload).
func (b *Builder) NewPodGroup(podGroupName, templateName string) (*schedulingv1alpha3.PodGroup, error)
The translation from the versioned building blocks (recorded in each node’s Input) into the
polymorphic IR is performed internally by the library while resolving the tree. A leaf node’s
WorkloadInput maps the WorkloadPodGroup... blocks into the IR, and a composite node maps its analogous WorkloadCompositePodGroup... blocks.
Keeping the mapping internal means there is a single, consistent translation path (exercised by both
Validate and BuildWorkload) rather than a public helper each controller must call correctly.
4. Library Usage Example (Job)
This is how the core Job controller integrates with the workloadbuilder library to compile its
flat Workload structure:
import (
batch "k8s.io/api/batch/v1"
schedulingv1alpha3 "k8s.io/api/scheduling/v1alpha3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
workloadbuilder "k8s.io/component-helpers/scheduling/schedulingv1/workloadbuilder"
"k8s.io/utils/ptr"
)
// mapSchedulingInput translates the Job's user-facing spec.scheduling block into
// a WorkloadInput, pairing each building block with the spec.scheduling sub-path
// where it lives. It returns the zero value for a nil block so the builder falls
// back to the controller's default config.
func mapSchedulingInput(cfg *batch.JobSchedulingConfiguration) workloadbuilder.WorkloadInput {
if cfg == nil {
return workloadbuilder.WorkloadInput{}
}
return workloadbuilder.WorkloadInput{
Policy: workloadbuilder.PolicyInput{
PodGroupData: cfg.SchedulingPolicy,
PathElements: []string{"schedulingPolicy"},
},
Constraints: workloadbuilder.ConstraintsInput{
PodGroupData: cfg.SchedulingConstraints,
PathElements: []string{"schedulingConstraints"},
},
DisruptionMode: workloadbuilder.DisruptionModeInput{
PodGroupData: cfg.DisruptionMode,
PathElements: []string{"disruptionMode"},
},
ResourceClaims: workloadbuilder.ResourceClaimsInput{
PodGroupData: cfg.ResourceClaims,
PathElements: []string{"resourceClaims"},
},
}
}
// defaultMinCountForJob returns a callback that defaults an unset gang MinCount
// to the Job's parallelism, clamped to a minimum of 1 (parallelism may be 0 for
// a suspended Job, but MinCount must be positive). It mutates only the resolved
// config, never writing the derived value back onto the Job's spec.
func defaultMinCountForJob(job *batch.Job) workloadbuilder.SchedulingConfigFunc {
return func(cfg *workloadbuilder.SchedulingConfig) {
if cfg == nil || cfg.Policy == nil || cfg.Policy.Gang == nil {
return
}
if cfg.Policy.Gang.MinCount == nil {
cfg.Policy.Gang.MinCount = new(max(int32(1), ptr.Deref(job.Spec.Parallelism, 1)))
}
}
}
// multiplyMinCountForAdjustedJob is an example of a non-defaulting adjustment: callbacks
// are free to implement arbitrary, controller-specific logic when needed.
func multiplyMinCountForAdjustedJob(job *batch.Job) workloadbuilder.SchedulingConfigFunc {
return func(cfg *workloadbuilder.SchedulingConfig) {
if job.Annotations["isAdjustedJob.example.com"] == "true" {
if cfg.Policy.Gang != nil {
cfg.Policy.Gang.MinCount *= 42
}
}
}
}
// buildWorkloadItem assembles the single-node logical workload tree for a Job.
// The Job defaults to Basic scheduling; the user's spec.scheduling overrides it.
func buildWorkloadItem(job *batch.Job) *workloadbuilder.WorkloadItem {
return &workloadbuilder.WorkloadItem{
Name: podGroupTemplateName(job),
DefaultConfig: &workloadbuilder.SchedulingConfig{
Policy: &workloadbuilder.SchedulingPolicy{Basic: &workloadbuilder.BasicSchedulingPolicy{}},
PriorityClassName: job.Spec.Template.Spec.PriorityClassName,
},
Input: mapSchedulingInput(job.Spec.Scheduling),
Callbacks: []workloadbuilder.SchedulingConfigFunc{defaultMinCountForJob(job)},
}
}
// generateWorkload compiles the Job's spec.scheduling into a Workload via the
// shared workloadbuilder library. BuildWorkload also sets the controller
// ownerReference and spec.controllerRef pointing at the Job.
func (jm *Controller) generateWorkload(job *batch.Job) (*schedulingv1alpha3.Workload, error) {
return workloadbuilder.NewBuilder(buildWorkloadItem(job), workloadbuilder.BuildOptions{
Name: computeWorkloadName(job),
Namespace: job.Namespace,
Owner: metav1.NewControllerRef(job, controllerKind),
}).BuildWorkload()
}
Callbacks are ordinary functions the controller sets on a node; defaultMinCountForJob is the
defaulting one used here, but because a callback receives the resolved config it can apply any
controller-specific adjustment. Note the allow-lists (AllowedPolicies/AllowedDisruptionModes)
are not set on this compile path, they are an apiserver-validation concern applied where the Job’s
validation calls Validate.
Reference Integration Examples: JobSet (Multi-Level)
This section provides reference integration examples demonstrating how a complex,
multi-level composite controller (JobSet) integrates with the Workload-aware
Scheduling (WAS) building blocks and the workloadbuilder library.
During the alpha phase of this KEP, two architectural models were explored:
- Option A (Recommended & Adopted): Centralized ‘Targeted Policies’ Model (Root-only Configuration).
- Option B (Alternative / Rejected): Template Delegation Model (Nested Configuration).
Following community review, the JobSet maintainers officially approved Option A in JobSet KEP-969: Workload-Aware Scheduling Integration (implemented in kubernetes-sigs/jobset#1250). Both patterns are documented below to contrast the architectural trade-offs, with Option A serving as the production reference.
1. Option A: Centralized ‘Targeted Policies’ Model (Root-only Configuration)
In this model—officially adopted by JobSet in KEP-969—all
scheduling configurations are declared centrally inside a single root-level spec.scheduling block.
The nested child templates (replicatedJobs[*].template) remain completely free of scheduling directives,
eliminating schema pollution, duplicate definitions, and split-brain ambiguity.
JobSet structures this centralized scheduling across three distinct levels:
- JobSet Level (Root): Global policy and constraints applied across the entire JobSet (e.g. basic scheduling across components or an overarching group gang).
- ReplicatedJob Level: Target-specific policies mapping to groups of replicated jobs using
targetReplicatedJobs(e.g. gang scheduling all replicas of the worker role, or zone-level topology). - Job Replica Level (
job:): Specific policies applying to each individual Job replica, such as per-job gang scheduling, rack-level topology constraints, disruption mode, and shared DRAresourceClaims.
Example YAML Manifest
apiVersion: jobset.x-k8s.io/v1alpha2
kind: JobSet
metadata:
name: distributed-training
spec:
scheduling: # Root-level centralized scheduling
# 1. JobSet Level: Global policy across all replicated jobs
schedulingPolicy:
basic: {}
replicatedJobs:
# 2. ReplicatedJob Level: Targeting specific ReplicatedJob roles
- targetReplicatedJobs: [worker]
schedulingPolicy:
gang: {} # Gang across all worker replicas
schedulingConstraints:
topology:
- level: "topology.kubernetes.io/zone"
# 3. Job Replica Level: Per-job policies and shared DRA resource claims
job:
schedulingPolicy:
gang: {}
schedulingConstraints:
topology:
- level: "topology.kubernetes.io/rack" # Co-locate pods of each worker replica on same rack
disruptionMode:
all: {}
resourceClaims:
- name: shared-imex-channel
resourceClaimTemplateName: imex-channel-template
replicatedJobs:
- name: driver
replicas: 1
template:
spec:
containers:
- name: main
image: driver-image:v1
- name: worker
replicas: 4
template:
spec:
# Templates remain completely clean of scheduling directives
containers:
- name: worker
image: worker-image:v1
2. Option B: Template Delegation Model (Nested Configuration)
In this alternative model (which was evaluated in JobSet but rejected in favor of Option A), leaf-level scheduling
policies would be declared inside nested child templates (e.g., spec.replicatedJobs[*].template.spec.scheduling),
while global policies (if any) would be declared at the root level (JobSet.spec.scheduling).
This model was rejected in JobSet because:
- It requires users to drill down into deeply nested child templates to define workload-level scheduling policies, fragmenting policy definition across multiple levels of the specification instead of presenting a clean, unified policy declaration at the top level.
- It tightly couples the parent controller’s scheduling configuration to child template schemas.
- In multi-tier hierarchies (such as
TrainJob -> JobSet -> Job), passing scheduling configuration down through multiple nested templates becomes cumbersome and fragile.
In contrast, Option A keeps all scheduling declarations centralized at the root level (JobSet.spec.scheduling)
while keeping child templates completely clean.
3. Controller Integration and workloadbuilder Mapping Go Code
Under Option A, the JobSet controller maps its centralized scheduling policies and structural spec
into a workloadbuilder.WorkloadItem tree. In the initial alpha implementation (MVP), JobSet compiles
the Workload resource and runtime PodGroup objects. When multi-level composite scheduling
(CompositePodGroup) is enabled, the controller structures the root as a composite node whose Children
are the ReplicatedJob roles, compiling a Workload with a CompositePodGroupTemplate over the child
PodGroupTemplates:
root := &workloadbuilder.WorkloadItem{
Name: "jobset-root",
// A composite node: the group-of-groups policy lives in the IR just like a leaf's.
DefaultConfig: &workloadbuilder.SchedulingConfig{
Policy: &workloadbuilder.SchedulingPolicy{Gang: &workloadbuilder.GangSchedulingPolicy{}},
},
// Input maps the JobSet's composite WorkloadCompositePodGroup* building block(s).
Input: mapJobSetSchedulingInput(jobSet),
Children: []*workloadbuilder.WorkloadItem{
leafItemForReplicatedJob(jobSet, "driver"),
leafItemForReplicatedJob(jobSet, "workers"),
},
}
workload, err := workloadbuilder.NewBuilder(root, opts).BuildWorkload()
Recommendations for Multi-Level Composite Controllers
Integrating Workload-aware Scheduling (WAS) into multi-level composite controllers (where
controllers orchestrate other controllers, such as JobSet creating core Jobs, or a Kubeflow
TrainJob composing a JobSet) introduces unique coordination challenges. Composite controllers
should adhere to the following guidelines:
1. Runtime PodGroup and CompositePodGroup Lifecycle Management
For single-level controllers (e.g., standard Job), the ownership boundaries are straightforward:
the Job controller manages both the static Workload resource and the corresponding runtime
PodGroup objects.
For multi-level composite controllers, two distinct lifecycle management strategies are available:
- Centralized Management: The parent controller compiles the
Workloadand is also fully responsible for directly creating and managing all runtimeCompositePodGroupandPodGroupobjects. - Delegated Management: The root controller compiles the
Workloadblueprint (and potentially its top-levelCompositePodGroup), but delegates the creation and management of individual runtimePodGroupobjects to intermediate or child execution controllers via downward annotations.
Architectural Guidance & Applicability:
It is crucial to distinguish between the depth of the scheduling hierarchy (the number
of nested CompositePodGroup and PodGroup levels) and the depth of the controller
actuation chain (whether a single controller directly creates child Job resources or
acts through intermediate controllers):
Centralized Management (Direct Controller Orchestration): When a controller directly creates and manages child workload resources (such as
JobSetdirectly orchestrating coreJobresources), centralized management is natural and effective, regardless of how many levels exist in the scheduling hierarchy. For example, even thoughJobSetmodels a 3-level scheduling hierarchy (JobSet->ReplicatedJob->Job), the singleJobSetcontroller directly stamps out childJobspecs. It can therefore directly instantiate all runtimeCompositePodGroupandPodGroupobjects and setjob.spec.template.spec.schedulingGroup.podGroupNamepointing to its own created groups without coordination overhead or race conditions.Delegated Management (Nested / Chained Controllers): Delegated management is necessary when there is a chain of intermediate controllers across component boundaries (such as Kubeflow Trainer /
TrainJobcomposing aJobSet, which in turn creates standardJobs).TrainJobonly constructs the top-levelJobSetcustom resource; it does not constructJobobjects directly.TrainJobcan patch theJobSettemplate embedded in itsClusterTrainingRuntime, but eachReplicatedJobhas a singleJobTemplateSpecshared by all of its generatedJobreplicas. When those replicas require distinct leafPodGroups,TrainJobcannot set a differentjob.spec.template.spec.schedulingGroup.podGroupNamefor each generatedJob. In contrast,JobSetconstructs each childJoband can set the reference to the correspondingPodGroupwhile doing so.Therefore, a wrapper controller like
TrainJobcannot directly bind each generatedJobreplica’s pods to a distinct, centrally created leafPodGroup. Instead, it must delegate runtime group creation downward:TrainJobcompiles theWorkloadblueprint (and potentially creates its top-levelCompositePodGroup), and injects well-known downward annotations (scheduling.k8s.io/workload-name,scheduling.k8s.io/group-template-nameandscheduling.k8s.io/parent-compositepodgroup) into theJobSetmetadata. The intermediateJobSetcontroller then reads these annotations, materializes the runtimePodGroups from the referenced template in the parentWorkload, attaches them to the parentCompositePodGroup, and injects the resultingPodGroupnames into the childJobpod templates.
Both patterns are first-class and fully supported by the workloadbuilder library and
conventions. Formal rules and detailed recommendations on when to apply each pattern will be
finalized for GA based on production feedback from ecosystem adopters.
2. Downward Template and Parent Mapping via Well-Known Annotations
If a composite controller delegates runtime PodGroup management to an intermediate or child controller (such as
in the TrainJob -> JobSet -> Job multi-tier pattern where the parent cannot inject distinct per-replica pod-level
scheduling references across the intermediary abstraction boundary), we must solve a crucial coordination problem. The downstream
controller needs three distinct pieces of information to construct and place its runtime scheduling objects correctly:
- Workload Linkage: Which
Workloadin the namespace holds the compiled templates. TheWorkloadmay be compiled by any ancestor, not only the direct parent (e.g.TrainJobin aTrainJob -> JobSet -> Jobhierarchy), so the downstream controller cannot derive it from its own owner. - Template Mapping: Which
PodGroupTemplateorCompositePodGroupTemplateinside the parent’s compiledWorkloadcorresponds to this child’s pods (enabling the downstream controller to materialize or compile the correct policy and constraints). - Parent Instance Linkage: Which specific runtime
CompositePodGroupinstance name in the namespace this newly created group must attach to (under itsspec.parentRef). This linkage is especially critical in multi-instantiated environments (such asLeaderWorkerSet/ LWS), where a composite controller may instantiate multiple separateCompositePodGroupobjects from the exact same template (one per replica).
The Solution: Downward Mapping Annotations
To resolve this template and hierarchy mapping without structural API schema changes, orchestrators
operating in delegated mode propagate these linkages downwards by injecting well-known metadata annotations
directly into the created child objects (for example, TrainJob sets these annotations on the JobSet objects it creates):
- Workload Linkage Annotation:
- Annotation Key:
scheduling.k8s.io/workload-name - Value: The exact resource name of the
Workloadin the same namespace that contains the template named byscheduling.k8s.io/group-template-name. It is required wheneverscheduling.k8s.io/group-template-nameis set, and is propagated unchanged through every delegation level, so a controller at any depth resolves theWorkloadwith a singleGetinstead of walking the ownerReference chain.
- Annotation Key:
- Template Linkage Annotation:
- Annotation Key:
scheduling.k8s.io/group-template-name - Value: The unique name of the target
PodGroupTemplateorCompositePodGroupTemplatedefined inside the parentWorkloadresource (ensuring direct mapping, as all template names inside a Workload are guaranteed to be unique). For example, in aTrainJob -> JobSet -> Jobhierarchy, this tellsJobSetwhich template from the rootWorkloadto use to materialize itsPodGroups and configure its childJobs.
- Annotation Key:
- Parent Instance Linkage Annotation:
- Annotation Key:
scheduling.k8s.io/parent-compositepodgroup - Value: The exact resource name of the parent
CompositePodGroupobject in the same namespace that the downstream controller’s newly created groups must attach to (viaspec.parentRef). This is required only in the delegated lifecycle model (the parent creates theWorkloadandCompositePodGroup, but runtimePodGroupcreation is delegated); when the parent centrally manages both theCompositePodGroupand thePodGroups (asJobSetdoes for coreJobs), this annotation is unnecessary.
- Annotation Key:
We strictly use unstructured metadata annotations rather than introducing new structural fields in the child’s API schemas for this coordination. These mappings are transient, internal, and automatically managed by composite operators during compilation, not user-configurable scheduling intents.
Go Package Placement & Graduation Strategy
Embedding reusable building block Go structures (defined in a pre-stable package like
scheduling.k8s.io/v1alpha3) directly into a stable GA type (like batch/v1.JobSpec or apps/v1.DeploymentSpec)
during its Alpha phase introduces package dependency and graduation challenges.
In the Go language, changing the import path of an embedded field inside a GA struct constitutes a
breaking change in client libraries. Furthermore, as highlighted during API reviews,
when building blocks are embedded into GA v1 resources, promoting the embedding feature gate to Beta
(enabled by default) commits wire format compatibility at the v1 resource level. In Kubernetes API
conventions, there is no semantic difference between Beta and GA for embedded structs in v1 resources.
To solve this graduation compatibility trap without forcing identical structure duplication across different apiGroups, we adopt the following transition pattern:
- Alpha Phase (v1.37): The shared building blocks were defined in the pre-stable
scheduling.k8s.io/v1alpha3package. Standard Kubernetes import rules allowed stable GA groups (batch/v1) to import pre-stable packages as long as the field itself remained gated in Alpha. - Graduation to Beta (v1.38) via Direct-to-v1 Promotion: When the composed fields are promoted to Beta
(enabled by default in
v1types likebatch/v1.JobSpecunder KEP-5547), we bypass the intermediatev1beta1package version entirely (since wire-format compatibility is already committed at thev1resource level). We graduate the building block structs straight into the stablescheduling.k8s.io/v1package and update the fields insidebatch/v1.JobSpecto reference thev1types. - Go Type Aliasing for Backward Compatibility: To prevent breaking third-party Go controllers that
still import the older alpha package (
k8s.io/api/scheduling/v1alpha3), we replace the physical structures inv1alpha3with Go Type Aliases (=) pointing to the new stablev1types:This is a well-established, approved Kubernetes API pattern (previously used in the// In k8s.io/api/scheduling/v1alpha3/types.go: type WorkloadPodGroupSchedulingConstraints = schedulingv1.WorkloadPodGroupSchedulingConstraints type WorkloadPodGroupDisruptionMode = schedulingv1.WorkloadPodGroupDisruptionMode type WorkloadPodGroupSchedulingPolicy = schedulingv1.WorkloadPodGroupSchedulingPolicy type WorkloadPodGroupResourceClaim = schedulingv1.WorkloadPodGroupResourceClaim // Analogously for composite WorkloadCompositePodGroup* typesadmissionregistrationAPI group) that allows external codebases to compile seamlessly while gradually transitioning their imports.
Test Plan
[x] I/we understand the owners of the involved components may require updates to existing tests to make this code solid enough prior to committing the changes necessary to implement this enhancement.
Prerequisite testing updates
Job-specific test plans are tracked in KEP-5547.
Unit tests
- Add tests that verify:
workloadbuildercompiles aBasicpolicy into the expectedWorkload/PodGroupworkloadbuildercompiles aGangpolicy into the expectedWorkload/PodGroupworkloadbuildercorrectly maps topology constraints, disruption mode, and resourceClaimsworkloadbuildermerges controller defaults with user overrides (e.g. userGangoverrides controller defaultBasic)workloadbuilderruns nodeCallbacksafter merging config, and a defaulting callback fillsgang.minCount(e.g. from a Job’s parallelism) when omittedworkloadbuilderValidaterejects semantically invalid configurations- Single-level
WorkloadItem(flat, no children) produces a leafPodGrouponly - the library correctly translates the leaf building blocks recorded in a
WorkloadItem.Input(WorkloadInput) into the library IR - Multi-level
WorkloadItemtree (with children) produces aCompositePodGroupwith correct parent–child structure, translating the compositeWorkloadCompositePodGroupSchedulingPolicyblock into the IR
- Reference integration tests for multi-level controllers (e.g.
JobSet) verify that theworkloadbuilderproduces the expectedCompositePodGroupand childPodGroupobjects from a compositeWorkloadItemtree.
Integration tests
- Verify that a single-level controller (Job) can create the correct
Workload/PodGroupviaworkloadbuilder— covered in KEP-5547 - Verify that a multi-level controller (e.g.
JobSet) can produce aCompositePodGroupwith multiple childPodGroupsvia theworkloadbuilderlibrary - Verify that updating
gang.minCounttriggers recompilation of theWorkloadand re-sync of thePodGroup
e2e tests
- Gang scheduling end-to-end: all pods scheduled together or none via
workloadbuilder-compiledWorkload/PodGroup - Mixed workloads: gang and basic Jobs coexist without interference
Graduation Criteria
Alpha
- Reusable scheduling API building blocks (
SchedulingConstraints,DisruptionMode,SchedulingMode,ResourceClaim) introduced under thescheduling.k8s.ioAPI group (v1alpha3). - The shared
workloadbuilderGo translation library implemented in thek8s.io/component-helpersstaging repository (k8s.io/component-helpers/scheduling/schedulingv1/workloadbuilder). - Comprehensive unit and integration tests added for the
workloadbuilderlibrary to verify correct resource translation and default-overriding logic. - Core
JobAPI (batch/v1) integrated with the standardized WAS building blocks and validated in the alpha phase (KEP-5547).
Beta
- Reusable building blocks graduated directly into
scheduling.k8s.io/v1, establishing wire-format stability for embedding intov1workload APIs (batch/v1,apps/v1), with Go type aliases inscheduling.k8s.io/v1alpha3preserving backward compatibility. - Widespread ecosystem adoption validated: 7 controllers across the ecosystem
(JobSet, LeaderWorkerSet, Kubeflow TrainJob, Spark Operator, Core Deployment, Core StatefulSet,
and lessons learned from KubeRay) are actively designing and implementing integrations based
on these building blocks and
workloadbuilder, providing strong confirmation that the building-block schemas and library abstractions are sound and ready for Beta. - User and ecosystem feedback gathered on usability, confirming that the composed configuration
and
workloadbuilderapproach provides a natural and cohesive UX.
GA
- Architectural and lifecycle recommendations for runtime
PodGroupandCompositePodGroupobjects (formalizing rules for Centralized vs Delegated Management) finalized based on production feedback from ecosystem adopters. - The
workloadbuilderlibrary ink8s.io/component-helpersmatured and expanded based on use cases emerging from active integrations. - At least two out-of-tree controllers (e.g., JobSet, LeaderWorkerSet, TrainJob, or
SparkApplication) running in production clusters with the building blocks and
workloadbuilder, with positive operator feedback.
Upgrade / Downgrade Strategy
The API building blocks are not top-level objects, and thus are not exposed directly
by kube-apiserver. Update/Downgrade strategy for top-level APIs (like Job) are
described in detail in corresponding KEPs.
Version Skew Strategy
workloadbuilder adds no version-skew constraints of its own, each component vendors a fixed
version at build time. Skew applies only to the runtime components of each integration.
Production Readiness Review Questionnaire
Feature Enablement and Rollback
How can this feature be enabled / disabled in a live cluster?
- Feature gate (also fill in values in
kep.yaml)- Feature gate name:
WorkloadWithJob- Components depending on the feature gate:
- kube-controller-manager
- kube-apiserver
- Components depending on the feature gate:
- Feature gate name:
- Other
- Describe the mechanism:
- Will enabling / disabling the feature require downtime of the control plane?
- Will enabling / disabling the feature require downtime or reprovisioning of a node?
Does enabling the feature change any default behavior?
This KEP itself is a code-level change and a building block for few KEPs.
The API building blocks and libraries can’t really be disabled. The integration
with those however is handled by feature gates dedicated to integrations (e.g.
WorkloadWithJob for the integration with Job API and job-controller). This
can be disabled and it’s described in detail in corresponding KEPs (e.g.
KEP-5547 for Job integration).
Can the feature be disabled once it has been enabled (i.e. can we roll back the enablement)?
The building blocks and libraries themselves aren’t gated, so there is nothing to roll back at this
level. Rollback is a property of each integration, disabling that integration’s dedicated gate (e.g.
WorkloadWithJob) is what stops the building-block fields from being served.
What happens if we reenable the feature if it was previously rolled back?
This is likewise governed by the integration rather than the building blocks. Reenabling an integration’s gate is handled according to each controller’s enablement/disablement strategy.
Are there any tests for feature enablement/disablement?
Since the building blocks aren’t gated, enablement/disablement tests live with each integration and are described in the corresponding KEPs. This KEP is covered by library and unit tests for the building blocks themselves.
Rollout, Upgrade and Rollback Planning
How can a rollout or rollback fail? Can it impact already running workloads?
This KEP provides reusable Go structs (scheduling.k8s.io) and a shared compilation library
(workloadbuilder). These building blocks are not top-level API resources served directly by
kube-apiserver. Instead, they are embedded into the specifications of integrating workloads
(such as batch/v1.Job).
Rollout / Beta Promotion:
- When integrating workloads (such as
Jobin KEP-5547) graduate their scheduling integrations and migrate their embedded fields fromscheduling.k8s.io/v1alpha3toscheduling.k8s.io/v1, the transition is completely seamless and safe because of Go type aliases (type Foo = schedulingv1.Foo). - Go type aliases preserve identical Go type identity, in-memory representation, and JSON/protobuf serialization tags across the packages. As a result, there is zero wire-format change, ensuring complete backward compatibility and zero impact on running workloads or existing client payloads.
- Controllers vendoring
workloadbuilderconsume it as a compile-time Go library dependency; translation and compilation logic is validated via extensive unit and integration tests. - Running workloads (Pods, Jobs, etc.) are not impacted by rollout. Running Pods remain
scheduled and bound to their assigned nodes. Existing runtime
WorkloadandPodGroupobjects in etcd remain active and untouched.
- When integrating workloads (such as
Rollback:
- Rollback is managed strictly at the level of each integrating controller’s feature gate
(e.g.,
WorkloadWithJobin KEP-5547). Disabling an integration’s gate stops that controller from accepting or compiling scheduling fields for new workloads. - Existing running workloads continue running without disruption. Existing
WorkloadandPodGroupobjects in etcd remain intact and are cleaned up according to standard garbage collection when their parent workload is deleted.
- Rollback is managed strictly at the level of each integrating controller’s feature gate
(e.g.,
What specific metrics should inform a rollback?
As this KEP provides Go structs and a client library rather than an active standalone controller or top-level API endpoint, there are no KEP-specific metrics for rollback.
Rollback criteria, alert thresholds, and operational metrics are defined by the respective
integrating controller KEPs (e.g., job_sync_duration_seconds and job_sync_total{result="error"}
in KEP-5547 for Job integration, and corresponding metrics in [KEP-6276] for Deployment).
Were upgrade and rollback tested? Was the upgrade->downgrade->upgrade path tested?
Upgrade and downgrade testing for runtime controller behavior is owned and conducted by individual controller integration KEPs (e.g. KEP-5547 for Job).
For this KEP, testing verifies:
- Go type alias compatibility between
v1alpha3andv1(serialization and deserialization equivalence). - Comprehensive unit and integration test coverage of the
workloadbuildercompilation library across all supported scheduling configurations.
Is the rollout accompanied by any deprecations and/or removals of features, APIs, fields of API types, flags, etc.?
No. The graduation of building blocks to scheduling.k8s.io/v1 preserves
scheduling.k8s.io/v1alpha3 via Go type aliases. No fields, APIs, or flags are deprecated or
removed.
Monitoring Requirements
As this KEP provides reusable Go structs and a client compilation library rather than an active standalone controller or top-level API resource, there are no direct runtime metrics or monitoring requirements for the building blocks themselves. Monitoring, observability, SLOs, and SLIs are owned and defined by each integrating controller’s KEP (e.g., KEP-5547 for Job).
How can an operator determine if the feature is in use by workloads?
N/A directly for the building blocks. Because this KEP provides reusable Go structs and a
compilation helper library rather than a standalone controller, detecting whether the feature is
in use is done by inspecting workloads managed by integrating controllers (e.g., checking for
.spec.scheduling on Job or JobSet objects) or checking for generated runtime objects
(Workload, PodGroup, CompositePodGroup). Detailed procedures and query commands belong to
each controller’s integration KEP (e.g., KEP-5547 for Job).
How can someone using this feature know that it is working for their instance?
N/A directly for the building blocks. As building blocks and a client library, there are no runtime
instances or standalone components to monitor directly. Runtime events (e.g., WorkloadCreated,
PodGroupCreated), status conditions, and pod-level references (such as
.spec.schedulingGroup.podGroupName) are emitted and managed by the integrating controllers and
kube-scheduler. These signals are detailed in the respective controller KEPs (e.g., KEP-5547 for
Job).
What are the reasonable SLOs (Service Level Objectives) for the enhancement?
N/A for the building blocks and compilation library. In-process compilation latency of
workloadbuilder is sub-millisecond (< 1 ms per workload compilation). System-level and
controller-level SLOs (such as controller sync duration or scheduler binding latency) belong to and
are measured under the respective integrating controller KEPs (such as KEP-5547).
What are the SLIs (Service Level Indicators) an operator can use to determine the health of the service?
N/A for this KEP. Because this KEP provides Go structs and a client library rather than an active controller or standalone service, operational SLIs and health indicators belong to the integrating controllers and kube-scheduler, and are specified in their dedicated KEPs (e.g., KEP-5547 for Job, [KEP-6276] for Deployment).
Are there any missing metrics that would be useful to have to improve observability of this feature?
N/A. Observability metrics tracking workload adoption across controllers belong to each respective integration KEP.
Dependencies
Does this feature depend on any specific services running in the cluster?
None directly. This KEP provides Go structs and a compilation library. Integrating controllers
depend on standard core Kubernetes components (kube-apiserver, kube-controller-manager, and
kube-scheduler with WAS plugins enabled), as detailed in their respective KEPs.
Scalability
Will enabling / using this feature result in any new API calls?
The API building blocks and workloadbuilder library execute in-process and do not make any API
calls directly. API calls to create or manage Workload and PodGroup objects are made by
integrating controllers when users opt in, and are quantified in each controller’s KEP (e.g.,
KEP-5547).
Will enabling / using this feature result in introducing new API types?
Yes, but they are not top-level API types. This KEP introduces reusable building-block Go field
types under scheduling.k8s.io/v1 (with Go type aliases in scheduling.k8s.io/v1alpha3) that are
embedded directly into the specifications of integrating workload APIs (such as batch/v1.Job).
Will enabling / using this feature result in any new calls to the cloud provider?
No.
Will enabling / using this feature result in increasing size or count of the existing API objects?
This KEP only defines optional embedded building-block fields. Object count and size increases
(such as runtime Workload and PodGroup objects) are created by integrating controllers and
quantified in their respective KEPs.
Will enabling / using this feature result in increasing time taken by any operations covered by existing SLIs/SLOs?
This KEP adds only in-process Go translation (negligible CPU, < 1 ms) in controllers vendoring the library. Any operational latency impact is governed by the integrating controllers and kube-scheduler, as evaluated in their KEPs.
Will enabling / using this feature result in non-negligible increase of resource usage (CPU, RAM, disk, IO, …) in any components?
No. The building blocks and library execute in-process with negligible resource overhead.
Can enabling / using this feature result in resource exhaustion of some node resources (PIDs, sockets, inodes, etc.)?
No. This feature operates entirely at the API and library compilation level and does not consume any node-level resources.
Troubleshooting
How does this feature react if the API server and/or etcd is unavailable?
The building blocks and workloadbuilder library execute strictly in-process within workload
controllers and carry no network I/O or state of their own. Controller failure behavior during API
server unavailability is governed by the host controller’s reconciliation loop, as detailed in the
corresponding integration KEPs.
What are other known failure modes?
Failure modes related to scheduling execution (e.g., unfulfillable gang scheduling requirements, missing downward template annotations) are handled and diagnosed within the integrating controllers and kube-scheduler, as described in their respective KEPs.
What steps should be taken if SLOs are not being met to determine the problem?
Refer to the troubleshooting guide of the relevant integrating controller KEP (e.g., KEP-5547 for Job integration) to diagnose controller reconciliation, admission, or scheduling issues.
Implementation History
- 2026-06-03: KEP Created for alpha release
- 2026-07-15: Synced the KEP with the implementation of the
workloadbuilderlibrary, the Job building-block fields and theworkloadbuilderAPI (Validatewith aValidationInputand theNewBuilderFromExistingWorkloadconstructor). - 2026-07-18: Synced the
CompositePodGroupdesign with the codebase and review feedback from the building-blocks library. - 2026-09-10: Promoted KEP to Beta for v1.38 milestone. Reflected adoption by 7 controllers across
the ecosystem (JobSet, LWS, TrainJob, Spark Operator, Deployment, StatefulSet). Promoted reusable
building blocks directly to
scheduling.k8s.io/v1withv1alpha3Go type aliases per API review guidance. Completed Beta PRR questionnaire. - 2026-09-29: Added the
scheduling.k8s.io/workload-namedownward annotation.
Drawbacks
- Reduced global uniformity / API fragmentation: Because each controller composes
its own user-facing scheduling API from the shared building blocks rather than a
single unified schema, the exact shape and vocabulary of the
schedulingconfiguration can differ between controllers. - Shared-library coupling and version skew: Out-of-tree controllers that adopt the
workloadbuilderlibrary take on a dependency whose translation/defaulting logic must stay compatible across controller and library versions. Skew between a controller’s vendored library version and the cluster’sscheduling.k8s.ioAPI version can lead to subtle behavioral differences. - Additional API surface to maintain: The standardized building blocks add new
types under
scheduling.k8s.iothat must evolve carefully to remain backward-compatible across the many controllers that embed them.
Alternatives
During the design phase, we initially pursued a highly unified, top-down compiler vision outlined in the [Public] API Design for WAS Controller Integration.
However, as we analyzed the implementation details, we discovered two fatal architectural and logistical challenges documented in [Public] WAS Controller API - challenges and potential alternatives that made the original unified API vision unfeasible within a reasonable timeframe:
1. Implementation Complexity & The “Transitive Capability Leak”
As detailed in [Public] The “capability leak” in
go/was-controller-api,
because composite workloads (such as JobSet or TrainJob) natively wrap child templates (like
standard JobTemplateSpec), any new scheduling field introduced at the child level transitively
propagates (“leaks”) up the schema stack. Handling these nested configurations requires massive,
complex boilerplate inside every intermediate controller (e.g., reconcilers dynamically checking
if they are the root compiler, managing owner references, and validating nested fields), making
the unified compiler pattern highly cumbersome and fragile.
2. The Upstream Dependency Bottleneck
The most critical issue with the original unified API design is the strict Controller
Integration Dependency chain. Under a monolithic, cascading rollout, integrating a new
scheduling feature into a top-level out-of-tree controller (such as TrainJob or RayJob) was
strictly blocked by the successful integration of all intermediate child controllers (waiting
first for core Job and then JobSet). This dependency chain would delay crucial Workload-aware
Scheduling features for quarters or years, which is completely unacceptable when the user demand
in the AI/ML space is immediate.
The Chosen Solution: Autonomous Composed Configurations & Conscious Trade-offs
Rather than delaying critical features, this KEP embraces Controller Autonomy. Sponsoring
out-of-tree controllers have full authority to design their own composed configurations using the
standard scheduling.k8s.io building blocks and the workloadbuilder library.
This represents a conscious and deliberate architectural trade-off:
- Local Consistency > Global Uniformity/Fragmentation: We prioritize native, idiomatic
consistency within each controller’s local API over a globally unified, rigid schema. Enabling
JobSetto utilize its establishedtargetReplicatedJobsconvention is far more intuitive for its users than forcing a single, shared structure across the entire ecosystem. - Time-to-Market > Perfect API: In the fast-paced AI and machine learning landscape, workload requirements change from month to month. Users need working scheduling capabilities today, not an idealized but delayed API a year from now. A “prettier” global API structure is not an acceptable justification for blocking immediate ecosystem adoption.