fix(server): _parse_redis_url 只认一种 URL 形态且无 match 时静默回落 localhost——执行状态存储与队列/事件总线连到两个 Redis(rediss:// 明文降级、ACL 形态静默失效、启动期一次性错配不自愈)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 45/100
Research direction
Start with _parse_redis_url in plaita/server/factory.py (lines 18-32) and its caller in create_storage_component (lines 92-104), then check how plaita/storage/redis.py (around lines 160-165) uses host, port, db and password. Add tests/unit/test_redis_url_parse.py covering the URL forms listed in the issue. Done when those forms return the expected tuples, redis:// with an empty target raises instead of falling back to localhost, and the old warning path no longer runs.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无
基线 HEAD = 65f48f2
Summary
create_storage_component 只对 execution / flow 两类存储走 _parse_redis_url 解析出的
连接参数,而同一进程里的任务队列与事件总线用的是原始 redis_url。_parse_redis_url
的正则只认 redis://[:password@]host:port[/db] 这一种形态,且 match is None 时既不报错
也不降级提示,直接返回默认的 localhost:6379/0。两种 URL 形态因此被解析到不同端点。
plaita/server/factory.py:18-32—— 解析器本体(正则只接受带端口的redis://;无
match 分支零日志):
def _parse_redis_url(redis_url: str):
"""解析 Redis URL 为连接参数"""
host, port, db, password = "localhost", 6379, 0, None
if redis_url:
try:
pattern = r'redis://(:(.*?)@)?(.*?):(.*?)(/(\d+))?$'
match = re.match(pattern, redis_url)
if match:
password = match.group(2) or None
host = match.group(3) or host
port = int(match.group(4)) if match.group(4) else port
db = int(match.group(6)) if match.group(6) else db
except Exception as e:
logger.warning("解析Redis URL失败: %s, 使用默认连接参数", e)
return host, port, db, password
plaita/server/factory.py:92-104—— 解析结果被当作连接的唯一来源,
execution/flow存储都用它构造:
if component_type in ("execution", "flow"):
host, port, db, password = _parse_redis_url(redis_url)
conn = dict(host=host, port=port, db=db, password=password)
# 多租户路由包装器:按 ContextVar 租户选择 namespace
# (default → 历史 plaita 前缀;其余 plaita:{tenant})
if kwargs.get("tenant_routing"):
from plaita.server.tenant_context import (
TenantRoutingExecutionStorage,
TenantRoutingFlowStorage,
)
if component_type == "execution":
return TenantRoutingExecutionStorage(**conn)
return TenantRoutingFlowStorage(**conn)
plaita/storage/redis.py:160-165—— 这四个参数确实被用来建客户端,解析值是实际生效的
连接目标(不是被忽略的配置):
self.client = redis.Redis(
host=host,
port=port,
db=db,
password=password,
decode_responses=True, # 自动将字节解码为字符串
plaita/server/flow_worker.py:3262-3267—— 集群档 CLI 里存储组件由上述函数创建,
传入的是未加工的args.redis_url(见:3258):
execution_storage = create_storage_component(
args.execution_storage_type,
"execution",
tenant_routing=True,
**storage_kwargs
)
plaita/server/flow_worker.py:2024—— 同一个 worker 的队列客户端用原始 URL:
redis_client = redis_client or Redis.from_url(redis_url)
plaita/server/event_filter.py:407—— 事件过滤器同样用原始 URL:
redis_client = Redis.from_url(redis_url)
plaita/event/redis.py:773—— 事件总线也用原始 URL:
self.redis = await aioredis.from_url(self.redis_url)
plaita/server/flow_worker.py:3114-3116—— 该 URL 在集群档由PLAITA_REDIS_URL注入,
默认值redis://localhost:6379/0(也就是「不配置时恰好正确」的形态):
# Redis参数(支持环境变量 PLAITA_REDIS_URL / REDIS_URL)
parser.add_argument("--redis-url",
default=os.environ.get("PLAITA_REDIS_URL", os.environ.get("REDIS_URL", "redis://localhost:6379/0")),
plaita-console/cluster_config.yaml:45,56—— 用户配置的redis.url原样注入到
worker / 各外延服务的环境变量:
PLAITA_REDIS_URL: ${redis.url}
plaita-console/backend/api/cluster.py:1285—— 该字段是自由文本,无格式校验:
redis_url: str = Field("redis://localhost:6379/0", description="Redis URL")
全仓只有这一个 Redis URL 解析器,且没有任何测试覆盖它:
$ grep -rn "_parse_redis_url" --include='*.py' .
./plaita/server/factory.py:18:def _parse_redis_url(redis_url: str):
./plaita/server/factory.py:93: host, port, db, password = _parse_redis_url(redis_url)
$ grep -rn "_parse_redis_url" tests/ plaita-console/
grep exit=1
(零命中)
实测(/tmp/plaita-audit-venv/bin/python,真 plaita.server.factory):
$ /tmp/plaita-audit-venv/bin/python -c "from plaita.server.factory import _parse_redis_url as p; [print(repr(u),'->',p(u)) for u in ['redis://plaita-redis','rediss://redis.internal:6380/0','redis://user:pass@host:6379/0','redis://host/3','redis://localhost:6379/0']]"
解析Redis URL失败: invalid literal for int() with base 10: 'pass@host:6379', 使用默认连接参数
'redis://plaita-redis' -> ('localhost', 6379, 0, None)
'rediss://redis.internal:6380/0' -> ('localhost', 6379, 0, None)
'redis://user:pass@host:6379/0' -> ('user', 6379, 0, None)
'redis://host/3' -> ('localhost', 6379, 0, None)
'redis://localhost:6379/0' -> ('localhost', 6379, 0, None)
补充形态(同一次实测):
$ /tmp/plaita-audit-venv/bin/python -c "from plaita.server.factory import _parse_redis_url as p; [print(repr(u),'->',p(u)) for u in ['redis://:pw@host:6379/2','redis://host:6379','rediss://user:pw@host:6380/1']]"
'redis://:pw@host:6379/2' -> ('host', 6379, 2, 'pw')
'redis://host:6379' -> ('host', 6379, 0, None)
'rediss://user:pw@host:6380/1' -> ('localhost', 6379, 0, None)
即:rediss://、user:pass@、无端口(redis://host、redis://host/3)三种形态被静默
改写成 localhost:6379;user:pass@ 形态还会把用户名当成 host(('user', …))并丢掉密码,
而 int() 异常只在 logger.warning 里出现一次,进程继续用错误参数运行。只有
redis://:password@host:port/db(无用户名)与 redis://host:port[/db] 是正确的。
影响
部署形态:console 集群档(plaita-console/cluster_config.yaml 把 redis.url 注入
PLAITA_REDIS_URL)或任何用 --redis-url / 环境变量指向非 localhost 或非 redis://
形态的 worker / 外延服务。
有条件(redis://host:port 之外)的 URL 一配置就会出现同一进程连两个 Redis:
任务队列(flow_worker.py:2024)、事件过滤器(event_filter.py:407)、事件总线
(event/redis.py:773)连到运维指定的实例;而执行状态/流程定义存储
(factory.py:93 → RedisExecutionStorage)连回 localhost:6379/0。后果:
- 状态分裂:worker 在正确的 Redis 上领取任务,却把
running/error终态写进另一个
Redis(或写不进去——容器里localhost通常根本没有 Redis,于是每次读写都抛连接错误);
队列上「任务已投递」与存储里「无此 execution」并存,console 看到的执行记录与实际调度脱节。 rediss://是明文降级:TLS 目标被改写为明文redis://localhost:6379/0,凭据与执行
状态(含节点输入输出)要么落到未预期的本机实例,要么以明文尝试连接,属于安全边界失效。- ACL 形态静默失效:
user:pass@被解析成 host=user、password=None,连接要么失败、
要么以错误的身份/无认证重试,且int()异常只留一行 warning,运维侧无告警、无 fail-fast。 - 这是启动期的一次性错配,不会自愈:worker 能启动、能领取任务,错误只在业务终态写盘时
以「连不上」或「状态对不上」的形式显现,排查成本远高于启动时直接报错。
不修的后果:运维改一次 redis.url(引入 rediss://、用户名或省略端口)就会把执行状态写散
到两个 Redis,且系统全程不报错。
建议
修复点唯一:plaita/server/factory.py:18-32 的 _parse_redis_url。
-
最小修法:用标准库替换手写正则,一次性拿到全部连接字段并保留 scheme:
from urllib.parse import urlparse u = urlparse(redis_url) return u.hostname, u.port or 6379, int((u.path or "").lstrip("/") or 0), u.password并按
u.scheme(redis/rediss)决定是否启用 TLS,把 username 一并透出
(RedisExecutionStorage需相应接受username/ssl参数)。 -
match is None、urlparse失败、或 scheme 不在白名单内时,改为抛异常中止启动
(fail-closed),不要静默回退默认端点。 -
更稳的形态:让
execution/flow存储与队列共用同一个
Redis.from_url(redis_url)客户端(现有RedisExecutionStorage.__init__已支持
client=注入,factory.py只需把它透传),从根上消除「一个 URL 两种解析」的可能。
验收条件:
- 新增
tests/unit/test_redis_url_parse.py,对
redis://host:6379/0、redis://host、redis://host/3、redis://:pw@host:6379/2、
redis://user:pw@host:6379/0、rediss://user:pw@host:6380/1断言解析结果分别等于
('host',6379,0,None)、('host',6379,0,None)、('host',6379,3,None)、
('host',6379,2,'pw')、('host',6379,0,'pw')(username=user、scheme=rediss)——即
上表里的('localhost', 6379, 0, None)全部消失。 - 断言非法形态(如
redis://空目标)抛异常而非返回默认值。 - 复现命令应输出:
'redis://plaita-redis' -> ('plaita-redis', 6379, 0, None)、
'rediss://redis.internal:6380/0' -> ('redis.internal', 6380, 0, None)、
'redis://user:pass@host:6379/0' -> ('host', 6379, 0, 'pass')(username=user),
且 stderr 无解析Redis URL失败的降级 warning。
边界
- 只处理
_parse_redis_url这一处解析错误与它的静默回退;不顺手改动存储/队列的其余配置
面(连接池、超时、TLS 证书校验等)。 - 不改
plaita/node/redis.py:32-40:那里的节点用自己的urlparse,已正确取
username/password,不属于本缺陷。 - console 侧对
redis.url无格式校验(cluster.py只做Redis.from_url连通性测试)是
本缺陷的放大条件而非根因,本单只要求解析器 fail-closed;给 console 加字段校验可另开单。 - 仓内默认值(
flow_worker.py:3116、event_filter.py:497、cluster.py:1285)都是
redis://localhost:6379/0,恰好是解析器唯一能正确处理的形态,所以默认部署与现有 E2E
(plaita-console/e2e.yaml用的redis://plaita-e2e-redis:6379/0带端口)都不会触发;
触发前提是运维把 URL 改成rediss://、user:pass@或省略端口。 - 查重:
gh issue list -R jeffkit/plaita --state all --limit 100返回的 31 条中无 Redis URL
解析相关单(#24 租户凭据路由、#40 存储后端列表均为不同组件/不同根因),本单不与既有单重复。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 2h 33m
- Merged PRs (30d)
- 9
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 5/5 Over a week Newbie friendliness 25/100
Maintainers usually reply within 1 day
-
enhancement
Difficulty 4/5 1-2 days Newbie friendliness 43/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 20/100
jeffkit/plaita#305 · 4 comments ·
Maintainers usually reply within 1 day
Similar issues
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
conda-forge/conda-forge-bot#7025 ·
Maintainers usually reply within 1 day
-
area:ci
Difficulty 1/5 Under an hour Newbie friendliness 74/100
catincloud-labs/dagster-dex#149 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
home-assistant/core#188424 ·
Maintainers usually reply within 1 day
-
Review backlog (docs-currency): README 'CI run' link lands on a run whose overall badge is cancelledOpenagent/reviewer needs-kind needs-triage
Difficulty 2/5 1-3 hours Newbie friendliness 63/100
projectbluefin/utah#880 · 2 comments ·
Maintainers usually reply within 1 day
-
LSan reports obj_gen leak on post-parse usage() exits (aarch64)Possibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
redis/memtier_benchmark#850 ·
Maintainers usually reply within 1 day