From 2dbfe2f7427b4be47cfcfd94e3dd062d7bf8946d Mon Sep 17 00:00:00 2001 From: youfak Date: Wed, 29 Jul 2026 21:41:17 +0800 Subject: [PATCH] fix: align sequential routing defaults --- docs/configuration/reference.md | 8 ++++-- docs/design/architecture.md | 3 ++ docs/design/product-design.md | 3 ++ docs/development/implementation-plan.md | 4 +++ findings.md | 8 +++--- internal/config/config_test.go | 17 ++++++++--- internal/config/validate.go | 3 ++ internal/domain/routing/routing_test.go | 38 ++++++++++++++++++++++--- internal/domain/routing/sequential.go | 8 +++--- progress.md | 5 ++++ 10 files changed, 78 insertions(+), 19 deletions(-) diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 3d5a815..dbb598c 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -192,7 +192,7 @@ routing: strategy: type: sequential switchAfterEmptyFetch: 5 - endBehavior: stayLast + endBehavior: stop onUnavailable: action: reject waitTimeout: 0s @@ -203,7 +203,9 @@ routing: - `strategy.type` 支持 `sequential`、`random`、`roundRobin`、`weighted`、 `leastConnections`。 - `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。 -- `sequential` 必须设置大于零的 `switchAfterEmptyFetch`。 +- `sequential` 至少引用两个 Upstream,并设置大于零的 + `switchAfterEmptyFetch`;`endBehavior` 省略时默认为 `stop`,也可显式设置 + `loop` 或 `stayLast`。 - `onUnavailable.action` 为 `reject`、`wait` 或 `direct`;默认建议 `reject`。 Sequential 的空计数属于 Upstream,当前索引属于 Routing。只有 Provider 响应 @@ -350,7 +352,7 @@ PostgreSQL 故障本身不应使 Redis 中可完成的 Extract 返回 `503`。Me 2. 所有启用监听器具有合法 `host:port`。 3. 非回环监听器满足认证或来源 CIDR 保护。 4. Routing 名称唯一,正则可编译,引用的 Upstream 存在。 -5. Sequential 阈值大于零,`onUnavailable.action` 明确。 +5. Sequential 至少引用两个 Upstream、阈值大于零,`onUnavailable.action` 明确。 6. 启用的 Upstream 有正数 `pool.maxSize`、并发和 Fetch 限制。 7. `allocationSafetyMargin < ttl`。 8. `fetch.maxTotal == 0` 或 `fetch.maxTotal >= pool.maxSize`。 diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 39a174c..6e82ab7 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -272,6 +272,9 @@ sequenceDiagram Upstream 的 Empty 事实全局共享;每条 Routing 独立 CAS 当前索引。多个并发 协程只能有一个成功从 A 切到 B,其他协程读取新版本,不会再切到 C。 +Sequential 至少配置两个 Upstream;列表耗尽后的默认行为是 `stop`,`loop` 和 +`stayLast` 必须显式配置。disabled Upstream 不参与新分配,其运行时跳过与权威 +游标持久化仍由后续 Routing Runtime 完成。 ## 10. Exclusive Extraction diff --git a/docs/design/product-design.md b/docs/design/product-design.md index bf7863b..04b7828 100644 --- a/docs/design/product-design.md +++ b/docs/design/product-design.md @@ -58,6 +58,9 @@ Gateway Worker 只读取本地不可变快照并维护本地容量计数。供 ### 4.3 Sequential 切换 +Sequential 至少配置两个 Upstream,初始使用列表第一项。列表耗尽时默认 `stop`, +也可显式选择 `loop` 或 `stayLast`。 + 1. Provider 响应成功且解析成功,但合法候选为零,才累计 Empty。 2. 网络、认证、HTTP、模板或解析失败只计 Error。 3. 全部候选均重复时计 DuplicateOnly,并重置连续 Empty。 diff --git a/docs/development/implementation-plan.md b/docs/development/implementation-plan.md index a332bd9..a792278 100644 --- a/docs/development/implementation-plan.md +++ b/docs/development/implementation-plan.md @@ -99,6 +99,10 @@ test/{fixtures,integration,e2e,load}/ - [ ] Cover four-empty-then-success, five-empty, A-to-B-only, disabled references, end behavior, and explicit onUnavailable. +当前进度(2026-07-29):领域构造器与严格配置已统一 Sequential 至少两个 +Upstream、`endBehavior` 默认 `stop`,并覆盖列表末端停止;disabled candidate、 +跨实例恢复和 `onUnavailable` 运行链仍待完成。 + ## Task 5: Provider Fetch Classification and Scheduling **Files:** `internal/controller/provider/*.go`, `internal/domain/upstream/*.go`, tests diff --git a/findings.md b/findings.md index b7c502e..89fd45e 100644 --- a/findings.md +++ b/findings.md @@ -115,8 +115,8 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两 503;wait/direct 和默认 reject 尚未形成运行时闭环。 - Sequential 当前版本 CAS 只在单进程内生效,构造时总从第一个 Upstream 开始; PostgreSQL 管理态尚未接入游标恢复和跨实例 CAS。 -- 单 Upstream Sequential、默认 endBehavior、disabled candidate 的最终语义与 - 现有代码/需求存在差异,进入设计确认项,不在测试中固化未确认行为。 +- 对话最终语义已确认并落实:单 Upstream Sequential 启动校验失败, + `endBehavior` 省略时默认 `stop`;disabled candidate 的运行时推进仍待实现。 ## Proxy Capacity 验收审计(2026-07-29) @@ -131,8 +131,8 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两 ## Routing Runtime 设计输入(2026-07-29) -- `对话内容.md` 最后一个明确建议将 Sequential `endBehavior` 默认设为 `stop`; - 当前领域构造器默认 `stayLast`,配置参考只展示显式值,三者需要统一。 +- `对话内容.md` 最后一个明确结论将 Sequential `endBehavior` 默认设为 `stop`; + 领域构造器、配置校验与配置参考现已统一,且拒绝单 Upstream Sequential。 - 产品安全默认已确定为 `onUnavailable=reject`,但当前严格配置要求字段必填; 需要确认省略时自动补 reject,还是继续拒绝启动。 - disabled Upstream 的确定语义是停止 Fetch、健康检查和新分配,已有 Proxy/连接 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7a3dfb4..99122e2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -35,7 +35,7 @@ routing: - name: extract enabled: true purpose: extract - upstreams: [provider-a] + upstreams: [provider-a, provider-b] strategy: type: sequential switchAfterEmptyFetch: 5 @@ -43,7 +43,7 @@ routing: onUnavailable: action: reject upstreams: - provider-a: + provider-a: &valid-upstream enabled: true exposure: [gateway, extract] provider: @@ -78,6 +78,7 @@ upstreams: maxAttempts: 2 maxConsecutiveFailures: 3 urls: [http://connect.rom.miui.com/generate_204] + provider-b: *valid-upstream ` func TestLoadStrictValidConfiguration(t *testing.T) { @@ -133,7 +134,7 @@ func TestValidateRejectsUnprotectedPublicListener(t *testing.T) { } func TestValidateRejectsMissingUpstreamReference(t *testing.T) { - broken := strings.Replace(validConfig, "upstreams: [provider-a]", "upstreams: [missing]", 1) + broken := strings.Replace(validConfig, "upstreams: [provider-a, provider-b]", "upstreams: [provider-a, missing]", 1) _, err := Load(strings.NewReader(broken)) if err == nil || !strings.Contains(err.Error(), "missing") { t.Fatalf("Load() error = %v, want missing upstream error", err) @@ -443,6 +444,13 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) { }, want: "endBehavior", }, + { + name: "sequential requires two upstreams", + mutate: func(cfg *Config) { + cfg.Routing[0].Upstreams = []string{"provider-a"} + }, + want: "at least two upstreams", + }, { name: "weighted strategy missing weight", mutate: func(cfg *Config) { @@ -456,9 +464,10 @@ func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) { cfg.Routing[0].Strategy = Strategy{Type: "weighted", Weights: map[string]int{ "provider-a": 1, "provider-b": 1, + "provider-c": 1, }} }, - want: "provider-b", + want: "provider-c", }, { name: "weighted strategy nonpositive weight", diff --git a/internal/config/validate.go b/internal/config/validate.go index a80ae0d..5211a5a 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -187,6 +187,9 @@ func validateStrategy(scope string, upstreams []string, strategy Strategy) error return err } if strategy.Type == "sequential" { + if len(upstreams) < 2 { + return fmt.Errorf("validate %s strategy: sequential requires at least two upstreams", scope) + } if err := requirePositive(scope+" strategy.switchAfterEmptyFetch", strategy.SwitchAfterEmptyFetch); err != nil { return err } diff --git a/internal/domain/routing/routing_test.go b/internal/domain/routing/routing_test.go index be82142..3e2c24c 100644 --- a/internal/domain/routing/routing_test.go +++ b/internal/domain/routing/routing_test.go @@ -96,6 +96,33 @@ func TestSequentialValidFetchResetsEmptyCount(t *testing.T) { } } +func TestSequentialDefaultsToStopAfterLastUpstream(t *testing.T) { + sequence, err := NewSequential([]string{"a", "b"}, 1) + if err != nil { + t.Fatalf("NewSequential(): %v", err) + } + if !sequence.ObserveEmpty("a") || sequence.Current() != "b" { + t.Fatalf("first transition current = %q, want b", sequence.Current()) + } + if !sequence.ObserveEmpty("b") { + t.Fatal("ObserveEmpty(b) = false, want transition to stopped") + } + if current, available, _ := sequence.CurrentSelection(); available || current != "" { + t.Fatalf("CurrentSelection() = %q, %v; want stopped", current, available) + } +} + +func TestSequentialRejectsSingleUpstream(t *testing.T) { + if _, err := NewSequential([]string{"a"}, 1); err == nil { + t.Fatal("NewSequential(single upstream) error = nil") + } + if _, err := NewSequentialWithState( + []string{"a"}, 1, EndStop, NewUpstreamEmptyState(), + ); err == nil { + t.Fatal("NewSequentialWithState(single upstream) error = nil") + } +} + func TestSequentialSharesUpstreamEmptyStateAcrossRoutingCursors(t *testing.T) { empty := NewUpstreamEmptyState() first, err := NewSequentialWithState([]string{"a", "b"}, 5, EndStayLast, empty) @@ -122,15 +149,18 @@ func TestSequentialSharesUpstreamEmptyStateAcrossRoutingCursors(t *testing.T) { } func TestSequentialStopEndBehaviorHasNoCurrentSelection(t *testing.T) { - sequence, err := NewSequentialWithState([]string{"a"}, 1, EndStop, NewUpstreamEmptyState()) + sequence, err := NewSequentialWithState([]string{"a", "b"}, 1, EndStop, NewUpstreamEmptyState()) if err != nil { t.Fatalf("NewSequentialWithState(): %v", err) } if !sequence.ObserveEmpty("a") { - t.Fatal("ObserveEmpty() = false, want transition to stopped") + t.Fatal("ObserveEmpty(a) = false, want transition to b") } - if current, ok, version := sequence.CurrentSelection(); ok || current != "" || version != 2 { - t.Fatalf("CurrentSelection() = %q, %v, %d; want stopped version 2", current, ok, version) + if !sequence.ObserveEmpty("b") { + t.Fatal("ObserveEmpty(b) = false, want transition to stopped") + } + if current, ok, version := sequence.CurrentSelection(); ok || current != "" || version != 3 { + t.Fatalf("CurrentSelection() = %q, %v, %d; want stopped version 3", current, ok, version) } } diff --git a/internal/domain/routing/sequential.go b/internal/domain/routing/sequential.go index 28afe9c..7e347bb 100644 --- a/internal/domain/routing/sequential.go +++ b/internal/domain/routing/sequential.go @@ -132,7 +132,7 @@ type Sequential struct { } func NewSequential(upstreams []string, threshold int) (*Sequential, error) { - return NewSequentialWithState(upstreams, threshold, EndStayLast, NewUpstreamEmptyState()) + return NewSequentialWithState(upstreams, threshold, EndStop, NewUpstreamEmptyState()) } func NewSequentialWithState( @@ -141,14 +141,14 @@ func NewSequentialWithState( end EndBehavior, empty *UpstreamEmptyState, ) (*Sequential, error) { - if len(upstreams) == 0 { - return nil, fmt.Errorf("sequential strategy requires at least one upstream") + if len(upstreams) < 2 { + return nil, fmt.Errorf("sequential strategy requires at least two upstreams") } if threshold <= 0 { return nil, fmt.Errorf("sequential threshold must be greater than zero") } if end == "" { - end = EndStayLast + end = EndStop } if end != EndStayLast && end != EndStop && end != EndLoop { return nil, fmt.Errorf("sequential end behavior %q is invalid", end) diff --git a/progress.md b/progress.md index ff1513f..859c45a 100644 --- a/progress.md +++ b/progress.md @@ -2,6 +2,11 @@ ## 2026-07-29 +- 根据对话最终定稿统一 Sequential:至少两个 Upstream,省略 `endBehavior` 时 + 默认 `stop`;新增领域和严格配置回归测试。disabled candidate、跨实例游标和 + `onUnavailable` 请求链仍未提前标记完成。 +- 一次并行读取误用了不存在的 `routing/sequential_test.go`,另一次误用了旧 ADR + 文件名;已改用 `routing_test.go` 和 `006-postgresql-admin-state.md`,未重复命令。 - 新增文档契约测试,递归验证 README、docs、deploy、diagrams 的相对链接,并 拒绝公开指南引用不存在的具体 Go 命令目标;修正配置参考中尚未实现的 `cmd/proxy-controller` 启动命令,改为当前真实可执行的严格配置校验工具。