Skip to content

feat: lease-aware scheduler gating, leader visibility, failover e2e - #82

Merged
stubbi merged 10 commits into
mainfrom
feat/lease-scheduler-gating
Jun 13, 2026
Merged

feat: lease-aware scheduler gating, leader visibility, failover e2e#82
stubbi merged 10 commits into
mainfrom
feat/lease-scheduler-gating

Conversation

@stubbi

@stubbi stubbi commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

Note

Stacked on #81 (feat/multi-replica-workload). This branch contains all of #81's commits; please review only the last 4 commits here (dd3cef6, a708e63, d615714, 9b3a860). The diff collapses to just those once #81 merges.

Motivation

The app is gaining lease-based scheduler leader election (paperclipai/paperclip#7995): every replica is a lease candidate, one wins, and the lease fails over automatically when the holder dies. The operator's existing gating (a pod-0 ordinal shell wrapper setting HEARTBEAT_SCHEDULER_ENABLED) has two gaps that this PR closes:

What changed (per commit)

dd3cef6 feat(operator): lease-aware scheduler gating modes (ordinal|lease|auto)

  • New spec.heartbeat.schedulerGating enum: ordinal (default, unchanged behavior), lease (no env manipulation; delegates to the app's lease election), auto (resolves to ordinal today; flips to lease once the minimum supported app version ships lease leadership).
  • The ordinal wrapper is now additionally guarded by the resolved mode and skipped for Deployment workloads.
  • New advisory SchedulerGatingValid condition (excluded from the Ready aggregate) + Warning event for the Deployment+ordinal+replicas>1 combination; removed at replicas <= 1.
  • Tests: wrapper presence matrix across mode/workload/replicas, mode-resolution unit tests, condition lifecycle envtest.

a708e63 feat(operator): scheduler leader visibility

While lease gating is active at replicas > 1, the operator polls each running server pod's unauthenticated /api/health for the scheduler block and surfaces the lease holder:

  • pod label paperclip.inc/role=scheduler on the leader, web on the rest;
  • status.schedulerLeader records the leader pod name (per-pod poll failures skip the pod; when every poll fails the previous value is kept to avoid thrashing);
  • controller.kubernetes.io/pod-deletion-cost on the leader for Deployment workloads, so ReplicaSet scale-in prefers web replicas and avoids needless failovers;
  • ordinal gating or replicas <= 1 strips all markers and clears the status field; active instances requeue every 30s.

The health probe is injectable, so envtest covers labeling, status, deletion-cost, no-thrash, and the clear path without a pod network.

d615714 test(e2e): image-gated scheduler lease failover scenario

2-replica Deployment-workload Instance against an in-cluster postgres:16 (database.mode: external), schedulerGating: lease: asserts 2 ready pods, exactly one scheduler-labeled pod matching status.schedulerLeader, then deletes the leader and asserts a different pod is labeled and recorded. Skipped by default -- it needs an app image containing lease leadership (unreleased), and runs only when an operator provides it:

PAPERCLIP_E2E_LEASE_IMAGE=<image-with-leases> make test-e2e

objectStorage is intentionally omitted in the scenario: MultiReplicaPreconditions reports an advisory False, the workload reconciles regardless, and the failover assertions touch no file state (keeps the suite free of a MinIO dependency).

9b3a860 docs: scheduler gating, version skew, migration, observability

README "Scheduler gating and failover" subsection under Scaling.

Version skew

Operator gating App without leases App with leases (>= the #7995 release)
ordinal (default) pod-0 pinned, no failover pod-0 pinned (the wrapper wins: only pod-0 is a lease candidate)
lease ALL replicas run the scheduler -- unsafe, do not use automatic failover

Migration recipe (order matters): upgrade the app image first, then set schedulerGating: lease, then optionally drop any manual HEARTBEAT_SCHEDULER_ENABLED pinning from spec.env.

Verification

  • make test green (unit + envtest, controller coverage includes the new gating condition and leader-visibility paths)
  • make lint 0 issues
  • go vet ./test/... && go build ./test/... green; go test ./test/e2e/ -ginkgo.dry-run builds the spec tree (5 specs registered, including the new one)
  • Skip gating confirmed: the failover context Skips in BeforeAll when PAPERCLIP_E2E_LEASE_IMAGE is unset, so a normal make test-e2e run is unaffected; the full kind suite was not run here
  • The failover spec itself cannot pass until a lease-capable app image exists (by design)

Dependencies

Mark ready for review after paperclip releases lease leadership and #81 merges.

🤖 Generated with Claude Code

stubbi and others added 10 commits June 13, 2026 19:19
Adds spec.heartbeat.schedulerGating (enum ordinal|lease|auto, default
ordinal) controlling how the heartbeat scheduler is pinned to a single
replica at replicas > 1:

- ordinal (default): ordinal-0 shell wrapper, StatefulSet only, no
  failover. The HEARTBEAT_SCHEDULER_ENABLED wrapper is now additionally
  guarded by the resolved mode and skipped for the Deployment workload.
- lease: no env manipulation; delegates to the app's lease-based leader
  election (paperclipai/paperclip#7995).
- auto: currently resolves to ordinal; will flip to lease once the
  minimum supported app version includes lease leadership.

SchedulerGatingMode in internal/resources resolves auto/empty to
ordinal. New advisory SchedulerGatingValid condition (excluded from the
Ready aggregate) reports False/OrdinalGatingRequiresStatefulSet plus a
Warning event for Deployment+ordinal at replicas > 1, True when valid,
and is removed at replicas <= 1.

Tests: wrapper presence matrix across mode/workload/replicas,
SchedulerGatingMode unit tests, condition lifecycle envtest, and the
advisory exclusion in allSubConditionsReady. Regenerated CRDs, chart
CRDs, and API docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…letion-cost

When schedulerGating resolves to lease at replicas > 1, the operator polls
each running server pod's unauthenticated /api/health for the scheduler
block, labels the lease holder paperclip.inc/role=scheduler (others web),
records status.schedulerLeader, and tags the leader with the
controller.kubernetes.io/pod-deletion-cost annotation on Deployment
workloads so ReplicaSet scale-down prefers web replicas. Per-pod poll
errors skip the pod; when every poll fails the previous leader value is
kept to avoid thrashing. Ordinal gating or replicas <= 1 strips all
markers and clears the status field. Active instances requeue every 30s.

The probe is injectable (healthProbe field) so envtest covers leader
labeling, status, deletion-cost, no-thrash, and the clear path without a
pod network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a "Scheduler lease failover" e2e context: a 2-replica Deployment
workload Instance (database.mode external against an in-cluster
postgres:16 Deployment, persistence off, schedulerGating lease) must
elect exactly one paperclip.inc/role=scheduler pod with a matching
status.schedulerLeader, and deleting that pod must hand the lease to a
different pod (label + status follow).

The scenario needs an app image that ships lease-based scheduler
leadership (paperclipai/paperclip#7995, unreleased), so it is gated
behind PAPERCLIP_E2E_LEASE_IMAGE and SKIPPED by default; the image ref
is split into spec.image repository/tag-or-digest. objectStorage is
intentionally omitted: MultiReplicaPreconditions reports an advisory
False, but the workload reconciles regardless and the failover
assertions do not touch file state, keeping the scenario free of a
MinIO dependency. Terminating pods are excluded from the leader-label
query so the drained old leader never trips the exactly-one assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a "Scheduler gating and failover" subsection to the README Scaling
section: the three schedulerGating modes (ordinal default, lease, auto)
with their failover behavior, the operator-vs-app version-skew matrix
(including the unsafe lease + lease-less-app combination), the
ordinal-to-lease migration recipe (upgrade the app first), and leader
observability (status.schedulerLeader, paperclip.inc/role pod labels,
pod-deletion-cost on Deployments). The Heartbeat Scheduler section now
points at it instead of stating pod-0 pinning unconditionally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app enforces a Host-header allowlist (service DNS names + loopback);
polling a pod IP with the IP as Host gets 403 in authenticated mode —
found live on a real cluster; envtest's injected probe couldn't catch it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rusted cannot bind in-cluster)

The app hard-rejects local_trusted on non-loopback binds ("local_trusted
requires server.bind=loopback"), so in-cluster local_trusted Instances
crash-loop. Switch the lease-failover Instance (and the env-rendering
e2e-feat Instance, which also runs the real app image) to
deployment.mode=authenticated with a BETTER_AUTH_SECRET Secret.

auth.adminUser is deliberately omitted: the bootstrap Job proved flaky
in live verification and the failover assertions need no authenticated
principal - the operator reads scheduler leadership from the
unauthenticated redacted health endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The kubernetes sandbox plugin polls Job completion via the status
subresource; without it every sandboxed run fails with a 403 after Job
creation. Found during live multi-replica verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…evention)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etpol Enabled pointer fix

Three live-verification findings: (1) in-cluster MinIO requires path-style
S3 addressing — new forcePathStyle field, defaulting true for provider=minio;
(2) the operator-managed NetworkPolicy had no way to allow egress to an
in-cluster object-storage endpoint — new extraEgress passthrough; (3)
networkPolicy.enabled was a plain bool with omitempty + default=true, so an
explicit false was dropped on marshal and re-defaulted by the API server —
same bug class as PersistenceSpec.Enabled, now *bool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stubbi
stubbi force-pushed the feat/lease-scheduler-gating branch from 63d769f to cc64648 Compare June 13, 2026 17:33
@stubbi
stubbi marked this pull request as ready for review June 13, 2026 17:46
@stubbi
stubbi merged commit de2005d into main Jun 13, 2026
14 checks passed
@stubbi
stubbi deleted the feat/lease-scheduler-gating branch June 13, 2026 17:47
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.

1 participant