Skip to content

feat: optionally distribute replicas across architectures - #153

Open
bschwedler wants to merge 2 commits into
mainfrom
topology-spread-constraints
Open

feat: optionally distribute replicas across architectures#153
bschwedler wants to merge 2 commits into
mainfrom
topology-spread-constraints

Conversation

@bschwedler

Copy link
Copy Markdown

Adds an optional topologySpreadConstraints field to Connect, Workbench,
and Package Manager, so a Site can guarantee its product replicas split
across topology domains — e.g. one amd64 and one arm64 replica in a
multi-arch cluster — instead of relying only on the operator's existing
soft, hostname-keyed anti-affinity.

An omitted labelSelector on a constraint is auto-filled to the
component's own pod labels, since Kubernetes otherwise treats a nil
selector as matching zero pods, silently turning the constraint into a
no-op. An explicit caller-supplied selector is never overridden.

Adds a user-configurable topologySpreadConstraints field to Connect,
Workbench, and Package Manager, passed straight through to the server
Deployment's PodSpec. Enables spreading a product's replicas across
topology domains (e.g. kubernetes.io/arch), which a hard
maxSkew:1/DoNotSchedule constraint can use to force one replica per
CPU architecture in a multi-arch cluster.

The field is independent of the operator's existing hardcoded
hostname-keyed soft anti-affinity (shared.go's
ComponentSpecPodAntiAffinity) and composes with it without conflict,
since Affinity and TopologySpreadConstraints are separate PodSpec
fields. Scoped to the server Deployment only; Workbench session pods
are unaffected. No Affinity override or podLabels field is added —
out of scope for the immediate use case.

Mirrors the pattern used for the Resources field (#146): spec field
on each product type + the Site's nested Internal*Spec, controller
wiring, Site-to-product propagation, and regenerated
CRDs/deepcopy/client-go via `just mgenerate` + `just helm-generate`.
A TopologySpreadConstraint with no labelSelector matches zero pods
under Kubernetes semantics (metav1.LabelSelectorAsSelector(nil)
returns labels.Nothing()), which silently turns maxSkew and
whenUnsatisfiable into a no-op. That's the exact shape used in the
motivating multi-arch example, so shipping the raw pass-through from
the prior commit would let the constraint compile, deploy, and pass
review while never actually forcing anything.

Neither Kubernetes nor the wider operator ecosystem solves this well:
upstream closed a request to validate it as "not planned"
(kubernetes/kubernetes#135797), and prometheus-operator's own
StatefulSet builder does the same raw pass-through.

Extracts the label-identity selector already used by
ComponentSpecPodAntiAffinity into componentSelector, and adds
ComponentSpecTopologySpreadConstraints, which fills in that selector
on any constraint that omits one before it reaches the Deployment.
An explicit caller-supplied selector is always left untouched, so
the default only fires for the common case this field exists to
serve: spreading a component's own replicas.
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @bschwedler's task in 3m 13s —— View job


PR Review: feat: optionally distribute replicas across architectures

  • Gather context and read review guidelines
  • Review API type changes (api/core/v1beta1/)
  • Review controller logic and labelSelector auto-fill
  • Review tests
  • Post review

Overall this is a clean, well-scoped feature. The core logic in ComponentSpecTopologySpreadConstraints is correct and the nil-selector auto-fill is exactly the right fix for the Kubernetes semantics footgun. Tests cover the two critical cases (nil selector → auto-filled, explicit selector → preserved). A few small issues below.

API

Missing // +optional markers (important): TopologySpreadConstraints on InternalConnectSpec, InternalPackageManagerSpec, and InternalWorkbenchSpec in site_types.go are missing the // +optional kubebuilder annotation that all other optional fields in those structs have. Without it, some CRD generators can mark the field as required. I left an inline comment on site_types.go:299.

No kubebuilder validation on the TopologySpreadConstraints fields. That's fine — the Kubernetes API server validates the embedded corev1.TopologySpreadConstraint struct itself when the Deployment is submitted, so there's nothing operator-level to add here.

Shared logic (shared.go)

The ComponentSpecTopologySpreadConstraints function is correct. One minor point: when called with a nil/empty slice it returns a non-nil empty slice, which is harmless for Kubernetes but slightly surprising if future callers do a == nil check. See inline suggestion on shared.go:129.

The componentSelector helper being shared between ComponentSpecPodAntiAffinity and ComponentSpecTopologySpreadConstraints is a good DRY refactor — both functions now target the same pod set, which is the right invariant.

Controller wiring

connect.go:679, workbench.go:884, package_manager.go:525 — straightforward pass-through of Spec.TopologySpreadConstraints via the new helper. site_controller_*.go changes correctly propagate the field from InternalConnectSpec / InternalWorkbenchSpec / InternalPackageManagerSpec through to the product CRs. Nothing missed.

Tests

Good coverage: nil-selector auto-fill and explicit-selector preservation are tested for all three products. The assertion pattern at connect_test.go:706–709 — using metav1.LabelSelectorAsSelector and labels.Set.Matches rather than a raw struct equality check — is the right way to verify selector semantics. Workbench and PackageManager tests follow the same pattern consistently.

One gap: there's no test for the empty-constraints case (passing nil or []TopologySpreadConstraint{} to ComponentSpecTopologySpreadConstraints). It's a trivial path but worth a unit test directly on the shared function to lock in the nil-input contract.

@bschwedler
bschwedler requested a review from ian-flores July 23, 2026 19:30
// TopologySpreadConstraints controls how server pod replicas are spread across topology
// domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in
// hostname-keyed soft anti-affinity.
TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The InternalConnectSpec.TopologySpreadConstraints field is missing a // +optional kubebuilder marker. All optional slice fields in this file use it (e.g. AdditionalRuntimeImages at line 350). Without it, the CRD schema technically marks the field as required in some generators.

Same applies to the TopologySpreadConstraints fields added to InternalPackageManagerSpec (line 223) and InternalWorkbenchSpec (line 434).

Fix this →

}
out[i] = c
}
return out

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: When constraints is nil, this returns a non-nil empty slice (make([]corev1.TopologySpreadConstraint, 0)). Kubernetes treats both the same, but if any downstream code does a == nil check on the result, it would surprise. Consider early-returning nil when the input is empty:

