ADR-0014: Scale the core by workload class and replica, not by module¶
| Status | Accepted. Implemented and verified locally (unit tests, Helm render and schema checks). Two API replicas now boot and serve on a kind cluster with replicaStrict and the Valkey stores (2026-10-14, section 6); open-stream rolling updates and scale-in under load are still unproven. |
| Date | 2026-10-12 |
| Deciders | Platform Engineering (maintainers) |
| Related | ADR-0001 modular monolith, Kubernetes architecture, Deployment shapes, overview |
1. Context¶
Kubernetes makes it cheap to run many small services, and the core Maven reactor (about 45 modules)
looks like a candidate for splitting. The constraint that decides the question is the chat path. An agent
turn runs input guardrails, tool dispatch, retrieval, the model call, output guardrails, budget accounting
and the audit write, and it streams tokens back over one open HTTP connection. Putting a network call
between any two of those steps adds latency to every turn and a new way for a turn to fail halfway through
a stream. AGENTS.md rule 6 already forbids it.
So the question is not "which modules become services" but "what can be separated without touching that path, and what stops us running more than one copy of it".
What already existed when this was written:
- One image, several shapes selected by Spring profile:
api,jobs(every scheduled sweep and poller),worker(Temporal) andmigrate. A scheduling-role mechanism skips beans per shape. - Cross-replica mutual exclusion for scheduled work (
PgAdvisoryJobLock), enforced byScheduledJobsAreLockedOrAnnotatedTest: every@Scheduledmethod is either locked or carries@AllowUnlockedSchedulewith a reason. - Valkey-backed implementations of most per-pod stores, a boot-time
ReplicaModeSafetyCheck(app.replica-mode=multi), and a Helm gate that refused more than one API replica.
What was missing:
- The Helm chart switched only three of the nine per-pod stores, and never turned
ReplicaModeSafetyCheckon. Eight of the nine were inspected by that check; the tool effect journal by nothing. A chart that said "ready for replicas" still kept guardrail procedure state, the effect journal, sandbox files, the E2B quota, resumable streams and Studio approvals in per-pod memory. - Nothing stopped a new in-memory store from being added without anyone noticing.
- Autoscaling looked only at CPU, which is a poor signal for a process that mostly waits on model tokens.
- Nothing checked that API replicas times the Hikari pool fits the database's connection limit. The charts referred to a "DB connection budget" that no document defined.
- All background work shared one jobs pod, so a large document upload competed with agent jobs for heap, CPU and database connections.
2. Decision¶
Keep one deployable codebase and one image. Scale it by replica and by workload class. Never insert a network hop between platform modules on the turn path. Five rules follow.
R1. The turn path stays in one process¶
Guardrails, tool calls, retrieval, provider calls, budget and audit remain method calls inside the API pod. Scaling the API means more identical pods behind the Service, not a different topology. A stream is one connection held by one pod, so streaming needs no cross-pod traffic. The only cross-pod read is a client that reconnects to a different pod and resumes a stream from the shared buffer (Valkey), which costs one read on reconnect.
R2. Work is partitioned by profile, and the partition is tested¶
A shape or pool is the same image started with a profile that decides which @Scheduled beans and boot
listeners run. Splitting a pool changes which pod runs a job, never whether it runs.
JobsPoolsCoverEveryJobTest reads the real profile files and the real @Scheduled classes and fails if a
class the single jobs pod runs would run in neither pool of a split, if a profile names a class that does not
exist, or if the ingest pool would run anything other than the two knowledge pollers.
The mechanism is app.scheduling.skip-bean-classes plus a new app.scheduling.keep-bean-classes for
exceptions, so a narrow pool can say "skip everything except these".
R3. Every piece of per-pod state is classified, and the classification is enforced¶
| Class | Meaning | Examples | Enforced by |
|---|---|---|---|
| Durable | In Postgres; any pod sees it | agent jobs, workflow runs, step journal | schema |
| Shared | Per-pod memory by default, Valkey when multi-replica | A2A tasks, abuse throttles, MCP registry, guardrail procedure state, effect journal, resumable streams | ReplicaStoreCoverageTest, ReplicaModeSafetyCheck |
| Per-JVM by design | Describes this JVM only; every replica runs it | browser sessions, MCP heartbeat, landing-event buffer | @AllowUnlockedSchedule(reason = ...) |
ReplicaStoreCoverageTest fails when a property whose code default is memory is neither inspected by
ReplicaModeSafetyCheck nor allowlisted with a reason, and when the Helm library chart does not set a checked
store to redis for a multi-replica API. Adding a ninth store without wiring it breaks the build.
R4. Autoscale on open requests per pod and scale in slowly¶
CPU alone under-reports load. Spring Boot already exports the number of HTTP requests a pod is serving,
http_server_requests_active_seconds_count, which for this API is dominated by open SSE chat streams.
InFlightRequestsMetricContractTest pins that series name against a Micrometer or Boot upgrade. The chart
accepts extra HPA metrics, offers an opt-in KEDA ScaledObject, and ships an example. Scale-in is slowed by
default (one pod per five minutes after ten quiet minutes) because removing a pod cuts the streams still
open on it once the drain window passes.
Resumable streams let a client read a turn from another pod; they do not move the generation. The turn runs in the pod that started it, so a pod that is removed finishes its open turns only within the drain window. The scale-out preset raises the drain to 300 seconds with a matching termination grace period.
R5. The database connection budget is checked when the chart renders¶
Every core pod holds up to its Hikari pool: API 12 (15 without core-jobs), jobs 10, ingest 6, worker 8,
and the migrate hook Job 10. The umbrella chart sums the worst case, using the autoscaler's maximum for the
API, and fails the render if it exceeds the server limit minus a reserve of 20. The limit is
postgres.parameters.max_connections for the bundled cluster (chart default 200) or
global.postgresql.maxConnections for an external server. PgBouncer must stay in session mode
(AGENTS.md rule 8); transaction pooling would run queries in the wrong tenant's schema.
3. What was built¶
| Area | Change | Where |
|---|---|---|
| Replica contract | Chart sets APP_REPLICA_MODE=multi when replicaCount > 1, an HPA or KEDA is on, and puts all shared stores in Valkey. New flags default to true so existing multi-replica installs keep rendering. replicaStrict makes fatal findings refuse to boot. |
vegaduta-lib/_core.tpl, charts/core/values.yaml |
| Safety check | Also reports the in-memory tool effect journal (non-fatal). | ReplicaModeSafetyCheck |
| Drift guard | New stores cannot be added without being checked and wired. | ReplicaStoreCoverageTest |
| Autoscaling | hpa.metrics, slow scale-in default, opt-in KEDA, values-autoscaling.yaml. |
vegaduta-lib/_workload.tpl |
| Connection budget | Render-time guard. | charts/vegaduta/templates/_guards.tpl |
| Pools | app.scheduling.keep-bean-classes; profiles shape-jobs-core and shape-jobs-ingest; chart core-ingest (same image as core-jobs); shape-api also skips KnowledgeReembedJobRunner so heavy re-embedding stays off the chat pods. |
SchedulingRoleMatcher, app-config, charts/core-ingest |
| Presets | values-scale-out.yaml (3 replicas, strict, long drain) and values-autoscaling.yaml. |
deploy/helm/examples |
4. Alternatives considered¶
- Split modules into services. Rejected for everything on the turn path (R1). Possible for work that is already asynchronous, which is what pools do without adding a network protocol between modules.
- One jobs pod, scaled up. Does not isolate heavy ingestion from agent jobs, and a single JVM is a single failure domain.
- Arbitrary pool names in the chart. Rejected: a pool is only meaningful with a profile that defines what it owns and a test that proves the partition. The first change shipped one named pool with the mechanism ready for more; webhooks, intake and billing have since been added the same way (section 5), each with its own profile entry, chart and test coverage.
- Overlapping Deployments of one chart for pools. Rejected: two Deployments with overlapping pod
selectors are fragile, and NetworkPolicies and Services select by component name. A separate chart gives a
clean identity, matching how
core-workeris built. - Cells (one full stack per tenant group, routed at the ingress). The only way to partition the turn path itself, with no hops between modules. Deferred until one cluster is genuinely saturated; it adds routing and operations cost that nothing here needs yet.
5. Consequences and limits¶
- More pools (webhooks, intake, billing), same rules. They are defined in
app.scheduling.poolsand chosen byapp.scheduling.pool(pool pod) andapp.scheduling.offloaded-pools(main jobs pod), because a profile replaces a whole list and so cannot say "skip whatever pools run elsewhere" for an arbitrary combination.JobsPoolsCoverEveryJobTestchecks all 16 combinations. Eval runs are not a pool: they execute in the API JVM and have no poller to move. See Deployment shapes. - The ingest pool adds isolation, not parallelism. Both knowledge pollers hold one Postgres advisory lock for
the whole queue walk, so extra replicas give failover only. Parallel ingestion is opt-in per-job claiming
(
KNOWLEDGE_INGESTION_CLAIMING_ENABLED, below).
Parallel ingestion claiming (opt-in)¶
KnowledgeIngestionJobClaimer claims one knowledge_ingestion_job row per statement:
WITH c AS (SELECT ... WHERE status = 'QUEUED' OR (status = 'PROCESSING' AND updated_at < now() - lease) ORDER BY
created_at LIMIT 1 FOR UPDATE SKIP LOCKED) UPDATE ... SET status = 'PROCESSING', updated_at = now(). The lease is the
existing updated_at column, refreshed by a heartbeat every lease/4 while the job runs, so no migration is needed.
On the claiming path every write to the job row (claim, heartbeat, completion, failure) is a SQL statement using the
database's now(), and the lease test also uses now(), so the lease compares database time with database time and no
JVM clock is involved (the JPA entity is no longer saved on this path). The sweep over tenants is unchanged (same
rotating walk, same per-tenant cap and tick budget); only the per-tenant step differs, and each claim names its tenant
schema explicitly. A reclaimed job first deletes the document's chunks and parent chunks, so the retry does not
duplicate what a dead run had already written.
- Attempt cap, no migration.
result_chunk_countis unused until a job completes, so while a job is notCOMPLETEit counts claims (1 on the first run, +1 per reclaim). A reclaim of a job already claimedKNOWLEDGE_INGESTION_CLAIM_MAX_ATTEMPTStimes (default 3) marks itFAILEDand clears its audio instead of running it, so a job that kills its pod every time stops after the cap. The API hides the counter unless the job isCOMPLETE. A dedicated column would be cleaner; it was not added because this change takes no migration. - Fencing the job row. The attempt number is a fencing token: heartbeat, completion and failure apply only while
status = 'PROCESSING'and the stored attempt equals the caller's, so a live-but-slow earlier attempt cannot overwrite or regress what a later attempt wrote. Its chunk writes (throughKnowledgeIngestionService) are not fenced: a duplicate run can still write chunks, and the reclaim purge only runs before the second run starts. - Mixed mode. A replica with the flag off still starts jobs under the advisory lock, but through a conditional
UPDATE ... WHERE status = 'QUEUED', so it skips a job a claiming replica already owns. It does not heartbeat, so a job it runs for longer than the lease can be reclaimed by a claiming replica. Switch allingestreplicas in one rollout. Replicas still on a build from before this change are not protected at all.
What is not proven: the lease is a time-based guess. A runner that is alive but cannot heartbeat (a long pause, a lost
connection, a starved connection pool) can have its job re-run; keep the lease well above a worst-case pause. Run on
real Postgres (KnowledgeIngestionClaimingIT): the claim statement under 6 concurrent threads in one JVM, the attempt
cap, fencing, and the real runner, service and claimer together with a stubbed sidecar and ingest call (completion,
reclaim purge, a real heartbeat thread, mixed mode). Not run: several replicas as separate processes, the real
sidecar, or connection-pool pressure; no throughput number is measured. Re-embedding still uses the advisory lock, and
running it next to claiming has not been verified. The Helm core-ingest chart still refuses more than one replica
unless leaderElection is true, which has not been checked together with claiming.
- Fourteen advisory-locked sweeps still tick in the API shape as well as the jobs shape. They are
correct (the lock makes one pod win) but they borrow a pinned database connection in a chat pod. Each can move
once its owner confirms nothing depends on API-pod state; the ownership table in
Deployment shapes lists them.
- MCP OAuth is multi-replica safe only behind app.mcp.oauth.store=redis. The
McpOAuthSharedStore Valkey implementation now carries the single-use state set, the token
cache (written encrypted, as before) and the refresh single-flight; the default memory is
per JVM. With replicaStrict the pod refuses to boot while app.mcp.oauth.enabled is on and
the store is memory; the provider-OAuth finding is non-fatal. The chart switches it through
core.replicaReadiness.mcpOAuthStoreRedis (default on, render fails if set false with 2+
replicas).
- A removed pod cuts its open turns after the drain window. Mitigated, not eliminated (R4).
- One config checksum changes, so existing installs roll their core pods once on upgrade.
- Jobs pods are not autoscaled. There is no queue-depth gauge to scale on.
6. Verification status¶
| What | State |
|---|---|
ReplicaStoreCoverageTest, JobsPoolsCoverEveryJobTest, SchedulingRoleTest, ReplicaModeSafetyCheckTest, InFlightRequestsMetricContractTest |
Run, pass. The first two were also run against a deliberately broken input and failed as intended. |
Helm render guards (deploy/helm/test-guards.sh) |
Run, 278 cases pass |
deploy/helm/validate.sh: lint, template across scenarios including scale-out and scale-out-autoscaling, kubeconform on Kubernetes 1.28 and 1.31 |
Run, pass |
HPA with a Pods metric, KEDA ScaledObject |
Rendered and schema-checked only. No cluster had prometheus-adapter or KEDA. |
| Two or more API replicas serving traffic, a rolling update with open streams, scale-in under load | Partially run (2026-10-14, kind). Two core replicas booted Ready under replicaStrict with every shared store on the bundled Valkey, including MCP_OAUTH_STORE=redis with APP_MCP_OAUTH_ENABLED=true (the safety check logged "no unsafe per-JVM settings found" on both pods, and the same settings on the pre-shared-store image failed boot exactly as designed). The rolling upgrade replaced one replica at a time with maxUnavailable=0. NOT yet proven: an OAuth callback handled by the other replica mid-flow, open chat streams surviving a rollout, scale-in under load. |
| Memory and database connections under the budget guard | Arithmetic from configured pool sizes, not measured |
The deeper exercise still owed is a load run from tests/load against the kind scale-out - open
streams across a rollout, a Valkey restart, and an OAuth callback that lands on the replica that did
not start the flow.