Async executor tuning: threads, acquisition, lock times
Motto — The executor has three dials — how often you look, how much you grab, how many hands you have — and every symptom maps to exactly one of them.
Part of Phase 09 — Operations & observability. Builds on Phase 2, lesson 04.
The Problem
It’s month-end again. 20,000 timers fire in an hour, async bureau calls pile up, and “the process is slow” tickets arrive. But which part is slow? Teams respond by doubling thread pools (usually wrong) or blaming the database (sometimes right).
The Phase 2 toy executor gave you the mental model. Production tuning means mapping symptoms onto its loop — acquire, lock, execute, settle — and knowing which Spring Boot property moves which stage.
The Concept
The pipeline and its dials:
<style> .dgm-et{background:var(–color-surface);border:1px solid var(–color-border);border-top:3px solid var(–color-border-danger-outline); border-radius:16px;padding:28px 26px 22px;box-shadow:0 1px 3px rgb(var(–shadow-color) / 0.04),0 8px 24px rgb(var(–shadow-color) / 0.04); margin:26px 0;max-width:100%;font-family:inherit;text-align:left;position:relative} .dgm-et-chip{position:absolute;top:-11px;left:24px;background:var(–color-background-danger-solid);color:var(–white); font-size:10.5px;font-weight:700;letter-spacing:1px;text-transform:uppercase; border-radius:20px;padding:3px 12px;box-shadow:0 2px 6px color-mix(in oklab, var(–color-background-danger-solid) 28%, transparent)} .dgm-et h4{margin:0 0 4px;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(–color-text-secondary)} .dgm-et-sub{font-size:12px;color:var(–color-text-secondary);margin:0 0 16px} .dgm-et-row{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap} .dgm-et-n{background:var(–color-surface-tertiary);border:1px solid var(–color-border);border-radius:9px;padding:9px 11px;text-align:center; font-size:10.5px;font-weight:700;color:var(–color-text);line-height:1.3} .dgm-et-n.accent{background:var(–color-background-danger-surface);border-color:var(–color-border-danger-surface);color:var(–color-text-danger)} .dgm-et-n.blue{background:var(–color-background-info-surface);border-color:var(–color-border-info-surface);color:var(–color-text-info)} .dgm-et-n.green{background:var(–color-background-success-surface);border-color:var(–color-border-success-surface);color:var(–color-text-success)} .dgm-et-n.red{background:var(–color-background-danger-surface);border-color:var(–color-border-danger-surface);color:var(–color-text-danger)} .dgm-et-arr{color:var(–color-text-tertiary);font-size:12px;display:flex;flex-direction:column;align-items:center;gap:1px} .dgm-et-arr .lbl{font-size:8.5px;font-weight:700;color:var(–color-text-tertiary);text-transform:uppercase;letter-spacing:.3px} @media (max-width:640px){.dgm-et{padding:20px 16px 18px}.dgm-et-row{flex-direction:column}} </style> <div class=“dgm-et”> <span class=“dgm-et-chip”>Four knobs between a due job and a finished one</span> <h4>Acquire, lock, queue, execute — each stage has its own tuning lever</h4> <p class=“dgm-et-sub”>Poll interval and batch size gate how fast jobs get picked up at all.</p> <div class=“dgm-et-row”> <div class=“dgm-et-n neutral”>Job tables</div> <span class=“dgm-et-arr”><span class=‘lbl’>acquire: poll interval × batch size</span>→</span> <div class=“dgm-et-n accent”>Locked set — lock time</div> <span class=“dgm-et-arr”><span class=‘lbl’>queue</span>→</span> <div class=“dgm-et-n blue”>Thread pool — core…max threads + queue size</div> <span class=“dgm-et-arr”><span class=‘lbl’>execute</span>→</span> <div class=“dgm-et-n green”>Your database</div> </div> </div>
| Dial | Property (flowable.process.async-executor-* family) |
Raises when… |
|---|---|---|
| Poll interval | default-async-job-acquire-wait-time (default 10 s) |
jobs sit due but unlocked; latency-sensitive flows want 1–5 s |
| Batch size | max-async-jobs-due-per-acquisition |
many small jobs, acquisition round-trips dominate |
| Lock time | async-job-lock-time (default 5 min) |
legitimate jobs run long — must exceed your slowest job or you get double-execution (Phase 2’s lock-expiry rule) |
| Threads / queue | max-threads (default 8), queue capacity |
threads saturated but DB has headroom |
Symptom → dial, the table you actually use during the incident:
| Symptom (from lesson 04’s probe) | Diagnosis | Move |
|---|---|---|
| due-jobs backlog grows, threads idle | acquisition-bound | shorten poll interval, raise batch |
| threads pegged, DB CPU low | execution-bound (slow external calls in jobs) | raise threads moderately; better: make the slow call the async step itself with retries (Phase 4) |
| threads pegged, DB CPU high | database-bound — the usual truth | tune the DB (lesson 05), don’t add threads that will queue on it |
| same job executes twice | lock time < job duration | raise lock time; verify handler idempotency |
| one instance’s jobs serialize | exclusive jobs (correct default) |
leave it — exclusivity prevents optimistic-lock storms (Phase 2 cheat sheet) |
Two cluster notes. Acquisition contention across many nodes shows up as optimistic-lock exceptions in acquisition — harmless noise at low rates, but a signal to stagger poll intervals at high rates. And scaling executor nodes multiplies pressure on the shared database, which is why most tuning journeys end at the third row of that table.
Use It
The shipped settings file —
outputs/application-tuned.properties —
is Phase 2’s starter config with the four dials set for a mid-volume fintech
workload, and each choice is annotated. Apply it, then prove the change helped.
Replay the month-end burst (the capstone driver in a loop) and watch due-job count
and job age from lesson 04’s probe, before and after. Untested tuning is
superstition with YAML.
Ship It
This lesson ships
outputs/application-tuned.properties —
annotated executor settings you can diff against your own, plus the symptom→dial
table above for the incident channel.
Check Yourself
Q1. Due jobs pile up; executor threads are mostly idle. First dial?
- A) more threads
- B) acquisition — shorten the poll interval and/or raise the acquisition batch
- C) lock time
- D) bigger database
<details><summary>Answer</summary>B — idle threads mean work isn’t reaching them. Threads fix execution pressure, not acquisition lag.</details>
Q2. A bureau-call job occasionally runs 7 minutes; lock time is 5. The symptom you’ll see is…
- A) nothing; locks auto-extend
- B) the job executing twice — the lock expires mid-run and another node acquires it
- C) a dead letter
- D) a deadlock
<details><summary>Answer</summary>B — Phase 2’s crash-recovery mechanism firing on a job that wasn’t dead, merely slow. Lock time must exceed the slowest legitimate job.</details>
Q3. Threads pegged and database CPU pegged. Adding executor threads will…
- A) fix it
- B) make it worse — more concurrent transactions against the real bottleneck
- C) do nothing
- D) reduce DB load
<details><summary>Answer</summary>B — the executor is a client of the DB. When the DB is the constraint, lesson 05 is the tuning guide, not this one.</details>
Challenge. Break the lock rule on purpose in the Phase 2 toy executor: set
LOCK_SECONDS below a sleeping job’s duration, run two nodes, and watch the
double-execution. Then write the one-line monitoring rule that would have caught
it in production (hint: same job id completing twice within lock-time × 2).
Related
- Next: Metrics & health
- The loop being tuned: Phase 2, lesson 04