Suggested change
return out
func ComponentSpecTopologySpreadConstraints(
constraints []corev1.TopologySpreadConstraint,
p product.KubernetesLabelser,
) []corev1.TopologySpreadConstraint {
if len(constraints) == 0 {
return nil
}
out := make([]corev1.TopologySpreadConstraint, len(constraints))
for i, c := range constraints {
if c.LabelSelector == nil {
c.LabelSelector = componentSelector(p)
}
out[i] = c
}
return out
}

require.NotNil(t, got[0].LabelSelector, "an omitted LabelSelector must be auto-filled, or the constraint silently matches zero pods")
selector, err := metav1.LabelSelectorAsSelector(got[0].LabelSelector)
require.NoError(t, err)
assert.True(t, selector.Matches(labels.Set(c.KubernetesLabels())),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good test: it verifies that the auto-filled selector actually matches the component's pod labels, not just that it's non-nil. The labels.Set(c.KubernetesLabels()) check is the right level of validation here.

constraints []corev1.TopologySpreadConstraint,
p product.KubernetesLabelser,
) []corev1.TopologySpreadConstraint {
out := make([]corev1.TopologySpreadConstraint, len(constraints))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The componentSelector uses MatchExpressions (specifically In) rather than MatchLabels. Both produce equivalent Kubernetes behavior, but the anti-affinity helper directly above uses MatchExpressions for the same reason — so this is consistent.

One thing to note: the selector is built from KubernetesLabels() keys app.kubernetes.io/instance and posit.team/site. These are the selector labels, so they're stable and correct for pod matching. Looks good.

}
out[i] = c
}
return out

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important: When constraints is nil (no constraints configured), this returns a non-nil empty slice (make([]corev1.TopologySpreadConstraint, 0)). Kubernetes treats an explicit empty slice the same as nil here, so this is harmless — but it's worth knowing that callers passing nil get back []TopologySpreadConstraint{} rather than nil. If downstream code ever does a == nil check on the result, it would behave unexpectedly.

Consider returning nil when the input is empty:

Suggested change
return out
func ComponentSpecTopologySpreadConstraints(
constraints []corev1.TopologySpreadConstraint,
p product.KubernetesLabelser,
) []corev1.TopologySpreadConstraint {
if len(constraints) == 0 {
return nil
}
out := make([]corev1.TopologySpreadConstraint, len(constraints))
for i, c := range constraints {
if c.LabelSelector == nil {
c.LabelSelector = componentSelector(p)
}
out[i] = c
}
return out
}

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support optional replica distribution across architectures

1 participant