From c3b5b2559737ef1fd578cd99bc91c87f1b360629 Mon Sep 17 00:00:00 2001 From: youfak Date: Thu, 30 Jul 2026 21:04:19 +0800 Subject: [PATCH] docs: record distributed admission delivery --- docs/api/distribution.md | 8 +++--- docs/configuration/reference.md | 4 +++ docs/development/implementation-plan.md | 13 +++++++--- docs/operations/runbook.md | 2 +- docs/requirements/completion-audit.md | 6 ++--- docs/requirements/traceability.md | 2 +- docs/testing/test-strategy.md | 3 ++- internal/config/config_test.go | 7 ++++++ internal/config/validate.go | 3 +++ internal/controller/extraction/service.go | 3 +++ .../controller/extraction/service_test.go | 25 +++++++++++++++++++ 11 files changed, 63 insertions(+), 13 deletions(-) diff --git a/docs/api/distribution.md b/docs/api/distribution.md index 18e83ce..e0aa92e 100644 --- a/docs/api/distribution.md +++ b/docs/api/distribution.md @@ -154,7 +154,7 @@ Redis Adapter 应通过单个 Lua 脚本、Redis Function 或等价的原子原 - 两个并发成功响应的 Proxy ID 集合交集为空。 - 原子操作失败时整个批次不返回,也不得留下部分移除结果。 - `allOrNothing` 不足时零个条目退出活动池。 -- Redis 活动池不可用时返回 503,不以内存副本冒充成功。 +- Redis 活动池或分布式限流不可用时返回 503,不以内存副本冒充成功。 - PostgreSQL 不可用不阻断提取;需要 PostgreSQL 的 Admin 管理写入单独降级。 ## 7. 幂等 @@ -175,7 +175,9 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退 - 直连请求使用来源 IP 形成匿名 Client。 - 只有来源属于 `trustedProxies` 时才接受转发头。 -- 全局和每 Client 限流在查询库存前执行。 +- 全局和每 Client 限流在查询库存前执行;本地监听器先做早期拒绝,Redis 使用 + 服务端时间和单个 Lua 原子操作执行跨 Controller 副本的权威额度。 +- Client 身份进入 Redis 前转换为定长摘要,窗口切换时原子删除上一窗口字段。 - 过滤条件、数量、请求体和 Header 都有长度/数量上限。 ## 9. 错误模型 @@ -190,7 +192,7 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退 - `415`:请求体不是 `application/json`。 - `422`:数量、枚举或过滤组合违反业务约束。 - `429`:全局或 Client 速率限制,响应 `Retry-After`。 -- `503`:Redis 活动池不可用、原子提取不可执行或服务正在排空。 +- `503`:Redis 活动池/分布式限流不可用、原子提取不可执行或服务正在排空。 - `500`:未分类的内部错误;响应不包含底层错误文本。 错误响应不得包含 Provider Secret、Proxy 凭据、SQL 或内部拓扑。 diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 386dead..bf239f1 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -104,6 +104,10 @@ limits: `trustedProxies` 只决定何时接受 `Forwarded` 或 `X-Forwarded-For`,不能替代 `allowCIDRs`。来自非可信代理的转发头必须忽略。 +`requestsPerMinute` 和 `requestsPerMinutePerClient` 为非负整数,最大值为 +`2^53-1`;Distribution 的非零额度由 Redis Lua 计数,因此配置校验统一限制在 +Lua 可精确表示的整数范围内。 + ### 3.1 认证模式 - `none`:无身份认证,访问控制与限流仍生效。 diff --git a/docs/development/implementation-plan.md b/docs/development/implementation-plan.md index 40ab467..e366cd4 100644 --- a/docs/development/implementation-plan.md +++ b/docs/development/implementation-plan.md @@ -174,8 +174,9 @@ Upstream、`endBehavior` 默认 `stop`,并覆盖列表末端停止;disabled candidate eligibility, Gateway reserve, ownership, removal, and short-lived idempotency. - [x] Implement Redis Worker ownership, drain/ACK, expiry reclaim, inventory and bounded sweep primitives with a monotonic global epoch. -- [ ] Implement Redis Provider leader, distributed rate, Client limit and Worker - heartbeat; wire automatic Provider inventory rebuild after Redis loss. +- [x] Implement Redis Provider leader, distributed request quota, Client limit and + automatic Provider inventory rebuild after Redis state loss. +- [ ] Implement the Worker heartbeat receiving path and session lifecycle. - [x] Keep Provider output in Redis TTL activity state and node memory only; keep the Gateway request path on immutable local snapshots with no Redis/PostgreSQL calls. - [x] Expose Distribution extraction/status and Admin status/enable/disable/switch/reload @@ -236,9 +237,13 @@ Redis Provider Permit 现已把 requestInterval、maxInFlight 与 maxTotal 放 启动后动态扩容。Supervisor 以 PostgreSQL 权威 HMAC 指纹和 revision 栅栏协调 多副本 reload:管理库瞬断沿用 last-known 状态,本地共享源落后时停止旧 Provider, 源匹配并预检后自动替换,迟到旧 revision 不覆盖新配置。 +Distribution 现通过公用 `admission.Admitter` 接入独立 `redisadmission` Adapter; +全局和单 Client 分钟额度使用 Redis 服务端时间并在单个 Lua 原子边界内检查、递增, +Controller 多副本共享同一计数。Client 身份只以 SHA-256 摘要进入 Redis,窗口切换 +原子回收历史字段;Redis 异常 fail-closed 并返回 503,真实额度耗尽返回 429。 +Gateway 请求热路径仍只使用本地准入,不增加 Redis/PostgreSQL 调用。 WorkerControlPlane gRPC 接收端、session 签发/心跳、Snapshot ACK -账本、Client 分布式限流和健康执行链仍待完成,因此本轮不勾选 Task 10 的组合 -验收项。 +账本和健康执行链仍待完成,因此 Task 10 尚未全部完成。 ## Task 11: Checker and Health Reducer diff --git a/docs/operations/runbook.md b/docs/operations/runbook.md index 854c50c..4c85028 100644 --- a/docs/operations/runbook.md +++ b/docs/operations/runbook.md @@ -240,7 +240,7 @@ Outbox 发布器必须以稳定 consumer ID 有界领取;发布成功后原子 回查 PostgreSQL 或 Provider。 2. Distribution 立即失败关闭并返回 503,禁止本地内存提取或 PostgreSQL 兜底。 3. Controller 停止活动池写入和需要分布式互斥的工作,防止多个 Fetch Leader; - 本地限流不能声称满足全局额度。 + Distribution 的 Redis 权威限流同时失败关闭,本地早期限流不冒充跨副本额度。 4. 恢复后确认 Leader 唯一和租约 epoch 单调,由 Provider 重新 Fetch 并构建 TTL 活动池,再恢复 Distribution。Redis 整体丢失会终止原活动池代次的排他状态和 短期幂等窗口;高可用、持久化、监控和告警必须明确并降低该风险。 diff --git a/docs/requirements/completion-audit.md b/docs/requirements/completion-audit.md index b1d7d54..2b02878 100644 --- a/docs/requirements/completion-audit.md +++ b/docs/requirements/completion-audit.md @@ -84,11 +84,11 @@ CI 已配置 Linux race job。PostgreSQL 18 和 Redis 8.2 的隔离 Adapter fixt 4. Controller 的 PostgreSQL 连接池、迁移和 pgx Adapter 启动装配已完成; 公用 bootstrap 已通过 PostgreSQL 18 + Redis 8.2 双存储集成,Controller 三监听器与探针集成已完成;可选聚合指标和完整容器进程部署验证仍待实现。 -5. Redis Provider Leader、分布式速率与 Client 限制、Worker 心跳和自动重建; - TTL 活动池、原子提取和 Worker ownership 已完成。 +5. Worker heartbeat gRPC 接收路径;Redis Provider Leader、分布式请求额度、 + Distribution Client 限制和 Provider 状态丢失重建已完成。 6. Worker 网络快照流;Redis ownership drain/ACK/过期回收已完成。 7. Checker 调度、探测器和健康 reducer。 -8. Admin/Distribution 细粒度授权、分布式限流和审计查询。 +8. Admin/Distribution 细粒度授权和审计查询;Distribution 分布式限流已完成。 9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。 10. 将五种 Routing 策略和 `onUnavailable` 接入 Gateway/Distribution 运行链, 补齐 Sequential 持久化恢复、跨实例 CAS 和 disabled candidate 语义。 diff --git a/docs/requirements/traceability.md b/docs/requirements/traceability.md index 2fb3b2d..61fbab1 100644 --- a/docs/requirements/traceability.md +++ b/docs/requirements/traceability.md @@ -72,7 +72,7 @@ | DIST-005 | 返回 expiresAt 与 remainingTtlSeconds | 9334-9360 | `extraction/service_test.go` | | DIST-006 | 提取前校验 minRemainingTTL 与 maxHealthCheckAge | 9334-9369 | 过滤测试 | | DIST-007 | reserveForGateway 防止 Extract 清空共享池 | 9281-9333 | 共享池测试 | -| DIST-008 | 提取认证可关闭,关闭后仍有来源识别与全局限制 | 8112-8441 | 来源身份准入与 `FixedWindow` 并发测试 | +| DIST-008 | 提取认证可关闭,关闭后仍有来源识别与全局限制 | 8112-8441 | 来源身份准入、`FixedWindow` 单元测试及 `redisadmission` 双实例/并发集成测试 | ## 健康、安全、运维与测试 diff --git a/docs/testing/test-strategy.md b/docs/testing/test-strategy.md index 16a6d39..9584db2 100644 --- a/docs/testing/test-strategy.md +++ b/docs/testing/test-strategy.md @@ -30,7 +30,8 @@ 原子操作;并发与主从切换下不得部分提交。 - PostgreSQL 配置版本、Upstream/Routing 管理状态、Admin 审计与 outbox 的事务 更新及幂等重放;测试库断言不包含 Proxy 明细或逐次提取记录。 -- Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。 +- Redis TTL 活动池、Leader 租约、Provider 请求额度、Distribution Client + 跨副本限流、短期幂等窗口和失联恢复。 - Snapshot/Delta/ACK/Report 的版本与校验和兼容性。 - OpenAPI 错误模型、认证矩阵、批量 fulfillment。 - OpenAPI 本地引用闭合、operationId 唯一、响应集合和 security scheme 引用。 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 992e873..ba2e55e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -428,6 +428,13 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) { }, want: "requestsPerMinute", }, + { + name: "listener request limit exceeds exact counter range", + mutate: func(cfg *Config) { + cfg.Distribution.Limits.RequestsPerMinutePerClient = int(MaximumExactCounter) + 1 + }, + want: "requestsPerMinutePerClient", + }, { name: "invalid client identification mode", mutate: func(cfg *Config) { diff --git a/internal/config/validate.go b/internal/config/validate.go index 5e70589..b27d980 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -122,6 +122,9 @@ func validateListener(name string, listener Listener, security Security) error { if limit.value < 0 { return fmt.Errorf("validate %s limits.%s: must be non-negative", name, limit.name) } + if int64(limit.value) > MaximumExactCounter { + return fmt.Errorf("validate %s limits.%s: exceeds exact counter range", name, limit.name) + } } host, err := validateListenAddress(name, listener.Listen) if err != nil { diff --git a/internal/controller/extraction/service.go b/internal/controller/extraction/service.go index cdd1020..75626b8 100644 --- a/internal/controller/extraction/service.go +++ b/internal/controller/extraction/service.go @@ -111,6 +111,9 @@ func (s *Service) Extract(ctx context.Context, request Request) (Response, error return response, ErrInvalidFulfillment } if err := s.admission.Admit(ctx, admissionKey(request)); err != nil { + if errors.Is(err, admission.ErrUnavailable) { + return response, errors.Join(ErrUnavailable, err) + } return response, errors.Join(ErrAdmissionRejected, err) } diff --git a/internal/controller/extraction/service_test.go b/internal/controller/extraction/service_test.go index 5f919c2..a2b5b89 100644 --- a/internal/controller/extraction/service_test.go +++ b/internal/controller/extraction/service_test.go @@ -9,6 +9,7 @@ import ( "proxy-pool/internal/domain/activitypool" domain "proxy-pool/internal/domain/extraction" proxyDomain "proxy-pool/internal/domain/proxy" + platformAdmission "proxy-pool/internal/platform/admission" ) func TestServiceAppliesPolicyAndBuildsResponse(t *testing.T) { @@ -122,6 +123,30 @@ func TestServiceAppliesAdmissionBeforeStoreUsingStableIdentity(t *testing.T) { } } +func TestServiceMapsUnavailableAdmissionToServiceUnavailable(t *testing.T) { + store := &recordingStore{} + admitter := &recordingAdmission{err: platformAdmission.ErrUnavailable} + service, err := NewService(store, Policy{ + MaxCountPerRequest: 1, + DefaultFulfillment: domain.Partial, + }, admitter, time.Now) + if err != nil { + t.Fatalf("NewService(): %v", err) + } + + _, err = service.Extract(context.Background(), Request{ + RequestID: "req-1", + ClientID: "client-1", + Count: 1, + }) + if !errors.Is(err, ErrUnavailable) || errors.Is(err, ErrAdmissionRejected) { + t.Fatalf("Extract() error = %v, want only ErrUnavailable", err) + } + if store.calls != 0 { + t.Fatalf("store calls = %d, want 0", store.calls) + } +} + func TestServiceUsesSourceIdentityForEphemeralIdempotency(t *testing.T) { store := &recordingStore{} service, err := NewService(store, Policy{