When an operation may run longer than 10 seconds, it must be changed to the triggerAsync() + getStatus() asynchronous state machine pattern.
Don't make users stare at a loading spinner that will time out, and even more importantly, don't let them click a second time.
In conjunction with
cc-query-performance-safety: first use the latter to push synchronous performance down to a range that can complete; only when "even after further optimization it cannot be brought 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)
- There are batch remote API calls in the chain, and batch size × single-call latency > 5s
- The synchronous implementation has historically already produced 504/502/30s timeout
- The user description includes "stuck", "spinning for a long time", or "no response after clicking"
Trap #1: Synchronous blocking cut off by the gateway
Scenario: Duplicate check on 6000 products, synchronous interface times out at 30s; the frontend gets 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 truncates by default at 30-60s → users see 502/504
- User retries → backend thread pool is occupied → avalanche
- The 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());
}
Trap #2: Unbounded growth of status cache → memory leak
Root Cause:ConcurrentHashMap<Long, State> As a long-lived cache, every tenant that has ever entered leaves 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 Strategies 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) |
Extremely large statuses (including complete results) |
Checklist
- For any
Map<X, State>used as a member variable, is there a TTL/LRU strategy - Are RUNNING statuses mistakenly cleaned up (they 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)
- Is a large result set stuffed into the status object (such as a list of all product DTOs) → consider storing only a summary, and put the results in Redis or DB
Trap #3: Child threads lose TenantContext
After asynchronous execution, ThreadLocal is not propagated by default. If the business depends on TenantContext, child threads cannot get it → cross-tenant data mixing, null pointers.
// ❌ 子线程拿不到 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.
Trap #4: Duplicate triggers causing concurrent runs
The user clicked the "Refresh" button and nothing happened; they clicked 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: while the button is in RUNNING state, disabled, and the label 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 for recursive polling, and when the status changes to RUNNING, start setInterval as well — the two coexist → each 3s triggers 2 API calls; status changes cause the second effect to recreate the interval → possibly exploding to N×N timers.
There must be only one polling mechanism: on mount, do only one initial fetch, and polling is entirely taken over by the effect that depends on status .
For complete wrong/correct code examples, see
cc-frontend-devskill - Trap #3 "useEffect dual polling conflict"
Frontend UX specification
| Item | Specification |
|---|---|
| Trigger button | immediately changes to loading={true} disabled, label "回填中 N%" |
| Progress visible | Add at the top <Alert> + <Progress>, showing processed / total |
| Visible to any user who comes in | On entering the page, first getStatus(), and automatically start polling when RUNNING |
| Switch pages and come back | Polling depends on status, and remount automatically resumes |
| RUNNING → completed | message.success/error + automatically loadData() refresh the main list |
| FAILED shows the reason | state.errorMessage passed through to message |
| Close the browser and reopen | The backend task is unaffected (runs in the Executor), and can still be checked next time you come in |
Checklist (Code Review)
When adding an async task, check item by item:
- Is async 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 status rejects duplicate triggers
- The child thread restores TenantContext / SecurityContext / MDC
- In FAILED status,
errorMessageis passed 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 |
|---|---|
Start a new thread instead of Executor |
Not managed by the pool, risk of OOM |
| Put the result into the HTTP Session | Fails directly in multi-instance deployment |
| Write the status to a file | Lost in multi-instance deployment and container restarts |
Use @Async but ignore the return value |
The exception is 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 |
Add @Async annotation to a synchronous API (self-invocation) |
Doesn't go through the proxy, so the annotation doesn't take effect → see cc-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 heavyweight too early.ConcurrentHashMap + Executor can cover 80% of "synchronization is too slow" scenarios.
Rule traceability
> 📋 本回复遵循:`cc-async-task-pattern` - [章节]