Skip to content

infra: scheduler is not a singleton; ADR-015 disagrees with the cluster; default-pool can't be deleted as-is #960

Description

@lilyshen0722

Measured against the live cluster on 2026-08-15 while evaluating whether to delete default-pool for cost. Conclusion was: don't, for now. Filing so the arithmetic isn't re-derived, and because one of these is a live latent bug worth fixing on its own.

Burn is ~$300/mo total (operator figure). Compute is 3 × n2-standard-2, all in us-central1-a.

1. schedulerService is not a singleton — this one is a real bug

server.ts:299 calls schedulerService.start() unconditionally. No leader election, no gate.

The deployment is one kubectl scale away from running every cron twice — including the daily digest, which sends real email to real users. Also agent heartbeats (double LLM spend), summarizer, pod curation, auto-join, ensemble, skills refresh.

This is latent today only because replicas: 1. It fires on: a manual scale during an incident, any autoscaling, or a stuck rollout leaving two pods up briefly.

Cheap fix: a short-TTL Redis lease; only the holder runs jobs. Redis is already connected and already used by the socket adapter.

(Note: onboardingSilenceService and stalledConnectService are the accidental exceptions — their unique indexes make a duplicate pass a no-op by design.)

2. Two places ADR-015 and the cluster disagree

ADR-015 says backend + frontend + redis schedule on spot-pool, and that dev-pool carries taint pool=dev:NoSchedule. Neither is true:

claim reality
backend/redis on spot-pool both have nodeSelector: pool=dev — pinned to the on-demand pool
dev-pool tainted pool=dev:NoSchedule dev-pool has no taint; only spot-pool is tainted (workload-tier)

Result: spot-pool (the cheap node) runs one frontend pod, and dev-pool (on-demand) carries backend, redis, commonly-bot, litellm, clawdbot-gateway, nginx-ingress and both cloudflared pods.

Either the doc is stale or the cluster drifted. Worth resolving either way — a doc claiming work nobody did is the drift class that has already cost this repo a fix commit.

Caveat if anyone acts on it: ADR-015 also warns that anything holding session state must stay off spot (30s reclaim notice). Backend holds Socket.io connections, so the pinning may be a deliberate correction rather than drift. Find out which before moving anything.

3. Why default-pool cannot simply be deleted

It looks nearly idle — but only if you filter out kube-system, which is the mistake I made first. It actually carries a kube-dns replica, both konnectivity agents, metrics-server, external-secrets ×3, kube-state-metrics, l7-default-backend and gmp-operator.

Measured:

must reschedule off default-pool   492m   (a further 276m is DaemonSet/static — dies with the node)
dev-pool free                      599m   (1930m allocatable, 1331m requested = 68%)
→ after absorbing                  107m free

backend is maxUnavailable=0, maxSurge=1, requests 200m, and is pinned nodeSelector: pool=dev. Every rolling deploy needs a second 200m pod on dev-pool before the old one terminates. 107m < 200m, so the next Deploy Dev would stall with the surge pod Pending. It cannot spill to spot-pool (tainted, and no pool label).

It would not fail at deletion time. It would fail at the next deploy.

Cleared as not blockers, for the record: kube-dns already has a second replica on dev-pool; all three nodes are in one zone so no PVC affinity problem; none of default-pool's workloads own a PVC.

4. Zero-downtime deploys are real and worth protecting

Verified end to end, and better configured than assumed:

  • maxSurge=1, maxUnavailable=0
  • readiness probe GET /api/health/ready that 503s until Mongo is connected and runs a real SELECT 1 against PG — with a deliberate 200-plus-degraded:['postgresql'] branch so a PG blip doesn't pull the pod from rotation
  • preStop: sleep 10 — drains before SIGTERM, which is the detail that actually prevents dropped requests
  • terminationGracePeriodSeconds: 30

Two honest limits: open Socket.io connections still drop when the old pod terminates (clients auto-reconnect; invisible at current concurrency, real at scale), and at replicas: 1 this covers deploys but not crashes.

If someone picks this up later

Order matters:

  1. Scheduler singleton — independently valuable, do first.
  2. Resolve the ADR-015 contradiction as a decision (is backend allowed on spot given Socket.io?).
  3. Only then consider the node. The likely-safe shape is replacing default-pool with an untainted spot pool so the 492m lands there rather than on dev-pool — the workloads on it are stateless and replicated, which is exactly the spot profile.
  4. Verify by running a real Deploy Dev and watching the backend rollout complete. Not by checking that pods moved.

replicas: 2 is not the win it appears to be until (1) is done: both replicas would land on the same node anyway (nodeSelector: pool=dev, one dev-pool node), so it buys crash resilience but not node-failure resilience.

Dollar figures are list prices; I could not see the bill.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions