# 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: but was: `** ← 核心缺陷复现 | 同时复现了失败登记的同类覆盖: `sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` → **`expected: but was: `** 其余 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` ```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 相关。 **过程中修正的测试侧问题(非产品缺陷)**: 1. `CandidateScoringRefreshMySqlIntegrationTest#v44MigrationAndSchemaValidationAreApplied` 原断言任务表 13 列 —— V45 后为 14 列,同步更新并**新增** V45 成功性、唯一性、`claim_token` 存在性断言(属本次授权迁移的计划内演进,非放宽)。 2. `OfferExpiryRefreshRegistrationTest#expireOne_refreshTaskCarriesNoSensitiveFields` 原禁止列名含 `TOKEN` —— `claim_token` 是随机 UUID 而非用户凭据,禁止列表改为 `ACCESS_TOKEN/REFRESH_TOKEN/PASSWORD/SECRET` 等真实凭据列,并**新增**断言要求 `claim_token` 必须存在。 3. 本人新增测试中的两处断言缺陷(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 评分快照写入迁移尚未冻结。