6.6 KiB
ADR-0146:PostgreSQL Durable Plugin Package 管理配额
- 状态:Accepted
- 日期:2026-07-25
- 关联 RFC:QL-RFC-0001 D-08、D-09、D-49、D-50、D-123、D-142、D-143
- 关联 ADR:ADR-0051、ADR-0125、ADR-0142、ADR-0144、ADR-0145
上下文
ADR-0145 的 fixed-memory peer/global shield 位于认证前,只能保护单个进程的连接、 body 和认证成本。双副本部署会把该预算按副本数放大;它既不知道 User/Project,也 不能在 Pod 重启或切换后保留业务公平性。把匿名入口请求写入 PostgreSQL 又会让攻击 流量消耗要保护的连接池。
管理写入已经使用稳定 actionRef 和 decisionId,但公开 inspect 原先既没有独立
幂等身份,也直接调用内部只读方法。后者会允许任意强认证 User 按 ID 探测其他
Project 的 proposal/Approval,且无法形成正确的
Project + subject + operation 配额键。
决策
1. 配额是认证和 Project Policy 之后的领域端口
@qinglong/runtime-core/plugin-package-management 增加可选
PluginPackageManagementQuotaPort,不新增 workspace package或第三方依赖。
propose在 action 规范化、当前package.managePolicy 允许之后消费额度,幂等 身份为actionRef;decide在读取 durable Approval、当前approval.decidePolicy 允许之后消费, 幂等身份为decisionId;- quota 拒绝发生在 proposal/Approval mutation 前;
- 未授权请求不得创建或消耗其他 Project 的 bucket;
- local/edge composition 不注入该 port,因此没有 PostgreSQL、timer 或常驻成本。
Cluster 公开 inspect 增加显式 inspectionId。manager service 先读取 proposal 与
Approval,复验两者的 Project/action/digest 绑定,再要求当前 User 具有
package.manage 或 approval.decide 权限,最后消费 inspect quota。不存在的对象
返回统一 conflict,未授权对象返回 forbidden,不再通过公开 transport 调用无授权
内部 inspect。
2. 单行、固定窗口、数据库时钟
pg-0023-plugin-package-management-quota 将 control-core 推进至 capability v22,
新增一张表:
plugin_package_management_quota_buckets
PK(project_id, subject_type, subject_id, operation)
window_started_at_ms
consumed_count
receipt_ids
updated_at_ms
裁决使用 PostgreSQL clock_timestamp();调用方时间和 Pod 本机时间不参与窗口。一个
SQL statement 使用 INSERT ... ON CONFLICT DO UPDATE 锁定 bucket:
- 过期窗口按数据库时间重置 count 和 receipt;
- 当前窗口已经存在相同 receipt 时返回成功但不递增;
- 未重放且 count 未到 limit 时原子递增并追加 receipt;
- 已满且不是重放时不更新,随后只读当前 bucket 计算
Retry-After。
receipt 与 count 同行、同窗重置,jsonb_array_length(receipt_ids) 必须等于
consumed_count。单 bucket 最大 1000 项、JSON 最大 256 KiB,不创建逐请求永久表、
cleanup timer 或后台 GC。主体固定为 User,operation 只允许
plugin-package.propose | decide | inspect。
这不是滑动窗口,也不是计费 ledger。窗口边界附近的两个新请求可能分别落入两个 窗口;这是固定窗口的明确语义。write replay 还由 proposal/Approval durable identity 约束,quota receipt 只负责当前窗口内不重复扣减。
3. 最小权限与资源档位
仅 ql3_package_manager 获得 quota 表 SELECT/INSERT/UPDATE。ql3_admin、
ql3_package_executor、ql3_runtime 与 ql3_worker_ingress 均无权限,所有角色都
无 DELETE、owner 或 schema CREATE。
默认窗口 60 秒,默认 limit:
- propose:30;
- decide:60;
- inspect:600。
窗口硬上限 5 分钟,单 operation limit 硬上限 1000。Kubernetes base 与 CloudNativePG overlay 显式冻结上述值;每副本仍最多 2 条 manager 连接。认证前 process-local shield 保留,不能用 durable quota 取代 TLS connection、并发、body 或 ingress/WAF 容量保护。
4. 响应和故障语义
quota exhausted 映射为低敏 HTTP 429、error.code=quota_exceeded 和向上取整的
Retry-After,不返回 Project、subject、计数行或数据库诊断。数据库不可用、返回行
损坏或无法确定裁决时映射 503,失败关闭并保留现有 readiness fence。
裁决 SQL 是单 statement 的隐式事务。若 PostgreSQL 已提交而客户端未收到响应, 相同幂等身份重放会命中 receipt 并返回成功,不额外消耗额度;不得生成新 identity 后盲重试。
验证
已完成:
- runtime-core 证明 quota 只在 Policy allow 后调用,quota 拒绝发生在任何 proposal/Approval mutation 前;
- PostgreSQL repository 证明 SQL 使用
clock_timestamp()、单语句 UPSERT、 bounded receipt、exact replay、429 reset delay 和低敏 unavailable; - migration/schema/readiness 证明 v22、36 张表、Drizzle/catalog/CHECK/FK lockstep 与 manager-only SELECT/INSERT/UPDATE;
- cluster transport 证明公开 inspect 只走 authorized/quota-aware path,HTTP 证明
429 与
Retry-After; - PostgreSQL 18.4 arm64 physical HA 门使用两个独立 manager instance 同时提交 16 个不同 inspection:精确 8 allow、8 quota reject;已放行 identity 重放不再 扣减;
- 同一 HA 门在服务端自动提交成功后注入客户端响应丢失,重放后 durable count 仍为 1;人工推进旧窗口后下一请求由数据库时钟重置为 count 1;
- HA 总门增加为 23 个具体 gate,并继续通过 timeline 1→2、
remote_apply、 fence-before-promote、旧主pg_rewind只读同步重入与双 fresh control activation; 运行后没有残留ql3-ha-*container、volume 或 network。
后果
优点是双 Pod 共享同一业务裁决,Pod 数量和本机时钟不再放大认证后额度;单行状态使 低额度管理 API 的空间和锁竞争可预测,也不增加 Redis。代价是同一 Project/User/operation 的请求串行于一个 PostgreSQL 行锁,且固定窗口不是全局严格 平滑速率。当前默认上限和管理流量规模接受该取舍;若未来需要高吞吐或计费语义,应 新增独立 ADR,而不能扩张本表为无界逐请求日志。
本 ADR 关闭 durable management quota 阻断项,但不开放生产 ingress。全副本重启 后的 keyset anti-rollback、真实 IdP 两名 User 四眼、双 Pod live NetworkPolicy/ certificate rotation、production ingress/WAF 与告警仍必须完成。
ADR-0147 已在后续 pg-0024/capability v23 中关闭全副本重启 keyset
anti-rollback;其余真实 IdP/live ingress 前置保持不变。