Skip to content

Adaptive worker polling and concurrency - #178

Open
v1r3n wants to merge 7 commits into
mainfrom
design/adaptive-worker-concurrency
Open

v1r3n wants to merge 7 commits into
mainfrom
design/adaptive-worker-concurrency

Conversation

@v1r3n

@v1r3n v1r3n commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request type

  • Bugfix
  • Feature
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • WHOSUSING.md
  • Other (please describe):

Changes in this PR

Adaptive worker concurrency — an opt-in mode where workers size themselves instead of using fixed thread counts.

Today you guess a threadCount per worker and hope it's right: too low leaves backlog with an idle machine, too high risks OOM or CPU saturation. In adaptive mode, one controller per process continuously answers three questions:

  1. How much work is waiting? Queue depths are sampled once a second (one batch GET /tasks/queue/sizes for all queues) and the process's capacity is divided among workers in proportion to their backlog — a queue with 600 waiting tasks gets ~6x the slots of one with 100.
  2. Can this JVM handle more? Capacity grows ~20% per 5s tick while CPU (smoothed, container-quota-aware) and post-GC heap have headroom; it shrinks 25% under sustained CPU pressure, and pauses new work entirely when post-collection heap is near full — running tasks always finish and report.
  3. Can we take this task right now? Capacity is reserved before every poll and the poll asks for at most that many tasks, so a polled task always has a free thread — nothing waits in an in-memory queue (a Conductor poll accepts work; it is not a preview).

Also: idle queues hold no threads but stay alive via standby long-polls (instant pickup) or 5s heartbeats (keeps the server's 10s active-poller window for domain routing); empty polls back off from the worker's configured polling interval up to 5s; an idle process decays back to minimum capacity.

Usage:

var controller = new AdaptiveConcurrencyController(new AdaptiveConcurrencyConfig.Builder().build());
new TaskRunnerConfigurer.Builder(taskClient, workers)
        .withAdaptiveConcurrency(controller)   // share one controller per process
        .build().init();

Spring: conductor.client.adaptive-concurrency.enabled=true; per-worker ceiling via conductor.worker.<task>.adaptive_max_concurrency.

Included: controller + config + JVM resource sampler, TaskRunner integration (fixed mode unchanged), Spring/annotated-worker wiring, new AdaptiveConcurrencyChanged event + Prometheus gauges (including adaptive_worker_task_slot_age_seconds, the wedged-worker signal), design doc (design/adaptive-worker-concurrency.md), unit tests, e2e suites, and a harness overhead scenario (controller lock p99 was 15µs with 305 queues).

Self-monitoring hardenings: a stalled GC-notification pipeline falls back to collection usage instead of freezing the heap estimate; standby polls require a free process slot; slot counters that would go negative are clamped and reported.

Measured (local server): 30 min at 8 wf/s — adaptive completed 98.7% of 14,400 workflows self-adjusting between 17 and 250 slots with zero client errors and flat memory; fixed 10-threads/worker completed 7% (13,331 of 14,400 workflows still queued or mid-pipeline at cutoff) with a linearly growing backlog, using 1.8x the poll requests. Full curves in the harness logs.

Behavior notes: disabled by default — fixed mode is untouched. In adaptive mode N pollers for a task share one pool (poller count no longer multiplies capacity). withTaskPollCount is deprecated. Virtual threads remain opt-in and use a thread-per-task executor.

Issue #

Alternatives considered

  • Fixed threads with better defaults — still a static guess; cannot respond to backlog shifts or memory pressure at runtime.
  • Used-heap instead of post-GC heap as the memory signal — rejected: used heap routinely spikes above 90% before a normal young collection; post-collection usage (incl. the fullest old-gen pool) is the JVM's real live-data estimate. Validated by load tests that OOM'd under the naive signal.
  • Server-side admission control — would require protocol changes; this design works with existing APIs and keeps the server authoritative over queues while the client governs only its own capacity.

v1r3n and others added 5 commits October 18, 2026 11:36
Design for an opt-in mode in which one controller per process divides a
process-wide concurrency target among its workers in proportion to queue
depth, grows the target under demand with CPU and post-GC heap headroom,
shrinks it under pressure, and reserves capacity before every poll. Uses
the existing batch poll, update, update-v2 and queue sizes APIs; no server
protocol change.
Size a process-wide slot pool from CPU, post-GC heap and queue backlog,
and share it across task queues by weight. A queue is polled only when
it holds an open slot. Queues whose share is fully held keep one
single-task standby poll open on unclaimed slots, so idle pickup stays
close to fixed-thread latency.

Heap growth uses the highest post-GC reading of the last 30 seconds,
and the control tick runs on a fixed-delay timer so an OutOfMemoryError
cannot stop it. Configurable through AdaptiveConcurrencyConfig and the
Spring client properties.
Mixed-workload and functional scenarios (weights, rotation, starvation,
scaling, warm start, heap, cpu) against a Conductor server started with
the Conductor CLI, with workers as host JVMs.
A failure after completePoll (metadata stamping, event publish) dropped
the delivered batch with its slots held forever; a throw from lease
scheduling mid-dispatch leaked the rest of the batch the same way.
Settle now wraps only the network call, post-settle bookkeeping cannot
drop tasks, and dispatch releases the slot on any submit failure while
lease-scheduling rejection only logs. Also removes the latent
double-settle when completePoll itself threw.

Docs: add the missing controller state (depthAtEmptyPoll, nextStandbyAt,
standbyPolls, pollableAtSample, heapGrowthCeiling, heap baseline and
30s readings window) and the pause-entry growth ceiling to the
pseudocode; document the bounded one-second reservation wait; mark the
design as implemented.

Harness: add ControllerOverheadMain, a controller-overhead scenario
(hundreds of idle queues plus a governed busy load) probing lock
acquisition latency and per-thread-group CPU; measured p99 15us at 305
queues. Main class is overridable via -PharnessMain.
@v1r3n v1r3n changed the title Design/adaptive worker concurrency Adaptive worker polling and concurrency Oct 20, 2026
v1r3n added 2 commits October 21, 2026 13:29
Three silent-degradation paths become visible or impossible:

- A stalled GC notification pipeline no longer freezes the heap
  estimate: when collections keep advancing without a notification the
  sampler falls back to collection usage until they resume.
- Standby polls now also require a free process slot; unclaimed slots
  are computed over registered queues and could overstate capacity
  while a deregistered worker's tasks drain.
- Slot counters are guarded: a release bug that would drive them
  negative (inflating deficits and over-admitting) is clamped and
  reported instead of compounding.

New observability: adaptive_worker_task_slot_age_seconds, seconds since
a worker last released a task slot while holding any — the signal that
separates a wedged worker from a busy one.

This branch has not been deployed

No deployments
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