Files
crawler-plugin/backend-java/src/test/java/com/nanri/aiimage/config/ExplainIndexAuditDocTest.java
T
huangzd1997 5f4fcad2ef
Build Backend JAR / build (push) Has been cancelled
修复双实例 owner-scoped 结果文件 job 被非归属实例误杀
线上现象:货源查询任务 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 两级菜单分组迁移、相关测试。
2026-09-01 21:07:25 +08:00

132 lines
5.7 KiB
Java

package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 115:高频查询 EXPLAIN 索引审计(文档产出验证)。
* 审计文档必须覆盖 history/progress/dashboard 三类高频查询的执行计划结论、
* 候选索引清单(表/列/类型/收益/风险)、全表扫描标记、可重复执行步骤,
* 且本任务明确不做 DDL(只审计,不加索引)。
*/
class ExplainIndexAuditDocTest {
private static final Path AUDIT = Paths.get("src", "main", "resources", "..", "..", "..",
"docs", "explain-index-audit.md").normalize();
private static final Path FLYWAY_SPEC = Paths.get("src", "main", "resources", "..", "..", "..",
"docs", "specs", "12-flyway-and-inspection.md").normalize();
private static String read(Path path) throws IOException {
return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
}
private static List<String> linesOf(Path path) throws IOException {
return List.of(read(path).split("\r?\n"));
}
private static String lineContaining(String content, String needle) {
for (String line : content.split("\r?\n")) {
if (line.contains(needle)) {
return line;
}
}
return null;
}
@Test
void test_explain_history_plan() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("history"), "审计文档覆盖 history 查询");
assertTrue(doc.contains("EXPLAIN"), "审计文档包含 EXPLAIN 执行计划记录");
}
@Test
void test_explain_progress_plan() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("progress"), "审计文档覆盖 progress 查询");
}
@Test
void test_explain_dashboard_plan() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("dashboard"), "审计文档覆盖 dashboard 查询");
assertTrue(doc.contains("GROUP BY"), "dashboard 聚合查询计划已记录");
}
@Test
void test_full_scan_detected() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("type=ALL") || doc.contains("全表扫描") || doc.contains("ALL")
|| doc.contains("possible_keys") || doc.contains("扫描"),
"审计文档记录执行计划扫描类型(full scan 标记)");
String line = lineContaining(doc, "idx_file_job_task");
assertTrue(line != null && line.contains("task_id"),
"审计文档指出 task_id 前缀索引对 (module_type, job_type, task_id IN) 的局限");
}
@Test
void test_index_candidates_listed() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("idx_file_job_module_type_task")
|| doc.contains("module_type") && doc.contains("job_type") && doc.contains("task_id"),
"候选索引清单含 (module_type, job_type, task_id) 组合");
assertTrue(doc.contains("收益") && doc.contains("风险"),
"候选索引清单含收益与风险说明");
}
@Test
void test_plan_documented() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("biz_task_file_job"), "审计文档引用目标表");
assertTrue(doc.contains("biz_file_task") || doc.contains("biz_file_result"),
"审计文档引用业务任务/结果表");
}
@Test
void test_plan_repeatable() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("EXPLAIN SELECT") || doc.contains("EXPLAIN")
&& doc.contains("手工执行") || doc.contains("执行"),
"审计文档包含可重复执行的 EXPLAIN SQL 步骤");
}
@Test
void test_no_ddl_this_task() throws Exception {
String audit = read(AUDIT);
String spec = read(FLYWAY_SPEC);
assertTrue(audit.contains("不做 DDL") || audit.contains("本任务无 DDL")
|| audit.contains("不新增索引") || audit.contains("只审计"),
"审计文档明确本任务不做 DDL");
// 任务 116 落地 V101 索引后,审计文档会把 ALTER TABLE 作为候选索引的
// 回滚说明引用。审计任务自身依然不执行 DDL——判定口径改为「ALTER TABLE
// 只能出现在候选/回滚等说明性上下文」,而不是文档里不得出现该字符串。
for (String line : audit.split("\r?\n")) {
if (!line.contains("ALTER TABLE")) {
continue;
}
assertTrue(line.contains("回滚") || line.contains("候选") || line.contains("建议")
|| line.contains("风险") || line.trim().startsWith(">"),
"审计文档中的 ALTER TABLE 必须是候选/回滚说明,实际行:" + line);
}
assertTrue(spec.contains("只读") || spec.contains("巡检"),
"12 spec 巡检报表模式与审计文档对应");
}
@Test
void test_audit_reflects_batch_loading_queries() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("findAssembleJobsByTaskIds"), "审计覆盖轻量进度 Job 批量查询");
assertTrue(doc.contains("findAssembleJobsByResultIds"), "审计覆盖结果列表 Job 批量查询");
assertTrue(doc.contains("selectMaps") || doc.contains("GROUP BY"), "审计覆盖 dashboard 聚合查询");
}
}