线上现象:货源查询任务 26402/26400(归属 server-121)在归属机正常推进, 却被 server-110 判为「结果生成失败」,错误为「该任务已绑定到另一台服务实例处理」。 两个缺陷叠加: 1. 两台实例共用一个 RocketMQ consumer group,dispatch 消息被轮询投递; worker 对"PENDING + owner 不匹配"的 job 选择本地抢跑,模块层 ensureTaskOwnedByCurrentInstance 直接抛 TaskOwnerMismatchException, 异常落进通用 catch 被当成生成失败,5 次抢跑即耗尽 retryCount 并终态失败。 2. runPendingJobs 双重 claim:claimRunnableJobsForOwner 已把 job 翻成 RUNNING, 随后又走 claimRunning(条件 PENDING/FAILED)必然失败 → job 被静默丢弃, 归属机的定时兜底从未真正生效。 修复: - worker:非归属实例一律跳过 owner-scoped job;抽出 processClaimed 供已持有 claim 的调用方使用;processInternal 单独捕获 TaskOwnerMismatchException, 只 deferRunning 退回 PENDING,不计入重试。 - TaskFileJobLocalDispatcher 新增 dispatchClaimed。 - dispatch 事件带上 owner,coordinator 按归属路由:本机 owner 直接本地派发不发 MQ, 他机 owner 不发不跑交由归属机轮询接手,owner 为空维持原有 MQ 行为。 - 新增 owner 路由回归测试两组,回滚配合"本地抢跑"的旧断言。 同时提交此前工作区内已随 JAR 上线的 backend-java 改动:SimilarAsin 解析载荷 groups 内嵌 items(Python 旧链路兼容)与 chunk 类型不匹配跳过、 PermissionMenuSchemaInitializer 与 V103 两级菜单分组迁移、相关测试。
This commit is contained in:
+62
-25
@@ -47,20 +47,34 @@ public class PermissionMenuSchemaInitializer {
|
||||
new DefaultAppChildMenu("ERP", "erp", "erp", 132, "brand_logistics_tools")
|
||||
);
|
||||
|
||||
/**
|
||||
* 后台一级分组:只做权限树层级与「上级菜单」候选,不映射真实页面。
|
||||
* route_path 使用 group- 前缀虚拟值以满足 (menu_type, route_path) 唯一约束;
|
||||
* 前端按 column_key 前缀 admin_group_ 识别分组行,不会出现在页面选择器中。
|
||||
*/
|
||||
private static final List<DefaultAdminGroup> DEFAULT_ADMIN_GROUPS = List.of(
|
||||
new DefaultAdminGroup("账号与权限", "admin_group_account", "group-account", 5),
|
||||
new DefaultAdminGroup("数据管理", "admin_group_data", "group-data", 15),
|
||||
new DefaultAdminGroup("店铺管理", "admin_group_shop", "group-shop", 45),
|
||||
new DefaultAdminGroup("记录与版本", "admin_group_record", "group-record", 55)
|
||||
);
|
||||
|
||||
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
||||
new DefaultAdminMenu("菜单权限配置", "admin_columns", "columns", 20),
|
||||
new DefaultAdminMenu("分组管理", "admin_group_manage", "group-manage", 25),
|
||||
new DefaultAdminMenu("去重数据汇总", "admin_dedupe_total_data", "dedupe-total-data", 30),
|
||||
new DefaultAdminMenu("无效ASIN数据", "admin_invalid_asin_data", "invalid-asin-data", 35),
|
||||
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40),
|
||||
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
|
||||
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
|
||||
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65),
|
||||
new DefaultAdminMenu("商品类目", "admin_product_categories", "product-categories", 66),
|
||||
new DefaultAdminMenu("生成记录", "admin_history", "history", 70),
|
||||
new DefaultAdminMenu("软件版本管理", "admin_version", "version", 80),
|
||||
new DefaultAdminMenu("数字人版本管理", "digital_human_version", "digital-human-version", 81)
|
||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10, "admin_group_account"),
|
||||
new DefaultAdminMenu("菜单权限配置", "admin_columns", "columns", 20, "admin_group_account"),
|
||||
new DefaultAdminMenu("分组管理", "admin_group_manage", "group-manage", 25, "admin_group_account"),
|
||||
new DefaultAdminMenu("去重数据汇总", "admin_dedupe_total_data", "dedupe-total-data", 30, "admin_group_data"),
|
||||
new DefaultAdminMenu("品牌数据库", "admin_invalid_asin_data", "invalid-asin-data", 35, "admin_group_data"),
|
||||
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65, "admin_group_data"),
|
||||
new DefaultAdminMenu("商品类目", "admin_product_categories", "product-categories", 66, "admin_group_data"),
|
||||
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40, "admin_group_shop"),
|
||||
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50, "admin_group_shop"),
|
||||
new DefaultAdminMenu("最低价ASIN设置", "admin_skip_price_asin", "skip-price-asin", 60, "admin_group_shop"),
|
||||
new DefaultAdminMenu("店铺数据记录", "admin_shop_data_crawl_tasks", "shop-data-crawl-tasks", 82, "admin_group_shop"),
|
||||
new DefaultAdminMenu("生成记录", "admin_history", "history", 70, "admin_group_record"),
|
||||
new DefaultAdminMenu("视频任务记录", "admin_image_video_tasks", "image-video-tasks", 75, "admin_group_record"),
|
||||
new DefaultAdminMenu("软件版本管理", "admin_version", "version", 80, "admin_group_record"),
|
||||
new DefaultAdminMenu("数字人版本管理", "digital_human_version", "digital-human-version", 81, "admin_group_record")
|
||||
);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -86,6 +100,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
executeQuietly("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''");
|
||||
executeQuietly("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0");
|
||||
executeQuietly("ALTER TABLE columns ADD UNIQUE KEY uk_menu_type_route_path (menu_type, route_path)");
|
||||
ensureDefaultAdminGroups();
|
||||
ensureDefaultAdminMenus();
|
||||
boolean shopDataCrawlDataPermissionCreated = ensureShopDataCrawlAdminDefaults();
|
||||
ensureDefaultAppMenus();
|
||||
@@ -105,20 +120,37 @@ public class PermissionMenuSchemaInitializer {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureDefaultAdminMenus() {
|
||||
for (DefaultAdminMenu menu : DEFAULT_ADMIN_MENUS) {
|
||||
private void ensureDefaultAdminGroups() {
|
||||
for (DefaultAdminGroup group : DEFAULT_ADMIN_GROUPS) {
|
||||
executeQuietly("""
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
|
||||
SELECT '%s', '%s', 'admin', '%s', %d
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, parent_id, sort_order)
|
||||
SELECT '%s', '%s', 'admin', '%s', NULL, %d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = '%s'
|
||||
)
|
||||
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(), menu.columnKey()));
|
||||
""".formatted(group.name(), group.columnKey(), group.routePath(), group.sortOrder(), group.columnKey()));
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureDefaultAdminMenus() {
|
||||
for (DefaultAdminMenu menu : DEFAULT_ADMIN_MENUS) {
|
||||
executeQuietly("""
|
||||
UPDATE columns
|
||||
SET sort_order = %d, parent_id = NULL
|
||||
WHERE column_key = '%s' AND (sort_order IS NULL OR sort_order = 0)
|
||||
""".formatted(menu.sortOrder(), menu.columnKey()));
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '%s', '%s', 'admin', '%s', %d, parent.id
|
||||
FROM columns parent
|
||||
WHERE parent.column_key = '%s'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = '%s'
|
||||
)
|
||||
""".formatted(menu.name(), menu.columnKey(), menu.routePath(), menu.sortOrder(),
|
||||
menu.parentKey(), menu.columnKey()));
|
||||
executeQuietly("""
|
||||
UPDATE columns child
|
||||
JOIN columns parent ON parent.column_key = '%s'
|
||||
SET child.name = '%s', child.sort_order = %d,
|
||||
child.parent_id = COALESCE(child.parent_id, parent.id)
|
||||
WHERE child.column_key = '%s'
|
||||
""".formatted(menu.parentKey(), menu.name(), menu.sortOrder(), menu.columnKey()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +211,9 @@ public class PermissionMenuSchemaInitializer {
|
||||
|
||||
private boolean ensureShopDataCrawlAdminDefaults() {
|
||||
executeQuietly("""
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
|
||||
SELECT '店铺数据记录', 'admin_shop_data_crawl_tasks', 'admin', 'shop-data-crawl-tasks', 82
|
||||
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id)
|
||||
SELECT '店铺数据记录', 'admin_shop_data_crawl_tasks', 'admin', 'shop-data-crawl-tasks', 82,
|
||||
(SELECT id FROM columns WHERE column_key = 'admin_group_shop' LIMIT 1)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM columns WHERE column_key = 'admin_shop_data_crawl_tasks'
|
||||
)
|
||||
@@ -226,7 +259,11 @@ public class PermissionMenuSchemaInitializer {
|
||||
}
|
||||
}
|
||||
|
||||
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder) {
|
||||
private record DefaultAdminMenu(String name, String columnKey, String routePath, int sortOrder,
|
||||
String parentKey) {
|
||||
}
|
||||
|
||||
private record DefaultAdminGroup(String name, String columnKey, String routePath, int sortOrder) {
|
||||
}
|
||||
|
||||
private record DefaultAppMenu(String name, String columnKey, String routePath, int sortOrder) {
|
||||
|
||||
+60
-1
@@ -5127,10 +5127,47 @@ public class SimilarAsinTaskService {
|
||||
payload.setSourceFiles(sourceFiles == null ? List.of() : sourceFiles);
|
||||
payload.setHeaders(headers == null ? List.of() : headers);
|
||||
payload.setItems(allRows == null ? List.of() : new ArrayList<>(allRows));
|
||||
payload.setGroups(groups == null ? List.of() : groups);
|
||||
// 兼容 Python 旧链路:groups 需内嵌完整行(Python 端 normalize_groups
|
||||
// 直接返回 groups 并按 gp["items"] 取行;仅有 startIndex/endIndex 引用时
|
||||
// items 为空、任务空转)。startIndex/endIndex 保留,Java 内部 expandGroupRefs 继续使用。
|
||||
payload.setGroups(fillGroupItems(groups, allRows));
|
||||
return writeJson(payload, "保存解析结果失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 group.startIndex/endIndex 半开区间从全量行切片,内嵌回 group.items;
|
||||
* 无索引或切片越界时退回按 group.items 原样(兼容旧 payload 反序列化场景)。
|
||||
*/
|
||||
static List<SimilarAsinParsedGroupVo> fillGroupItems(List<SimilarAsinParsedGroupVo> groups, List<SimilarAsinParsedRowVo> allRows) {
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
return groups == null ? List.of() : groups;
|
||||
}
|
||||
if (allRows == null || allRows.isEmpty()) {
|
||||
return groups;
|
||||
}
|
||||
List<SimilarAsinParsedRowVo> rows = new ArrayList<>(allRows);
|
||||
for (SimilarAsinParsedGroupVo group : groups) {
|
||||
if (group == null) {
|
||||
continue;
|
||||
}
|
||||
Integer start = group.getStartIndex();
|
||||
Integer end = group.getEndIndex();
|
||||
if (start != null && end != null) {
|
||||
int from = Math.max(0, Math.min(start, rows.size()));
|
||||
int to = Math.max(from, Math.min(end, rows.size()));
|
||||
if (to > from) {
|
||||
group.setItems(new ArrayList<>(rows.subList(from, to)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 索引缺失/无效:保留已有 items(兼容旧 payload),不覆盖
|
||||
if (group.getItems() == null) {
|
||||
group.setItems(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
private String buildTaskResultJson(String aiPrompt, String apiKey, Boolean imgSwitch, Boolean categorySwitch, List<SimilarAsinSourceFileDto> sourceFiles, String parsedPayloadPointer) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("aiPrompt", normalize(aiPrompt));
|
||||
@@ -5172,6 +5209,11 @@ public class SimilarAsinTaskService {
|
||||
}
|
||||
|
||||
private SimilarAsinParsedPayloadDto hydrateParsedPayloadRows(SimilarAsinParsedPayloadDto payload) {
|
||||
return hydrateForPythonCompat(payload);
|
||||
}
|
||||
|
||||
/** 解析载荷 hydrate:回填 allItems/items/groups 内嵌 items(Python 旧链路兼容)。 */
|
||||
static SimilarAsinParsedPayloadDto hydrateForPythonCompat(SimilarAsinParsedPayloadDto payload) {
|
||||
if (payload == null) {
|
||||
return new SimilarAsinParsedPayloadDto();
|
||||
}
|
||||
@@ -5182,6 +5224,13 @@ public class SimilarAsinTaskService {
|
||||
if (payload.getItems() == null || payload.getItems().isEmpty()) {
|
||||
payload.setItems(rows);
|
||||
}
|
||||
// Python 旧链路兼容:读取时也回填 groups 内嵌 items(存量任务 payload
|
||||
// 只有引用),否则 Python normalize_groups 拿到空 items 任务空转。
|
||||
if (payload.getGroups() != null && !payload.getGroups().isEmpty()) {
|
||||
List<SimilarAsinParsedRowVo> filledRows = payload.getItems() == null || payload.getItems().isEmpty()
|
||||
? rows : payload.getItems();
|
||||
payload.setGroups(fillGroupItems(payload.getGroups(), filledRows));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -5205,6 +5254,16 @@ public class SimilarAsinTaskService {
|
||||
// P1-4:识别跨实例 local 指针读不到的场景,把"chunk 在另一实例"的元信息
|
||||
// 通过 task_scope_state.last_error 留痕,便于排查"为何 owner 切换后 chunk 读不到"。
|
||||
boolean crossInstance = msg.contains("only exists on instance=");
|
||||
// 类型不兼容(如历史版本序列化数据含当前类加载器不认识的类型标记):
|
||||
// 该 chunk 永久不可读,跳过而不是把组装/收尾永久拖死,留痕便于排查。
|
||||
boolean typeMismatch = ex instanceof TypeNotPresentException
|
||||
|| msg.contains("not present");
|
||||
if (typeMismatch) {
|
||||
log.warn("[similar-asin] chunk payload type mismatch, skip chunk taskId={} chunk={} err={}",
|
||||
chunk.getTaskId(), chunk.getChunkIndex(), msg);
|
||||
recordChunkReadFailure(chunk, false, msg);
|
||||
return rows;
|
||||
}
|
||||
log.warn("[similar-asin] read chunk payload failed taskId={} chunk={} crossInstance={} err={}",
|
||||
chunk.getTaskId(), chunk.getChunkIndex(), crossInstance, msg);
|
||||
recordChunkReadFailure(chunk, crossInstance, msg);
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ public record TaskFileJobDispatchEvent(
|
||||
Long resultId,
|
||||
String scopeKey,
|
||||
String jobType,
|
||||
String owner,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
+29
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
|
||||
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -17,6 +18,7 @@ public class TaskFileJobDispatchCoordinator {
|
||||
|
||||
private final TaskFileJobPublisher taskFileJobPublisher;
|
||||
private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher;
|
||||
private final InstanceMetadata instanceMetadata;
|
||||
@Qualifier("taskFileJobDispatchExecutor")
|
||||
private final TaskExecutor taskFileJobDispatchExecutor;
|
||||
|
||||
@@ -43,6 +45,28 @@ public class TaskFileJobDispatchCoordinator {
|
||||
}
|
||||
|
||||
private void dispatchAfterCommit(TaskFileJobDispatchEvent event, TaskFileJobMessage message) {
|
||||
// owner 非空 ⇔ owner-scoped job(只有 SIMILAR_ASIN/APPEARANCE_PATENT/PUBLISH/
|
||||
// SHOP_DATA_CRAWL 会写 "task:{id}:owner:{instance}" 形式的 scopeKey)。
|
||||
// 这类 job 必须在归属实例上执行,走共享 consumer group 的 MQ 会被轮询投递到
|
||||
// 另一台,那边只能跳过,白白多绕一圈。
|
||||
String owner = event.owner() == null ? "" : event.owner().trim();
|
||||
if (!owner.isBlank()) {
|
||||
if (owner.equals(currentInstanceId())) {
|
||||
boolean dispatched = taskFileJobLocalDispatcher.dispatch(
|
||||
event.jobId(), event.taskId(), event.moduleType(), true);
|
||||
if (dispatched) {
|
||||
return;
|
||||
}
|
||||
log.warn("[task-file-job] owner-scoped local dispatch declined, fallback to mq jobId={} taskId={} moduleType={} owner={}",
|
||||
event.jobId(), event.taskId(), event.moduleType(), owner);
|
||||
} else {
|
||||
// 归属实例每 15 秒扫一次自己 owner 的 PENDING/FAILED job,这里不发 MQ、
|
||||
// 不本地跑,避免消息落到非归属实例后被丢弃。
|
||||
log.info("[task-file-job] skip dispatch because job belongs to another instance jobId={} taskId={} moduleType={} owner={} current={}",
|
||||
event.jobId(), event.taskId(), event.moduleType(), owner, currentInstanceId());
|
||||
return;
|
||||
}
|
||||
}
|
||||
boolean published = taskFileJobPublisher.publish(message);
|
||||
if (published) {
|
||||
return;
|
||||
@@ -56,4 +80,9 @@ public class TaskFileJobDispatchCoordinator {
|
||||
event.jobId(), event.taskId(), event.moduleType());
|
||||
}
|
||||
}
|
||||
|
||||
private String currentInstanceId() {
|
||||
String instanceId = instanceMetadata == null ? null : instanceMetadata.getInstanceId();
|
||||
return instanceId == null || instanceId.isBlank() ? "unknown-instance" : instanceId;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -42,7 +42,19 @@ public class TaskFileJobLocalDispatcher {
|
||||
return dispatch(jobId, taskId, moduleType, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用方已把 job claim 成 RUNNING 时使用:复用同样的背压/去重/异步执行,
|
||||
* 但落到 worker.processClaimed,避免二次 claimRunning 失败导致 job 被静默丢弃。
|
||||
*/
|
||||
public boolean dispatchClaimed(Long jobId, Long taskId, String moduleType) {
|
||||
return submit(jobId, taskId, moduleType, false, true);
|
||||
}
|
||||
|
||||
public boolean dispatch(Long jobId, Long taskId, String moduleType, boolean force) {
|
||||
return submit(jobId, taskId, moduleType, force, false);
|
||||
}
|
||||
|
||||
private boolean submit(Long jobId, Long taskId, String moduleType, boolean force, boolean alreadyClaimed) {
|
||||
if (jobId == null || jobId <= 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -63,7 +75,7 @@ public class TaskFileJobLocalDispatcher {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
taskFileJobDispatchExecutor.execute(() -> processLocally(jobId, taskId, moduleType));
|
||||
taskFileJobDispatchExecutor.execute(() -> processLocally(jobId, taskId, moduleType, alreadyClaimed));
|
||||
return true;
|
||||
} catch (RuntimeException ex) {
|
||||
inflightJobIds.remove(jobId);
|
||||
@@ -71,7 +83,7 @@ public class TaskFileJobLocalDispatcher {
|
||||
jobId, taskId, moduleType, ex.getMessage(), ex);
|
||||
if (force) {
|
||||
try {
|
||||
processLocally(jobId, taskId, moduleType);
|
||||
processLocally(jobId, taskId, moduleType, alreadyClaimed);
|
||||
return true;
|
||||
} finally {
|
||||
inflightJobIds.remove(jobId);
|
||||
@@ -81,7 +93,7 @@ public class TaskFileJobLocalDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private void processLocally(Long jobId, Long taskId, String moduleType) {
|
||||
private void processLocally(Long jobId, Long taskId, String moduleType, boolean alreadyClaimed) {
|
||||
try {
|
||||
TaskFileJobEntity job = taskFileJobMapper.selectById(jobId);
|
||||
if (job == null) {
|
||||
@@ -95,7 +107,11 @@ public class TaskFileJobLocalDispatcher {
|
||||
jobId, taskId, moduleType);
|
||||
return;
|
||||
}
|
||||
taskResultFileJobWorker.process(job);
|
||||
if (alreadyClaimed) {
|
||||
taskResultFileJobWorker.processClaimed(job);
|
||||
} else {
|
||||
taskResultFileJobWorker.process(job);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[task-file-job] local dispatch failed jobId={} taskId={} moduleType={} msg={}",
|
||||
jobId, taskId, moduleType, ex.getMessage(), ex);
|
||||
|
||||
+34
-1
@@ -57,7 +57,14 @@ public class TaskFileJobService {
|
||||
if (existing == null) {
|
||||
return null;
|
||||
}
|
||||
if ("SUCCESS".equals(existing.getStatus()) || "RUNNING".equals(existing.getStatus()) || "PENDING".equals(existing.getStatus())) {
|
||||
if ("SUCCESS".equals(existing.getStatus()) || "RUNNING".equals(existing.getStatus())) {
|
||||
return existing;
|
||||
}
|
||||
if ("PENDING".equals(existing.getStatus())) {
|
||||
// PENDING 僵尸兜底:job 入队时若 MQ 发送失败且本地兜底未生效(或消息被
|
||||
// 非 owner 实例静默吞掉),job 会永远停留在 PENDING、无人处理。
|
||||
// 补发一次 dispatch 事件,让调度链重试(幂等:consumer 侧 claim 有状态翻转保护)。
|
||||
publishDispatchEvent(existing);
|
||||
return existing;
|
||||
}
|
||||
// 终态失败:retryCount 已达上限的 FAILED job 不再重置为 PENDING,
|
||||
@@ -237,6 +244,13 @@ public class TaskFileJobService {
|
||||
.lt(TaskFileJobEntity::getUpdatedAt, threshold)
|
||||
.or()
|
||||
.isNull(TaskFileJobEntity::getUpdatedAt)))
|
||||
.or(stuckPending -> stuckPending
|
||||
.eq(TaskFileJobEntity::getStatus, "PENDING")
|
||||
.ne(TaskFileJobEntity::getRetryCount, MAX_RETRY_COUNT)
|
||||
.and(age -> age
|
||||
.lt(TaskFileJobEntity::getUpdatedAt, threshold)
|
||||
.or()
|
||||
.isNull(TaskFileJobEntity::getUpdatedAt)))
|
||||
.or(exhausted -> exhausted
|
||||
.eq(TaskFileJobEntity::getStatus, "FAILED")
|
||||
.ge(TaskFileJobEntity::getRetryCount, MAX_RETRY_COUNT)
|
||||
@@ -251,6 +265,24 @@ public class TaskFileJobService {
|
||||
exhaustedJobs.add(job);
|
||||
continue;
|
||||
}
|
||||
if ("PENDING".equals(job.getStatus())) {
|
||||
// PENDING 僵尸兜底:补发 dispatch 事件(worker claim 幂等),
|
||||
// 并累加 retryCount 防无限重发;注意不翻转状态,
|
||||
// 避免与真实 PENDING 竞争 worker 的 claim 条件更新冲突。
|
||||
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount();
|
||||
int nextRetryCount = Math.min(MAX_RETRY_COUNT - 1, retryCount + 1);
|
||||
int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper<TaskFileJobEntity>()
|
||||
.eq(TaskFileJobEntity::getId, job.getId())
|
||||
.eq(TaskFileJobEntity::getStatus, "PENDING")
|
||||
.eq(job.getUpdatedAt() != null, TaskFileJobEntity::getUpdatedAt, job.getUpdatedAt())
|
||||
.isNull(job.getUpdatedAt() == null, TaskFileJobEntity::getUpdatedAt)
|
||||
.set(TaskFileJobEntity::getRetryCount, nextRetryCount)
|
||||
.set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
if (updated > 0) {
|
||||
publishDispatchEvent(taskFileJobMapper.selectById(job.getId()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount();
|
||||
int nextRetryCount = Math.min(MAX_RETRY_COUNT, retryCount + 1);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -642,6 +674,7 @@ public class TaskFileJobService {
|
||||
entity.getResultId(),
|
||||
entity.getScopeKey(),
|
||||
entity.getJobType(),
|
||||
entity.getOwner(),
|
||||
LocalDateTime.now()
|
||||
));
|
||||
}
|
||||
|
||||
+37
-6
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
@@ -76,13 +77,16 @@ public class TaskResultFileJobWorker {
|
||||
}
|
||||
log.info("[task-file-job] scheduled worker claimed jobs count={}", jobs.size());
|
||||
for (TaskFileJobEntity job : jobs) {
|
||||
boolean dispatched = taskFileJobLocalDispatcher.dispatch(
|
||||
// claimRunnableJobsForOwner 已把 job 原子翻转成 RUNNING,这里必须走"已 claim"
|
||||
// 通道:若再走 process() 的 claimRunning,条件 UPDATE 会因状态已是 RUNNING 而
|
||||
// 失败,job 被静默丢弃并挂到 stuck 扫描才回收,定时兜底等于失效。
|
||||
boolean dispatched = taskFileJobLocalDispatcher.dispatchClaimed(
|
||||
job.getId(),
|
||||
job.getTaskId(),
|
||||
job.getModuleType()
|
||||
);
|
||||
if (!dispatched) {
|
||||
process(job);
|
||||
processClaimed(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,18 +108,39 @@ public class TaskResultFileJobWorker {
|
||||
}
|
||||
|
||||
public void process(TaskFileJobEntity job) {
|
||||
if (isOwnerScopedJob(job) && !isOwnedByCurrentInstance(job)) {
|
||||
log.debug("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} owner={} current={}",
|
||||
job.getId(), job.getTaskId(), job.getModuleType(), ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
|
||||
if (job == null || job.getId() == null) {
|
||||
return;
|
||||
}
|
||||
if (job == null || job.getId() == null) {
|
||||
if (isOwnerScopedJob(job) && !isOwnedByCurrentInstance(job)) {
|
||||
// 非归属实例一律不碰 owner-scoped job:模块层 ensureTaskOwnedByCurrentInstance
|
||||
// 会直接抛 TaskOwnerMismatchException,抢跑只会白白消耗 job 的重试次数,
|
||||
// 重试耗尽后把归属机上跑得好好的任务判成失败。
|
||||
// job 不会因此卡死:归属实例的 runPendingJobs 每 15 秒扫一次自己 owner 的
|
||||
// PENDING/FAILED job,enqueueAssembleResult / stuck 扫描也会补发 dispatch。
|
||||
log.info("[task-file-job] skip owner-scoped job because owner is another instance jobId={} taskId={} moduleType={} status={} owner={} current={}",
|
||||
job.getId(), job.getTaskId(), job.getModuleType(), job.getStatus(),
|
||||
ownerFromScopeKey(job.getScopeKey()), currentInstanceId());
|
||||
return;
|
||||
}
|
||||
TaskFileJobEntity claim = taskFileJobService.claimRunning(job.getId());
|
||||
if (claim == null) {
|
||||
return;
|
||||
}
|
||||
offloadOrRun(job, claim);
|
||||
}
|
||||
|
||||
/**
|
||||
* 供"调用方已持有 RUNNING claim"的路径使用(定时 worker 的 claimRunnableJobsForOwner):
|
||||
* 跳过 claimRunning,直接按 handler 能力异步/内联执行。
|
||||
*/
|
||||
public void processClaimed(TaskFileJobEntity claim) {
|
||||
if (claim == null || claim.getId() == null) {
|
||||
return;
|
||||
}
|
||||
offloadOrRun(claim, claim);
|
||||
}
|
||||
|
||||
private void offloadOrRun(TaskFileJobEntity job, TaskFileJobEntity claim) {
|
||||
ResultFileJobHandler handler = handlerRegistry.asMap().get(job.getModuleType());
|
||||
if (handler != null && handler.supportsAsyncOffload()) {
|
||||
try {
|
||||
@@ -206,6 +231,12 @@ public class TaskResultFileJobWorker {
|
||||
if (finalizeWithdraw) {
|
||||
withdrawTaskService.tryFinalizeTask(job.getTaskId(), false);
|
||||
}
|
||||
} catch (TaskOwnerMismatchException ex) {
|
||||
// 归属校验失败不是"生成失败":不能计入 retryCount,否则任务会在归属机
|
||||
// 一切正常的情况下被本机重试耗尽判死。退回 PENDING 交还归属实例。
|
||||
log.warn("[task-file-job] process rejected because task belongs to another instance jobId={} taskId={} moduleType={} owner={} current={}",
|
||||
job.getId(), job.getTaskId(), job.getModuleType(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId());
|
||||
taskFileJobService.deferRunning(job.getId(), ex.getMessage());
|
||||
} catch (Exception ex) {
|
||||
TaskFileJobEntity latest = taskFileJobService.findById(job.getId());
|
||||
if (latest != null && "SUCCESS".equals(latest.getStatus())) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- V103: 后台管理菜单改为两级结构(一级分组 + 子页面)
|
||||
-- 新增 4 个后台一级分组(不映射真实页面,route_path 为分组虚拟值),
|
||||
-- 并把这 16 个后台页面菜单挂到对应分组下。
|
||||
-- 分组只用于权限树层级与「上级菜单」候选;column_key / route_path 校验逻辑不变。
|
||||
-- 对应 Java 端清单见 PermissionMenuSchemaInitializer.DEFAULT_ADMIN_GROUPS / DEFAULT_ADMIN_MENUS。
|
||||
|
||||
-- ---------- 1. 新增一级分组 ----------
|
||||
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `parent_id`, `sort_order`)
|
||||
SELECT '账号与权限', 'admin_group_account', 'admin', 'group-account', NULL, 5
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `columns` WHERE `column_key` = 'admin_group_account');
|
||||
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `parent_id`, `sort_order`)
|
||||
SELECT '数据管理', 'admin_group_data', 'admin', 'group-data', NULL, 15
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `columns` WHERE `column_key` = 'admin_group_data');
|
||||
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `parent_id`, `sort_order`)
|
||||
SELECT '店铺管理', 'admin_group_shop', 'admin', 'group-shop', NULL, 45
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `columns` WHERE `column_key` = 'admin_group_shop');
|
||||
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `parent_id`, `sort_order`)
|
||||
SELECT '记录与版本', 'admin_group_record', 'admin', 'group-record', NULL, 55
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `columns` WHERE `column_key` = 'admin_group_record');
|
||||
|
||||
-- ---------- 2. 页面菜单挂到分组下,并同步显示名 ----------
|
||||
-- 只更新当前没有父级的页面(保留管理员手工调整过的层级),
|
||||
-- COALESCE 保证已挂接的叶子不重复指向。
|
||||
|
||||
UPDATE `columns` child
|
||||
JOIN `columns` parent ON parent.`column_key` = 'admin_group_account'
|
||||
SET child.`parent_id` = COALESCE(child.`parent_id`, parent.`id`)
|
||||
WHERE child.`column_key` IN ('admin_users', 'admin_columns', 'admin_group_manage');
|
||||
|
||||
UPDATE `columns` child
|
||||
JOIN `columns` parent ON parent.`column_key` = 'admin_group_data'
|
||||
SET child.`parent_id` = COALESCE(child.`parent_id`, parent.`id`)
|
||||
WHERE child.`column_key` IN ('admin_dedupe_total_data', 'admin_invalid_asin_data',
|
||||
'admin_query_asin', 'admin_product_categories');
|
||||
|
||||
UPDATE `columns` child
|
||||
JOIN `columns` parent ON parent.`column_key` = 'admin_group_shop'
|
||||
SET child.`parent_id` = COALESCE(child.`parent_id`, parent.`id`)
|
||||
WHERE child.`column_key` IN ('admin_shop_keys', 'admin_shop_manage', 'admin_skip_price_asin',
|
||||
'admin_shop_data_crawl_tasks');
|
||||
|
||||
UPDATE `columns` child
|
||||
JOIN `columns` parent ON parent.`column_key` = 'admin_group_record'
|
||||
SET child.`parent_id` = COALESCE(child.`parent_id`, parent.`id`)
|
||||
WHERE child.`column_key` IN ('admin_history', 'admin_version', 'digital_human_version',
|
||||
'admin_image_video_tasks');
|
||||
|
||||
-- ---------- 3. 显示名对齐前端界面 ---
|
||||
UPDATE `columns` SET `name` = '品牌数据库'
|
||||
WHERE `menu_type` = 'admin' AND `column_key` = 'admin_invalid_asin_data';
|
||||
|
||||
UPDATE `columns` SET `name` = '最低价ASIN设置'
|
||||
WHERE `menu_type` = 'admin' AND `column_key` = 'admin_skip_price_asin';
|
||||
|
||||
UPDATE `columns` SET `name` = '视频任务记录'
|
||||
WHERE `menu_type` = 'admin' AND `column_key` = 'admin_image_video_tasks';
|
||||
|
||||
UPDATE `columns` SET `name` = '店铺数据记录'
|
||||
WHERE `menu_type` = 'admin' AND `column_key` = 'admin_shop_data_crawl_tasks';
|
||||
Reference in New Issue
Block a user