Skip to content

Dynamic headroom: derive placeholder count and size from recent worker spawns#894

Merged
benben merged 1 commit into
mainfrom
ben/duckgres-dynamic-headroom
Jul 6, 2026
Merged

Dynamic headroom: derive placeholder count and size from recent worker spawns#894
benben merged 1 commit into
mainfrom
ben/duckgres-dynamic-headroom

Conversation

@benben

@benben benben commented Jul 6, 2026

Copy link
Copy Markdown
Member

Problem

The headroom placeholder pool is hardcoded to an r6gd node shape (46 vCPU / 360Gi per placeholder, controlplane/headroom.go constants) with a fixed count from DUCKGRES_K8S_HEADROOM_NODES. That shape dates from the exclusive worker-per-node era; with today's ~15 vCPU / 120Gi workers, 3 placeholders reserve 138 vCPU / 1080Gi — ~216% of live worker demand — and preempting one 46-vCPU placeholder to schedule one 15-vCPU worker wastes most of the reserve granularity. The legacy DUCKGRES_K8S_HEADROOM_PERCENT mode is dead config in prod (nodes>0 wins) and was retired for amplifying idle-worker leaks.

Change

Placeholder count and size are now derived dynamically from real spawns — no count/size knobs at all:

  • Spawn log: every worker pod create appends its shape best-effort to duckgres_worker_spawn_log (goose migration 000016; cross-CP by construction, pruned to the sizing window by the leader reconcile). A log failure never fails the spawn.
  • Slot count = clamp(peak spawn burst, 1, cap) — the peak number of spawns in any 5-minute bucket of the last hour. Serial spawns reuse one warm slot; only burst concurrency needs parallel headroom. Floor 1 (never zero while enabled). cap = max(4, ceil(25% × live workers)) — fleet-relative, so it grows with the deployment without anyone bumping a constant, and bounds a spawn-storm bug's cost. Fleet size is only the ceiling, never the target, so an idle-worker leak cannot inflate the pool (the failure that killed percent mode).
  • Slot size = componentwise max shape spawned in the last 7 days → live fleet max → configured/default worker request. Client-sized (GUC) workers can exceed any configured maximum, so observed spawns are the only honest sizing source. A spawn bigger than one slot preempts several placeholders — wrong sizing degrades to Karpenter latency, never a failure.
  • Hysteresis: scale-up/refill immediate; scale-down lazy (one slot per 10 min while above target); shape drift >20% replaces placeholders in place.
  • Enable switch = existing DUCKGRES_K8S_PLACEHOLDER_PRIORITY_CLASS (already a hard prerequisite — an unprioritized placeholder would never be preempted). DUCKGRES_K8S_HEADROOM_NODES / DUCKGRES_K8S_HEADROOM_PERCENT are removed. k8s/headroom-rbac.yaml (node-read ClusterRole) deleted — nothing reads node allocatable anymore.
  • Gauges: duckgres_headroom_slots_desired / _slots_cap / _peak_spawn_burst / _slot_cpu_millicores / _slot_memory_bytes. Desired pinned at cap = demand wants more than the fleet ratio allows.

At current fleet shape this cuts steady-state headroom from 138 vCPU / 1080Gi to one worker-sized slot when idle, scaling with real burst demand when busy.

Rollout

The prod values overlay already sets placeholderPriorityClassName, so dynamic mode enables itself on deploy; the old env vars become inert. Follow-up in the charts repo: drop headroomNodes / headroomPercent values, their env plumbing, and the duckgres-headroom-nodes ClusterRole gate. Existing node-sized placeholders are >20% shape-drifted from the new plan, so the first reconcile replaces them with worker-sized ones.

Testing

  • controlplane/headroom_test.go: burst→count, spawn-log→size with live-fleet and default fallbacks, fleet-relative cap under a spawn storm, ceiling-not-target (idle-leak regression), cap floor, lazy scale-down one-per-delay, shape-drift replacement (incl. legacy node-sized pods), disabled-converges-to-zero, stats-error skips tick, best-effort spawn recording.
  • tests/configstore/spawn_log_postgres_test.go: real-postgres round-trip of the burst-bucket / componentwise-max SQL and pruning; migration asserts in migrations_postgres_test.go.
  • e2e: headroom placeholders stay deliberately disabled in-Job — real placeholders would consume shared mw-dev Karpenter capacity and outlive the per-PR CP that owns them (nothing would delete them after teardown). Documented in tests/e2e-mw-dev/README.md "Deliberately not covered here"; spawn recording itself runs in-Job on every worker spawn.

🤖 Generated with Claude Code

…r spawns

Replace the hardcoded constant node-headroom (46 vCPU / 360Gi placeholders,
DUCKGRES_K8S_HEADROOM_NODES) and the legacy demand-percent mode with a fully
dynamic controller, with no count/size config at all:

- Every worker pod spawn is logged best-effort to a new config-store table
  (duckgres_worker_spawn_log, goose migration 000016) with its shape.
- Slot COUNT = clamp(peak spawns in any 5-minute bucket of the last hour, 1,
  max(4, ceil(25% of live worker pods))). Serial spawns reuse one warm slot,
  so only burst concurrency needs parallel headroom; the fleet-relative cap
  grows with the deployment and bounds spawn-storm cost without anyone
  bumping a constant. Fleet size is only the ceiling, never the target, so
  an idle-worker leak cannot inflate the placeholder pool.
- Slot SIZE = componentwise max worker shape spawned in the last 7 days,
  falling back to the live fleet's max shape, then the configured/default
  worker request. Client-sized (GUC) workers can exceed any configured
  maximum, so observed spawns are the only honest sizing source.
- Scale-up/refill is immediate; scale-down is lazy (one slot per 10 minutes)
  and shape drift >20% replaces placeholders in place, so flapping never
  thrashes pods.
- Enable switch is the existing DUCKGRES_K8S_PLACEHOLDER_PRIORITY_CLASS env
  (a placeholder without a priority class below workers would never be
  preempted, so it was already a hard prerequisite). HEADROOM_NODES and
  HEADROOM_PERCENT envs are removed; the k8s/headroom-rbac.yaml node-read
  ClusterRole is deleted (nothing reads node allocatable anymore).
- New gauges: duckgres_headroom_slots_desired/_slots_cap/_peak_spawn_burst/
  _slot_cpu_millicores/_slot_memory_bytes.

At current fleet shape (15 vCPU / 120Gi workers) this cuts steady-state
headroom from 138 vCPU / 1080Gi (3 node-sized placeholders, ~216% of live
worker demand) to one worker-sized slot when idle, scaling with real burst
demand when busy.

Headroom placeholders stay disabled in the e2e job (no placeholder
PriorityClass there): real placeholders would consume shared mw-dev capacity
and outlive the per-PR CP; covered by fake-clientset unit tests and a real
postgres round-trip for the spawn-log SQL (see tests/e2e-mw-dev/README.md).
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 1 3 0
E2E/journey files 0 1 0
Workflow files 0 0 0

Signals

  • Test cases: +12 / -3
  • Assertions: +49 / -9
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 0

Coverage risk: needs review

Warnings

  • E2E or journey files changed (needs review)
    • tests/e2e-mw-dev/README.md

@benben benben merged commit 732a92c into main Jul 6, 2026
28 checks passed
@benben benben deleted the ben/duckgres-dynamic-headroom branch July 6, 2026 14:25
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