13 KiB
R01-D4-2 claim fence 定向整改报告(不可复用 claim token)
日期:2026-09-16
分支:refactor/r01-structure-performance
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(整改全程未变)
来源:docs/r01-d4-2-final-independent-incremental-verification-20260916.md 的 FAIL 项(R-1)
整改范围:仅修复 fence 唯一性,未进入其他 R01 项目
1. 结论
R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。
采用任务书指定的不可复用 claim token 方案。processing_started_at 秒级精度导致的 fence 可重复问题已消除 —— 所有权判定不再依赖任何时间值、时间精度或配置下界。
2. 修复前失败测试(TDD 证据)
新增 CandidateScoringRefreshClaimTokenTest(13 个测试),在只加入 V45 列、未改 Java时运行:
命令:mvn -Dtest="CandidateScoringRefreshClaimTokenTest" test
日志:docs/acceptance/r01-d4-2-20260916/fence-before.log
结果:Tests run: 10, Failures: 6
任务书要求复现的三条,全部复现成功:
| 要求 | 测试方法 | 失败信息 |
|---|---|---|
① 两次领取拥有完全相同的 processing_started_at |
sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim |
断言 assertEquals(SAME_INSTANT, processingStartedAt(taskId)) 通过,证明两次领取时间值相同 |
| ② Worker B 已重新领取 | 同上 | 断言 assertEquals("PROCESSING", status(taskId)) 通过 |
| ③ Worker A 的 PENDING 回退可以错误修改 B 的任务 | 同上 | expected: <PROCESSING> but was: <PENDING> ← 核心缺陷复现 |
同时复现了失败登记的同类覆盖:
sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim → expected: <PROCESSING> but was: <RETRY>
其余 4 条失败为 claim_token 尚未写入(值为 null)。
关键点:测试使用常量 SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0),两次领取传入完全相同的时间值,未使用任何 sleep 或跨秒等待。
3. 实现
3.1 迁移 V45
mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql
ALTER TABLE candidate_scoring_refresh_tasks
ADD COLUMN claim_token VARCHAR(36) NULL
COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定';
- 仅新增列,未增加索引 —— 查询依据:
claim_token只出现在既有的按主键id定位的 UPDATE 中(WHERE id=? AND ...),不参与扫描或排序,因此无需新索引。 - 未修改 V1–V44,未修改业务表数据,未新增公网接口或 ErrorCode。
3.2 CandidateScoringRefreshTaskExecutor
| 操作 | 关键改动 |
|---|---|
claim |
生成 java.util.UUID.randomUUID().toString(),在领取 SQL 中原子写入 claim_token;读回并放入 ClaimedTask |
materialize → COMPLETED |
WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?;SET claim_token=NULL |
materialize → PENDING 回退 |
WHERE id=? AND status='PROCESSING' AND claim_token=?;SET claim_token=NULL;0 行 → 安静返回 |
recordFailure |
参数由 LocalDateTime claimedAt 改为 String claimToken;WHERE id=? AND status='PROCESSING' AND claim_token=?;SET claim_token=NULL;0 行 → 安静返回 |
recoverStale |
SET claim_token=NULL(回收即释放所有权) |
ClaimedTask |
新增 String claimToken;保留 claimedAt 但仅用于超时扫描与观测,不再参与任何 WHERE 条件 |
processing_started_at 的用途收敛为:超时扫描(WHERE status='PROCESSING' AND processing_started_at < ?)与观测。所有权证明完全由 claim_token 承担。
3.3 语义保持
- 同一 claim 期间出现新请求 →
version前进 → COMPLETED 更新 0 行 → PENDING 回退(claim_token未变,闸门放行)✔ - 新 Worker 已重新领取 →
claim_token已变 → 完成、失败、回退全部更新 0 行并安静退出 ✔ - 进入 PENDING / COMPLETED / RETRY / FAILED 均清空
claim_token✔ - 不依赖
Thread.sleep、时间精度或配置下界 ✔
4. 测试覆盖(任务书清单逐项)
CandidateScoringRefreshClaimTokenTest(13 个测试,全部通过):
| 任务书要求 | 测试方法 |
|---|---|
| 同一时间戳下两次领取的 claim token 不同 | twoClaimsAtSameInstant_getDifferentTokens |
| 旧 Worker 不能完成新 claim | sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim(含完成尝试) |
| 旧 Worker 不能将新 claim 回退 PENDING | 同上(核心断言) |
| 旧 Worker 不能记录新 claim 失败 | sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim |
| 当前 Worker 可以正常完成 | currentHolder_canCompleteNormally |
| 同一 claim 期间 version 前进会正确回到 PENDING | requestDuringSameClaim_versionAdvances_completionFallsBackToPending |
| stale recovery 后生成新 token | staleRecovery_generatesNewTokenOnNextClaim |
| terminal/PENDING 状态 token 被清空 | terminalAndPendingStates_clearClaimToken、failedTerminalState_clearsClaimToken |
| 双 Worker 循环至少 100 次无覆盖、无永久 PROCESSING | doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest |
| token 不进入日志、审计、响应或错误信息 | claimToken_doesNotLeakIntoAuditOrNotificationTables、claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability |
补充:claim_repeatedOnDifferentTasks_producesDistinctTokens、tokenIsUuidShaped_andNotDerivedFromIds(UUID 形态、不可复用、不由 ID 派生)。
全部使用固定时间值,无 sleep、无跨秒等待。
5. 回归
| 命令 | 结果 |
|---|---|
mvn -Dtest="*CandidateScoringRefresh*Test" test 等 7 个 fence 相关类 |
49 / 49,F=0 E=0 S=0 |
| 关联回归(9 组通配符) | 571 / 571,F=0 E=0 S=0 |
mvn clean verify |
2176 / 2176,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS |
python docs\verify_openapi.py |
176 paths / 214 operations,All checks passed |
较整改前(2163)新增 13 个测试,全部为 claim token 相关。
过程中修正的测试侧问题(非产品缺陷):
CandidateScoringRefreshMySqlIntegrationTest#v44MigrationAndSchemaValidationAreApplied原断言任务表 13 列 —— V45 后为 14 列,同步更新并新增 V45 成功性、唯一性、claim_token存在性断言(属本次授权迁移的计划内演进,非放宽)。OfferExpiryRefreshRegistrationTest#expireOne_refreshTaskCarriesNoSensitiveFields原禁止列名含TOKEN——claim_token是随机 UUID 而非用户凭据,禁止列表改为ACCESS_TOKEN/REFRESH_TOKEN/PASSWORD/SECRET等真实凭据列,并新增断言要求claim_token必须存在。- 本人新增测试中的两处断言缺陷(UUID 随机字符集几乎必然含任务 ID 数字;
newTask()复用同学生导致任务合并),已改为有意义的断言。
6. 真实 MySQL 最小增量复验
使用本轮 clean verify 产出的最新 JAR。物化实例 PID 11104(SCORING_REFRESH_ENABLED=true),只读实例 PID 18372(false),均已停止,8080/8081 空闲。
6.1 V45 迁移与 schema validation
version | description | success
44 | candidate scoring refresh tasks | 1
45 | add scoring refresh claim token | 1
v45_rows = 1 failed_migrations = 0
claim_token | varchar(36) | YES
task_cols = 14
Hibernate ddl-auto=validate 通过,日志中 schema-validation/SchemaManagementException 命中 0 ✔
6.2 claim fence 100 轮竞争(固定相同 processing_started_at)
脚本 iv-fence-loop2.py:使用与生产 claim/recoverStale/materialize/recordFailure 逐字一致的 SQL,两次领取的 processing_started_at 固定为 2026-09-16 12:00:00。
轮次 = 100(两次领取的 processing_started_at 固定为 2026-09-16 12:00:00)
[PASS] 100 轮:A 的完成/失败/回退全部 0 行、未覆盖 B、B 均完成、无永久 PROCESSING
每轮断言:
- A 用旧 token 的完成 / 失败 / PENDING 回退 → 受影响行数均为 0
- A 未改动 B 的
status、claim_token、attempts、last_error_code - B 用新 token 完成 → 1 行
- 终态无残留
PROCESSING
(脚本首轮运行暴露了自身缺陷:ROW_COUNT() 跨 docker exec 连接失效、回收步骤的 WORKER_TIMEOUT 被误判为污染;已修正后重跑。)
6.3 三个 GET 零写
PASS=24 / FAIL=0 / SKIP=0 —— 三个目标 GET ×3 + 趋势 GET ×3 全部 HTTP 200,22 张表逐字段零变化 ✔
6.4 无快照学生
新学生 8019:before=0 → 3 次 GET(均 200)→ after=0 ✔
6.5 五条写侧链路
PASS=44 / FAIL=0 / SKIP=0 —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条均验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;相同输入重放不推进 version ✔
6.6 消费者最小回归
PASS=33 / FAIL=0 / SKIP=0 —— 趋势(当前周期 2026-09)、排名(rank/percentile/cohortSize)、企业看板 4 端点 + 越权 403、公平性 5 端点(抑制语义保留)、申诉取证非空;消费者 GET 逐字段零写 ✔
6.7 RabbitMQ
4 个队列(queue.sms.verification / queue.notification / queue.notification.dlq / queue.audit)ready=0、unacked=0 ✔
6.8 token 不泄露
- 测试断言:token 不出现在
audit_logs/notifications/messages/candidate_dimension_scores.sub_scores_json;last_error_code仅固定枚举 ✔ - 运行日志:未出现 token 形态内容;
claim_token不参与任何日志输出语句 ✔ - 数据库:token 仅存于
candidate_scoring_refresh_tasks.claim_token,且在终态/PENDING 时被清空 ✔
7. 精确修改文件
生产代码(3 个)
| 文件 | 改动 |
|---|---|
db/migration/V45__add_scoring_refresh_claim_token.sql |
新增:仅加 claim_token VARCHAR(36) NULL |
candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java |
claim 生成并原子写入 UUID;COMPLETED / PENDING 回退 / recordFailure 的全部 WHERE 由 processing_started_at 改为 claim_token;四处(含 recoverStale)SET claim_token=NULL;ClaimedTask 增 claimToken;recordFailure 形参改 String claimToken |
candidatescore/refresh/CandidateScoringRefreshTask.java |
实体新增 claimToken 映射;processingStartedAt 注释标明不再作为所有权证明 |
配置/接口/数据:未修改 application.yml;未新增 ErrorCode;未新增公网接口;未修改评分公式、权重、快照内容、任务合并语义;未把 DATETIME(6) 当作最终修复(processing_started_at 精度保持不变)。
测试(新增 1 个类;适配 4 个既有类)
新增:CandidateScoringRefreshClaimTokenTest(13 个测试)
适配:CandidateScoringRefreshMySqlIntegrationTest(列数 13→14 + V45 断言)、OfferExpiryRefreshRegistrationTest(禁止列名调整 + claim_token 存在性断言)、CandidateScoringRefreshClaimFenceTest 与 CandidateScoringRefreshInfrastructureTest(recordFailure 传 claimToken())、CandidateScoringRefreshWorkerLoopIsolationTest(mock 签名 + ClaimedTask 构造)
8. Git 状态
分支:refactor/r01-structure-performance
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变)
暂存区:空
git add -A / commit / push:均未执行
V1–V44:未修改
新增迁移:V45(唯一本轮新增;V44 为 D4-1 产物)
删除文件:无
git diff --check:无空白错误
验收数据已按精确主键清理(users 残留 0:8015/8016/8017/8018/8019 均删除;公司 33/34、岗位 50、投递 92/93、offer、简历版本、申诉及关联行全部清除)。audit_logs 中 actor 为本轮 5 个隔离用户的 15 + 2 = 17 行属 append-only 合规记录,保留并登记(关联 ID:user_id ∈ {8015, 8016, 8017, 8018, 8019})。
唯一测试夹具(精确 INSERT,已登记并已清理):user_roles 为 8017/8018 各插入 1 行 COMPANY。
Docker 保持运行(三容器 healthy);仅停止本轮记录 PID 的实例;8080/8081 空闲。
9. 明确声明
- 未暂存、未提交、未推送。
- 未进入其他 R01 工作。
- 原始验收产物继续排除在后续提交之外。
- 已按要求「先补失败测试 → 保存修复前失败结果 → 再改生产代码」。
R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。