fix(console): #274 并发同 dedup_key 双 POST 各自入队——预认领与占位行非原子,落败 id 永久 queued 且占配额
Maintainers usually reply within 1 day
@okguitar is already working on this.
Since Oct 21, 2026.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- python, redis
- Domain
- backend, databases, distributed-systems
Research direction
Start with POST /api/executions and _claim_start_dedup_at_post, then trace _write_queued_placeholder, _handle_start_dedup_hit, and the worker start flow. Read the race probe at /tmp/r101_dedup_race.py and reproduce the interleaving with its placeholder-write barrier. Done means the acceptance checks pass: concurrent same-tenant requests share one id and queue entry, crashed claims can be recovered, and the existing TTL and tenant-isolation behavior remains intact.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无
基线 HEAD = 280be04(含 #273/#274)。探针 clean archive:/tmp/plaita-r101-tree。
Summary
#274 的 console 侧预认领不是原子的:POST /api/executions 的流程是
- 铸造新
execution_id; _claim_start_dedup_at_post对{ns}:start-dedup:{dedup_key}做SET NX;- 命中时读 mapped id 的执行行;行不存在则视为「孤儿映射」,
_release_start_dedup_if_owner删除后重新认领; - 才写 queued 占位行并入队。
当两个同 dedup_key 的请求并发到达时,A 已 SET NX 成功但尚未写占位行,B 命中映射后发现行不存在,把 A 的映射当孤儿删掉并用自己的 id 重新认领。随后 A、B 都继续写占位行并各自入队。结果是同一个 dedup_key 返回两个不同 execution_id,队列里有两条 start 载体。
复现探针:/tmp/r101_dedup_race.py(fakeredis + TestClient;用 _write_queued_placeholder 的屏障精确卡在 A 的「预认领成功、占位行未写」窗口):
B result: 200 {'status':'queued','execution_id':'29cdbfb28ecc4836b1dd766db8937723',...}
A result: 200 {'status':'queued','execution_id':'3aed66b11a21429c8bb144974e574a50',...}
queued start ids: ['29cd...', '3aed...'] distinct = 2
dedup key final: 29cd...
state rows: [('29cd...', True, 'queued'), ('3aed...', True, 'queued')]
RACE REPRODUCED
影响
- 幂等契约失效:并发重试(客户端超时后重发、双副本同时转发、消息重投)是同
dedup_key最需要收敛的场景,却直接返回两个 id;两条响应都声称自己已受理。 - 落败 id 永久 queued:worker 消费落败载体时
_claim_start_dedup返回hit,_handle_start_dedup_hit读到 mapped 行是queued,最终返回{status: queued, deduplicated: True};start_flow直接返回,run()正常XACK这条 start 载体。落败 id 的 queued 占位行没有任何写者再推进,也没有终态 TTL(非终态不设 TTL),GET/列表永久显示 queued。 - 配额泄漏:两个 POST 都在
try_admit_tenant_start各占一个名额;落败载体的 worker hit 路径按 mapped id 返回,不会释放落败 id 的配额(终态释放收口只认state.execution_id)。该名额占用到配额条目 TTL 才自愈,期间同租户可被虚假占满。 - 队列重复载体:落败 start 会被消费并 ack,不产生第二次副作用(worker 映射收敛到胜者),但用户侧看到的是两条「已受理」执行,排障/取消/配额统计都指向错误 id。
建议
预认领必须与「占位行已可读」建立原子/可等待的关系,例如:
- 用 Lua 同时完成
SET NX+ 写一个最小 claim 记录(或SET NX的 value 带状态),命中方看到 claim 但行未落时等待/轮询,不得把在途认领当孤儿删除; - 或把预认领改成
SET NX后由 owner 写占位行,命中方仅在确认 owner 已死亡(短 TTL claim + heartbeat)后才接管,且接管本身用 CAS(GET owner == mapped 才 DEL); - 孤儿判定必须能区分「owner 正在写」与「owner 已崩溃」;当前「行不存在即孤儿」的判据在并发窗口内不成立。
验收:
- 两个同租户同
dedup_key的并发 POST:两个响应execution_id相同,队列只有一条 start,只有一个 queued 占位行; - 若一个请求在
SET NX后崩溃且未写行:后续重试能在有限等待/接管后成功,不得永久 404 或永久 queued; - force DELETE 后 7 天内用同
dedup_key重试仍按新 id 启动(#274 既有验收保持); - 不同租户同 key、不同 key 的隔离不变。
边界
- 本项不涉及跨租户;租户键命名空间隔离在顺序/并发场景均保持。
- 不改变 worker 7 天 dedup TTL 与「同键必同 id」契约,只修生产侧并发认领的原子性。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 2h 22m
- Merged PRs (30d)
- 8
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from jeffkit/plaita
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
Maintainers usually reply within 1 day
-
fix(console): ${redis.url} 从配置文件而非数据面 effective URL 解析——metadata 档 worker 静默连 localhost,warnings 为空Open
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 55/100
Maintainers usually reply within 1 day
-
refactor(expr): 摘除运行期字符串的二次求值(#104/#130 的终章)——契约定为「配置求值、运行期数据」Possibly taken @jeffkit claimed this today. Open
Difficulty 5/5 Over a week Newbie friendliness 25/100
jeffkit/plaita#268 · 1 comment ·
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
pgvector/pgvector-python#169 ·
-
ChaosEngine: backup-remote doctor row passes while consumer mode hides bot memory from gitPossibly taken @MohabMohie claimed this today. Openagentic
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
ShaftHQ/SHAFT_ENGINE#7263 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 87/100
huggingface/smolagents#3045 ·
Maintainers usually reply within 1 day