task-73: 本地文件作业队列 in-flight 去重和队列背压
This commit is contained in:
+32
-2
@@ -11,6 +11,9 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -26,6 +29,15 @@ public class TaskFileJobLocalDispatcher {
|
||||
@Value("${aiimage.result-file-job.local-dispatch-enabled:true}")
|
||||
private boolean localDispatchEnabled;
|
||||
|
||||
/**
|
||||
* 已受理未完成的 jobId 集合:同 job 重复 dispatch 幂等返回;
|
||||
* 数量达到上限时拒绝新提交(背压),防止执行队列无界堆积。
|
||||
*/
|
||||
@Value("${aiimage.result-file-job.max-inflight-dispatch:64}")
|
||||
private int maxInflightDispatch = 64;
|
||||
|
||||
private final Set<Long> inflightJobIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public boolean dispatch(Long jobId, Long taskId, String moduleType) {
|
||||
return dispatch(jobId, taskId, moduleType, false);
|
||||
}
|
||||
@@ -39,15 +51,31 @@ public class TaskFileJobLocalDispatcher {
|
||||
jobId, taskId, moduleType);
|
||||
return false;
|
||||
}
|
||||
int inflightLimit = Math.max(1, maxInflightDispatch);
|
||||
if (!inflightJobIds.contains(jobId) && inflightJobIds.size() >= inflightLimit) {
|
||||
log.warn("[task-file-job] local dispatch backpressure, inflight limit reached jobId={} taskId={} moduleType={} inflight={} limit={}",
|
||||
jobId, taskId, moduleType, inflightJobIds.size(), inflightLimit);
|
||||
return false;
|
||||
}
|
||||
if (!inflightJobIds.add(jobId)) {
|
||||
log.info("[task-file-job] local dispatch skipped, job already inflight jobId={} taskId={} moduleType={}",
|
||||
jobId, taskId, moduleType);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
taskFileJobDispatchExecutor.execute(() -> processLocally(jobId, taskId, moduleType));
|
||||
return true;
|
||||
} catch (RuntimeException ex) {
|
||||
inflightJobIds.remove(jobId);
|
||||
log.warn("[task-file-job] local dispatch executor rejected jobId={} taskId={} moduleType={} msg={}",
|
||||
jobId, taskId, moduleType, ex.getMessage(), ex);
|
||||
if (force) {
|
||||
processLocally(jobId, taskId, moduleType);
|
||||
return true;
|
||||
try {
|
||||
processLocally(jobId, taskId, moduleType);
|
||||
return true;
|
||||
} finally {
|
||||
inflightJobIds.remove(jobId);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -71,6 +99,8 @@ public class TaskFileJobLocalDispatcher {
|
||||
} catch (Exception ex) {
|
||||
log.warn("[task-file-job] local dispatch failed jobId={} taskId={} moduleType={} msg={}",
|
||||
jobId, taskId, moduleType, ex.getMessage(), ex);
|
||||
} finally {
|
||||
inflightJobIds.remove(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 73:为本地文件作业队列增加 in-flight 去重和队列背压。
|
||||
* TaskFileJobLocalDispatcher 以 in-flight 集合跟踪已受理未完成的 job:
|
||||
* 同一 jobId 重复 dispatch 幂等返回(不重复入队);in-flight 数量达到
|
||||
* max-inflight-dispatch 上限时拒绝新提交(背压,调用方走同步兜底);
|
||||
* 执行完成或失败后释放 in-flight 名额;executor 拒绝时名额一并释放。
|
||||
*/
|
||||
class TaskFileJobLocalDispatcherTest {
|
||||
|
||||
private TaskFileJobMapper taskFileJobMapper;
|
||||
private ObjectProvider<TaskResultFileJobWorker> workerProvider;
|
||||
private TaskResultFileJobWorker worker;
|
||||
private TaskExecutor executor;
|
||||
private TaskFileJobLocalDispatcher dispatcher;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
taskFileJobMapper = mock(TaskFileJobMapper.class);
|
||||
worker = mock(TaskResultFileJobWorker.class);
|
||||
workerProvider = mock(ObjectProvider.class);
|
||||
when(workerProvider.getIfAvailable()).thenReturn(worker);
|
||||
executor = mock(TaskExecutor.class);
|
||||
dispatcher = new TaskFileJobLocalDispatcher(taskFileJobMapper, workerProvider);
|
||||
ReflectionTestUtils.setField(dispatcher, "taskFileJobDispatchExecutor", executor);
|
||||
ReflectionTestUtils.setField(dispatcher, "maxInflightDispatch", 8);
|
||||
// @Value 注解在纯单测中不生效,boolean 字段默认 false,需显式开启本地派发。
|
||||
ReflectionTestUtils.setField(dispatcher, "localDispatchEnabled", true);
|
||||
}
|
||||
|
||||
private TaskFileJobEntity job(Long id) {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setId(id);
|
||||
job.setTaskId(100L + id);
|
||||
job.setModuleType("SIMILAR_ASIN");
|
||||
return job;
|
||||
}
|
||||
|
||||
private void stubJobFound(Long id) {
|
||||
when(taskFileJobMapper.selectById(id)).thenReturn(job(id));
|
||||
}
|
||||
|
||||
/** executor 捕获 runnable 不执行,模拟任务仍在 in-flight。 */
|
||||
private List<Runnable> captureRunnables() {
|
||||
List<Runnable> captured = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
captured.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(executor).execute(any(Runnable.class));
|
||||
return captured;
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_normal_default_path() {
|
||||
// 默认路径:正常 dispatch 提交到执行器并返回 true。
|
||||
stubJobFound(1L);
|
||||
captureRunnables();
|
||||
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
|
||||
verify(executor, times(1)).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_normal_multiple_items() throws Exception {
|
||||
// 批量场景:多个不同 jobId 依次 dispatch,互不冲突,全部受理。
|
||||
stubJobFound(1L);
|
||||
stubJobFound(2L);
|
||||
stubJobFound(3L);
|
||||
List<Runnable> captured = captureRunnables();
|
||||
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
|
||||
assertTrue(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"));
|
||||
assertTrue(dispatcher.dispatch(3L, 103L, "SIMILAR_ASIN"));
|
||||
assertEquals(3, captured.size(), "三个不同 job 都入队");
|
||||
|
||||
for (Runnable runnable : captured) {
|
||||
runnable.run();
|
||||
}
|
||||
verify(worker, times(3)).process(any(TaskFileJobEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 幂等:同一 jobId 在 in-flight 期间重复 dispatch 不重复入队,返回 true 表示已受理。
|
||||
stubJobFound(1L);
|
||||
List<Runnable> captured = captureRunnables();
|
||||
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "重复 dispatch 幂等受理");
|
||||
assertEquals(1, captured.size(), "同一 job 只入队一次");
|
||||
|
||||
captured.get(0).run();
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "完成后可再次受理");
|
||||
assertEquals(2, captured.size());
|
||||
captured.get(1).run();
|
||||
verify(worker, times(2)).process(any(TaskFileJobEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_boundary_empty_input() {
|
||||
// 空输入:null jobId 直接拒绝,不提交、不计数。
|
||||
captureRunnables();
|
||||
|
||||
assertFalse(dispatcher.dispatch(null, null, null));
|
||||
assertFalse(dispatcher.dispatch(0L, null, null));
|
||||
verify(executor, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_boundary_single_item() throws Exception {
|
||||
// 单元素:单 jobId dispatch 一次执行一次,不依赖批量路径。
|
||||
stubJobFound(9L);
|
||||
List<Runnable> captured = captureRunnables();
|
||||
|
||||
assertTrue(dispatcher.dispatch(9L, 109L, "SIMILAR_ASIN"));
|
||||
captured.get(0).run();
|
||||
verify(worker).process(any(TaskFileJobEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_boundary_limit_and_overflow() throws Exception {
|
||||
// 上限/超限:in-flight 达到上限后新提交被拒绝(背压);
|
||||
// 先完成的 job 释放名额后再次提交成功。
|
||||
stubJobFound(1L);
|
||||
stubJobFound(2L);
|
||||
stubJobFound(3L);
|
||||
ReflectionTestUtils.setField(dispatcher, "maxInflightDispatch", 2);
|
||||
List<Runnable> captured = captureRunnables();
|
||||
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"));
|
||||
assertTrue(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"));
|
||||
assertFalse(dispatcher.dispatch(3L, 103L, "SIMILAR_ASIN"), "达到上限拒绝,背压生效");
|
||||
assertEquals(2, captured.size(), "被拒绝的 job 不入队");
|
||||
|
||||
captured.get(0).run();
|
||||
assertTrue(dispatcher.dispatch(3L, 103L, "SIMILAR_ASIN"), "名额释放后可再次提交");
|
||||
assertEquals(3, captured.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_invalid_input_rejected() {
|
||||
// 非法参数:jobId<=0 直接拒绝;非法上限配置回退到 1(不崩溃、行为确定)。
|
||||
stubJobFound(1L);
|
||||
ReflectionTestUtils.setField(dispatcher, "maxInflightDispatch", 0);
|
||||
captureRunnables();
|
||||
|
||||
assertFalse(dispatcher.dispatch(-1L, null, null), "非法 jobId 拒绝");
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "上限 0 回退到 1,首个受理");
|
||||
assertFalse(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"), "回退上限已满,背压拒绝");
|
||||
verify(executor, times(1)).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_073_dispatch_dependency_failure_releases_resources() {
|
||||
// 依赖失败:executor 拒绝时释放 in-flight 名额,恢复后再次 dispatch 成功;
|
||||
// force 路径同步兜底执行且不残留名额。
|
||||
stubJobFound(1L);
|
||||
stubJobFound(2L);
|
||||
doThrow(new RuntimeException("executor full"))
|
||||
.doNothing()
|
||||
.doThrow(new RuntimeException("executor full"))
|
||||
.doNothing()
|
||||
.when(executor).execute(any(Runnable.class));
|
||||
|
||||
assertFalse(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "executor 拒绝返回 false");
|
||||
assertTrue(dispatcher.dispatch(2L, 102L, "SIMILAR_ASIN"), "名额已释放,再次提交成功");
|
||||
verify(worker, never()).process(any(TaskFileJobEntity.class));
|
||||
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN", true), "force 路径同步兜底");
|
||||
verify(worker, times(1)).process(any(TaskFileJobEntity.class));
|
||||
|
||||
assertTrue(dispatcher.dispatch(1L, 101L, "SIMILAR_ASIN"), "force 执行完不残留 in-flight");
|
||||
verify(executor, times(4)).execute(any(Runnable.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user