When an operation may run for more than 10 seconds, it must be changed to the triggerAsync() + getStatus() asynchronous state machine pattern.
Do not make the user stare at a loading spinner that will time out, and even more so, do not let them click a second time.
In conjunction with
query-performance-safety: first use the latter to compress synchronous performance to a range that can finish; only when "no amount of further optimization can get it under 10s" should this specification be followed.
Trigger Conditions
As long as any one condition is met, it falls within the scope of this skill:
- A single operation processes ≥ 1000 records (batch import, full table scan, cross-tenant aggregation)
- The chain includes remote API batch calls, and batch size × single-call latency > 5s
- The historical synchronous implementation has already produced 504/502/30s timeout
- The user's description includes "stuck", "spinning for a long time", or "no response after clicking"
Pitfall #1: Synchronous blocking cut off by the gateway
Scenario: 6000 product duplicate checks, synchronous interface times out at 30s; the frontend receives no response, and the user clicks repeatedly.
Incorrect Example
// ❌ HTTP 请求线程内串行跑 N 次远程调用 + JPA 写库
@PostMapping("/duplicates/check")
public ApiResponse<Map<String, List<ProductDTO>>> checkDuplicates() {
return ApiResponse.success(productService.findDuplicateProducts());
// 6000 条 × 4 次隐藏查询 = 24000 次 SQL,必然 > 30s
}
Consequences:
- Nginx/gateway default 30-60s cutoff → user sees 502/504
- User retries → backend thread pool is exhausted → avalanche
- Frontend loading state cannot be preserved across refreshes
Correct Template (Spring Version)
@Service
public class DuplicateChecker {
private static final long FINISHED_STATE_TTL_MINUTES = 60;
private final Executor executor;
private final ProductService productService;
// 关键:状态缓存按租户隔离,永远不要让它无界增长
private final Map<Long, CheckState> tenantStates = new ConcurrentHashMap<>();
public CheckState triggerAsync() {
evictExpiredStates();
Long tenantId = TenantContext.getTenantId();
// 同租户已 RUNNING:返回当前状态,不重复启动
CheckState existing = tenantStates.get(tenantId);
if (existing != null && existing.getStatus() == Status.RUNNING) {
return existing.snapshot();
}
CheckState state = new CheckState();
state.setStatus(Status.RUNNING);
state.setStartedAt(LocalDateTime.now());
tenantStates.put(tenantId, state);
executor.execute(() -> {
try {
TenantContext.setTenantId(tenantId); // 子线程必须恢复上下文
run(tenantId, state);
} finally {
TenantContext.clear();
}
});
return state.snapshot();
}
public CheckState getStatus() {
evictExpiredStates();
Long tenantId = TenantContext.getTenantId();
CheckState state = tenantStates.get(tenantId);
if (state == null) {
CheckState idle = new CheckState();
idle.setStatus(Status.IDLE);
return idle;
}
return state.snapshot();
}
private void evictExpiredStates() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(FINISHED_STATE_TTL_MINUTES);
tenantStates.entrySet().removeIf(entry -> {
CheckState s = entry.getValue();
return s.getStatus() != Status.RUNNING
&& s.getFinishedAt() != null
&& s.getFinishedAt().isBefore(threshold);
});
}
public enum Status { IDLE, RUNNING, SUCCESS, FAILED }
}
// Controller 立即返回
@PostMapping("/duplicates/check")
public ApiResponse<CheckState> trigger() {
return ApiResponse.success("已触发", duplicateChecker.triggerAsync());
}
@GetMapping("/duplicates/check/status")
public ApiResponse<CheckState> status() {
return ApiResponse.success(duplicateChecker.getStatus());
}
Pitfall #2: Unbounded growth of the status cache → memory leak
Root Cause:ConcurrentHashMap<Long, State> As a long-lived cache, every tenant that has ever entered leaves behind a record, and it is not cleaned up even after FINISHED.
// ❌ 错误:永远不清理
private final Map<Long, RefreshState> tenantStates = new ConcurrentHashMap<>();
// 多租户 SaaS 跑 3 个月后:1000 个租户 × 平均 2KB state = 内存只增不减
// 若 state 里还带着完整结果集(duplicateGroups Map<String,List>)→ 单租户就可能 MB 级
Cleanup Strategy That Must Be Implemented
Choose one (the first is recommended, as it is the simplest):
| Strategy | Implementation | Applicable |
|---|---|---|
| Entry cleanup + TTL(Recommended) | On each triggerAsync/getStatus , traverse and clean up finishedAt < now - 1h non-RUNNING statuses |
Number of statuses < 1000, high access frequency |
| Scheduled task cleanup | @Scheduled(fixedRate = 600000) Scan every 10 minutes |
Large number of statuses, low access frequency |
| LRU capacity limit | Caffeine.newBuilder().maximumSize(500).expireAfterWrite(1, HOURS) |
Very large statuses (including complete results) |
Checklist
- Any
Map<X, State>used as a member variable: is there a TTL/LRU strategy - Is the RUNNING status mistakenly cleaned up (it must not be!)
- Is the cleanup trigger point on the hot path (cleaning up on every status call is OK; cleaning up on every trigger is OK)
- Does the status object contain a large result set (such as a list of all product DTOs) → consider storing only a summary and putting the result in Redis or DB
Pitfall #3: Child threads lose TenantContext
After asynchronous execution, ThreadLocal is not propagated by default. If the business depends on TenantContext, the child thread cannot obtain it → cross-tenant data mixing, null pointer.
// ❌ 子线程拿不到 tenantId
executor.execute(() -> run(state));
// ✅ 在子线程入口手动恢复
executor.execute(() -> {
try {
TenantContext.setTenantId(tenantId);
run(tenantId, state);
} finally {
TenantContext.clear();
}
});
The same applies to:
SecurityContextHolder、MDC(logging context), custom request-level ThreadLocal.
Pitfall #4: Duplicate triggers causing concurrent execution
The user clicked the "Refresh" button and nothing happened; clicking it again → the backend started two concurrent tasks that overwrote each other's state.
public State triggerAsync() {
State existing = states.get(key);
if (existing != null && existing.getStatus() == Status.RUNNING) {
return existing.snapshot(); // 关键:直接返回,不要重新启动
}
// ... 启动新任务
}
Frontend coordination: the button, while in RUNNING state, disabled, the text changes to "回填中 N%", so that any user who comes in sees the same task running.
Trap #5: Frontend dual useEffect polling conflict
On mount use setTimeout recursive polling, and when the state changes to RUNNING, start setInterval again — the two coexist → the API is hit twice every 3s; state changes cause the second effect to recreate the interval → potentially N×N timers.
There can be only one polling mechanism: on mount, do only one initial fetch; polling is entirely handled by the effect depending on status .
See
frontend-devskill - Trap #3 "useEffect dual polling conflict" for complete wrong/correct code examples
Frontend UX specification
| Item | Specification |
|---|---|
| Trigger button | immediately changes to loading={true} disabled, text "回填中 N%" |
| Progress visible | Add to top <Alert> + <Progress>, displays processed / total |
| Any user coming in can see it | On entering the page first getStatus(), automatically start polling when RUNNING |
| Switch page and come back | polling depends on status; remount automatically resumes |
| RUNNING → complete | message.success/error + automatic loadData() refresh the main list |
| FAILED shows the reason | state.errorMessage passed through to message |
| Close browser and reopen | Backend task is unaffected (runs in the Executor), and can still be queried next time |
Checklist (Code Review)
When adding a new async task, check each item:
- Whether async is really needed (if synchronous can be kept under < 5s, don't use it)
- The trigger API returns immediately, without any business computation
- The status cache has TTL/LRU
- RUNNING state rejects duplicate triggers
- The child thread restored TenantContext / SecurityContext / MDC
- FAILED state's
errorMessagepassed through to the frontend - The frontend has only one polling mechanism
- The frontend button is disabled while RUNNING
- Critical paths have
log.infowritten for troubleshooting (start / progress / end)
Anti-patterns (don't do this)
| Anti-pattern | Why it doesn't work |
|---|---|
Starting a new thread instead of Executor |
not managed by a pool, OOM risk |
| Stuffing the result into the HTTP Session | Directly fails in multi-instance deployment |
| Writing the state to a file | Lost in multi-instance deployment and container restarts |
Using @Async but ignoring the return value |
Exceptions are swallowed, and the user doesn't know it failed |
Frontend polling uses setInterval but doesn't clean up |
It keeps running after route switching, repeatedly triggering setState warnings |
Adding @Async annotation to a synchronous method (self-invocation) |
Doesn't go through the proxy, annotation doesn't take effect → see java-dev skill "Spring self-invocation trap" |
Upgrade path
Evolution direction as task scale continues to grow:
| Current scale | Recommended solution |
|---|---|
| Single-tenant single process, < 1 hour | The ConcurrentHashMap state machine described in this skill |
| Multi-instance deployment, requires shared state | Store state in Redis (TTL 1h) + distributed lock |
| Long tasks (> 1 hour) or requiring retries | Use a dedicated task queue (Spring Batch / XXL-Job / RocketMQ) |
| Workflow orchestration | Temporal / Activiti |
Do not go for heavyweight solutions too early.ConcurrentHashMap + Executor can cover 80% of the "synchronous is too slow" scenarios.
Rule traceability
> 📋 本回复遵循:`async-task-pattern` - [章节]