feat: consume gateway routing snapshots
Some checks are pending
ci / proto (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run

This commit is contained in:
youfak 2026-07-31 15:36:56 +08:00
parent bc94f4dbf1
commit 1846c98e09
13 changed files with 371 additions and 29 deletions

View File

@ -44,7 +44,9 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
目的地址保护、本地快照存储、容量调度和 Worker 控制面 Register/Watch/ACK/
Runtime 会话组件已有实现与定向测试。`SessionSupervisor` 会为可恢复控制面中断执行
有界退避重连。Controller 可向 Worker 下发无凭据引用的已归属 Proxy 及 Gateway
Routing 快照;`proxy-gateway` 命令、凭据分发和 Routing Snapshot 消费仍待装配。
Routing 快照。Gateway 会将 Routing 与 Proxy 原子编译为同一内存 View并只按当前未过期
View 匹配请求;`proxy-gateway` 命令、凭据分发,以及策略选择与 `onUnavailable` 派发语义
仍待装配。
- **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
CIDR、可信代理、严格请求解析和敏感信息最小化。
@ -83,8 +85,8 @@ flowchart LR
Redis 会话栅栏。
- **部分完成**Gateway 传输与调度组件、Snapshot 本地存储、Worker ownership
与运行态领域组件、Docker Compose/Kubernetes 静态部署清单和 protobuf 契约。
- **待完成**:带凭据 Proxy 分发、Gateway Routing Snapshot 消费、Gateway 进程装配、
Outcome 上报、Checker 调度与健康状态链、完整 Routing 运行链,以及 loadgen 和代表性
- **待完成**:带凭据 Proxy 分发、Gateway 进程装配、Routing 策略选择与
`onUnavailable`Outcome 上报、Checker 调度与健康状态链,以及 loadgen 和代表性
集群压测。
检查项数量不等于生产就绪度。静态部署清单与 protobuf descriptor 验证也不代表

View File

@ -30,9 +30,9 @@ Gateway 校验后 ACK 并开始 Runtime 心跳。Controller 会从 Redis 的有
索引构建无凭据引用的已归属 Proxy 内容,并将租约到期收紧到 Proxy 的
`usable_until`;带凭据 Proxy 会 fail-closed直到凭据材料分发完成。完整 Snapshot
已从配置原始顺序和 Admin 当前状态合成 Gateway Routing并与 Proxy 一起纳入 checksum
Gateway 对该 Routing payload 的运行时消费、增量、Gateway 进程装配、Outcome 与 Checker
闭环尚未实现。`ReportOutcomes` 仍明确返回 `Unimplemented``100,000 QPS` 仍是未验证的
设计目标。
Gateway 已将该 payload 编译并原子发布到与 Proxy 相同版本的本地 View动态 Router 只匹配
当前未过期 View。策略选择、`onUnavailable`、增量、Gateway 进程装配、Outcome 与 Checker
闭环尚未实现。`ReportOutcomes` 仍明确返回 `Unimplemented``100,000 QPS` 仍是未验证的设计目标。
`WatchSnapshots` 建立时校验当前 session每次签发快照引用时也把 `session_id`
交给 Redis 原子校验。重复 Register 会同时清除旧 Runtime 和已签发引用,因此迟到的

View File

@ -218,8 +218,9 @@ Snapshot 并保持连接Gateway 已具备 Register/Watch/ACK/Runtime 会话
重注册会清除旧引用,避免迟到 Stream 覆盖新 session。Worker 服务端会在最近完整
Snapshot 的 `valid_until` 到达时结束流;公用 `SessionSupervisor` 已为 Gateway 调用方
提供可恢复错误的有界指数退避重连,并在参数/认证/协议错误时停止。Gateway Routing
payload 已按配置顺序和 Admin revision/current 状态发布并覆盖 checksum其 Gateway
运行时消费、凭据分发、Gateway 命令与 Outcome 上报仍未实现。
payload 已按配置顺序和 Admin revision/current 状态发布并覆盖 checksumGateway 已将其与
Proxy 原子编译为同版本 View动态 Router 只匹配该未过期 View。策略选择、`onUnavailable`、
凭据分发、Gateway 命令与 Outcome 上报仍未实现。
已新增公用 `domain/activitypool` 契约及并发安全内存参考实现Provider
Reconciler 通过 `UpsertFetched` 写入带供应商 TTL 和分配安全余量的批次;已覆盖

View File

@ -99,9 +99,9 @@ CI 已配置 Linux race job。PostgreSQL 18 和 Redis 8.2 的隔离 Adapter fixt
5. Worker Register/ACK/Runtime gRPC 接收路径、Redis 服务端 TTL 会话生命周期、
SPIFFE mTLS 校验和 Controller 生命周期接线已完成Redis Provider Leader、
分布式请求额度、Distribution Client 限制和 Provider 状态丢失重建已完成。
6. Worker 基础网络快照流、无凭据引用 Proxy/Gateway Routing payload 和 Gateway
Snapshot 客户端已完成Gateway Routing 运行时消费、凭据分发与 Outcome 上报仍待
完成。Redis ownership drain/ACK/过期回收及按 Worker 的可下发索引已完成。
6. Worker 基础网络快照流、无凭据引用 Proxy/Gateway Routing payload、Gateway Snapshot
客户端及同版本 Routing 编译/动态匹配已完成;策略选择、`onUnavailable`、凭据分发与
Outcome 上报仍待完成。Redis ownership drain/ACK/过期回收及按 Worker 的可下发索引已完成。
7. Checker 调度、探测器和健康 reducer。
8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。

View File

@ -7,7 +7,7 @@
| ID | 最终需求 | 来源 | 验证证据 |
|---|---|---|---|
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离Controller 已运行 Worker Register/Watch/ACK/Runtime gRPC并发布无凭据 Proxy/Gateway Routing 完整快照Gateway 已有会话客户端组件Gateway/Checker/Loadgen 构建产物及 Gateway Routing 运行时消费待实现 |
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离Controller 已运行 Worker Register/Watch/ACK/Runtime gRPC并发布无凭据 Proxy/Gateway Routing 完整快照Gateway 已将同版本 Routing 编译为动态匹配 View。Gateway/Checker/Loadgen 构建产物、策略派发与 `onUnavailable` 待实现 |
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Snapshot/Dispatch 及依赖边界已验证;完整 Gateway 进程与代表性性能剖析待完成 |
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Controller 命令已装配 Distribution/Admin/Metrics 三个独立监听及联动停机Gateway 生产入口待装配 |
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Redis Leader、动态 Provider Supervisor 与 Bootstrap 生产装配已完成Admin disable/reload 驱动取消替换,多副本按权威 HMAC 指纹和 revision 栅栏收敛并拒绝旧配置换主Routing 切换到 Drain 的编排待完成 |

View File

@ -15,6 +15,30 @@ const (
ActionReject Action = "reject"
)
type StrategyType string
const (
StrategySequential StrategyType = "sequential"
StrategyRandom StrategyType = "random"
StrategyRoundRobin StrategyType = "roundRobin"
StrategyWeighted StrategyType = "weighted"
StrategyLeastConnections StrategyType = "leastConnections"
)
type Strategy struct {
Type StrategyType
CurrentUpstream string
Weights map[string]uint32
}
type OnUnavailableAction string
const (
OnUnavailableReject OnUnavailableAction = "reject"
OnUnavailableWait OnUnavailableAction = "wait"
OnUnavailableDirect OnUnavailableAction = "direct"
)
type Match struct {
HostRegex string
Methods []string
@ -27,6 +51,8 @@ type Rule struct {
Match Match
Upstreams []string
Action Action
Strategy Strategy
OnUnavailable OnUnavailableAction
}
type Request struct {
@ -106,6 +132,12 @@ func cloneRule(source Rule) Rule {
cloned.Match.Headers[name] = value
}
}
if source.Strategy.Weights != nil {
cloned.Strategy.Weights = make(map[string]uint32, len(source.Strategy.Weights))
for upstream, weight := range source.Strategy.Weights {
cloned.Strategy.Weights[upstream] = weight
}
}
return cloned
}

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"math"
"time"
@ -11,6 +12,7 @@ import (
controlplanev1 "proxy-pool/gen/controlplane/v1"
"proxy-pool/internal/controlplane/snapshotwire"
proxyDomain "proxy-pool/internal/domain/proxy"
routingDomain "proxy-pool/internal/domain/routing"
"proxy-pool/internal/domain/workerruntime"
"proxy-pool/internal/gateway/snapshot"
@ -123,14 +125,147 @@ func (watcher *SnapshotWatcher) applyFull(full *controlplanev1.WorkerSnapshot) e
if err != nil {
return err
}
routes, err := wireRouting(full.GetRouting())
if err != nil {
return err
}
envelope := snapshot.Envelope{
ClusterID: watcher.options.ClusterID, WorkerID: watcher.options.WorkerID,
Epoch: full.GetOwnershipEpoch(), Version: full.GetVersion(), Full: true, ValidUntil: validUntil, Proxies: proxies,
Epoch: full.GetOwnershipEpoch(), Version: full.GetVersion(), Full: true, ValidUntil: validUntil,
Proxies: proxies, Routing: routes,
}
envelope.Checksum = snapshot.Checksum(proxies)
envelope.Checksum = snapshot.ChecksumWithRouting(proxies, routes)
return watcher.store.Apply(envelope)
}
func wireRouting(source []*controlplanev1.RoutingRule) ([]routingDomain.Rule, error) {
result := make([]routingDomain.Rule, 0, len(source))
names := make(map[string]struct{}, len(source))
for _, rule := range source {
if rule == nil || !workerruntime.ValidIdentifier(rule.GetName()) {
return nil, ErrInvalidSnapshotWatcher
}
if _, duplicate := names[rule.GetName()]; duplicate {
return nil, ErrInvalidSnapshotWatcher
}
names[rule.GetName()] = struct{}{}
if !rule.GetEnabled() {
continue
}
upstreams, err := wireRoutingUpstreams(rule.GetUpstreams())
if err != nil {
return nil, err
}
strategy, err := wireRoutingStrategy(rule.GetStrategy(), upstreams)
if err != nil {
return nil, err
}
action, err := wireUnavailableAction(rule.GetOnUnavailable())
if err != nil {
return nil, err
}
result = append(result, routingDomain.Rule{
Name: rule.GetName(),
Match: routingDomain.Match{
HostRegex: rule.GetHostRegex(), Methods: append([]string(nil), rule.GetMethods()...),
PathRegex: rule.GetPathRegex(), Headers: cloneRoutingHeaders(rule.GetHeaders()),
},
Upstreams: upstreams, Action: routingDomain.ActionProxy, Strategy: strategy, OnUnavailable: action,
})
}
return result, nil
}
func wireRoutingUpstreams(source []string) ([]string, error) {
if len(source) == 0 {
return nil, ErrInvalidSnapshotWatcher
}
result := make([]string, len(source))
seen := make(map[string]struct{}, len(source))
for index, upstream := range source {
if !workerruntime.ValidIdentifier(upstream) {
return nil, ErrInvalidSnapshotWatcher
}
if _, duplicate := seen[upstream]; duplicate {
return nil, ErrInvalidSnapshotWatcher
}
seen[upstream] = struct{}{}
result[index] = upstream
}
return result, nil
}
func wireRoutingStrategy(source *controlplanev1.RoutingStrategy, upstreams []string) (routingDomain.Strategy, error) {
if source == nil {
return routingDomain.Strategy{}, ErrInvalidSnapshotWatcher
}
strategy := routingDomain.Strategy{CurrentUpstream: source.GetCurrentUpstream()}
switch source.GetType() {
case controlplanev1.StrategyType_STRATEGY_TYPE_SEQUENTIAL:
strategy.Type = routingDomain.StrategySequential
if !containsUpstream(upstreams, strategy.CurrentUpstream) {
return routingDomain.Strategy{}, ErrInvalidSnapshotWatcher
}
case controlplanev1.StrategyType_STRATEGY_TYPE_RANDOM:
strategy.Type = routingDomain.StrategyRandom
case controlplanev1.StrategyType_STRATEGY_TYPE_ROUND_ROBIN:
strategy.Type = routingDomain.StrategyRoundRobin
case controlplanev1.StrategyType_STRATEGY_TYPE_WEIGHTED:
strategy.Type = routingDomain.StrategyWeighted
strategy.Weights = make(map[string]uint32, len(upstreams))
for _, upstream := range upstreams {
weight, exists := source.GetWeights()[upstream]
if !exists || weight == 0 {
return routingDomain.Strategy{}, ErrInvalidSnapshotWatcher
}
strategy.Weights[upstream] = weight
}
for upstream := range source.GetWeights() {
if !containsUpstream(upstreams, upstream) {
return routingDomain.Strategy{}, ErrInvalidSnapshotWatcher
}
}
case controlplanev1.StrategyType_STRATEGY_TYPE_LEAST_CONNECTIONS:
strategy.Type = routingDomain.StrategyLeastConnections
default:
return routingDomain.Strategy{}, ErrInvalidSnapshotWatcher
}
return strategy, nil
}
func wireUnavailableAction(action controlplanev1.UnavailableAction) (routingDomain.OnUnavailableAction, error) {
switch action {
case controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_REJECT:
return routingDomain.OnUnavailableReject, nil
case controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_WAIT:
return routingDomain.OnUnavailableWait, nil
case controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_DIRECT:
return routingDomain.OnUnavailableDirect, nil
default:
return "", fmt.Errorf("%w: unsupported unavailable action", ErrInvalidSnapshotWatcher)
}
}
func cloneRoutingHeaders(source map[string]string) map[string]string {
if source == nil {
return nil
}
result := make(map[string]string, len(source))
for name, value := range source {
result[name] = value
}
return result
}
func containsUpstream(upstreams []string, target string) bool {
for _, upstream := range upstreams {
if upstream == target {
return true
}
}
return false
}
func wireProxies(source []*controlplanev1.OwnedProxy) ([]proxyDomain.Proxy, error) {
proxies := make([]proxyDomain.Proxy, len(source))
for index, item := range source {

View File

@ -9,6 +9,7 @@ import (
controlplanev1 "proxy-pool/gen/controlplane/v1"
"proxy-pool/internal/controlplane/snapshotwire"
"proxy-pool/internal/domain/routing"
"proxy-pool/internal/gateway/snapshot"
"google.golang.org/protobuf/types/known/emptypb"
@ -23,6 +24,11 @@ func TestSnapshotWatcherAppliesVerifiedFullSnapshotAndAcknowledges(t *testing.T)
Id: "proxy-a", Upstream: "upstream-a", Protocol: controlplanev1.ProxyProtocol_PROXY_PROTOCOL_HTTP,
Host: "192.0.2.10", Port: 8080, MaxConcurrency: 3, ExpiresAt: timestamppb.New(time.Now().Add(time.Minute)),
}},
Routing: []*controlplanev1.RoutingRule{{
Name: "gateway-api", Enabled: true, HostRegex: `^api\.example\.test$`, Upstreams: []string{"upstream-a"},
Strategy: &controlplanev1.RoutingStrategy{Type: controlplanev1.StrategyType_STRATEGY_TYPE_RANDOM},
OnUnavailable: controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_REJECT,
}},
}
setSnapshotChecksum(t, full)
client := &snapshotClientStub{stream: &snapshotStreamStub{values: []*controlplanev1.SnapshotEnvelope{{Payload: &controlplanev1.SnapshotEnvelope_Full{Full: full}}}}}
@ -38,6 +44,10 @@ func TestSnapshotWatcherAppliesVerifiedFullSnapshotAndAcknowledges(t *testing.T)
!view.ValidUntil.Equal(full.GetValidUntil().AsTime()) {
t.Fatalf("snapshot view = %+v", view)
}
matched, ok := view.MatchRouting(routing.Request{Host: "api.example.test", Method: "GET", Path: "/"})
if !ok || matched.Name != "gateway-api" || matched.Strategy.Type != routing.StrategyRandom {
t.Fatalf("snapshot routing = %+v, %v", matched, ok)
}
if client.watch.GetSessionId() != "session-a" || client.ack.GetVersion() != 1 || !client.ack.GetApplied() || string(client.ack.GetChecksum()) != string(full.GetChecksum()) {
t.Fatalf("watch=%+v ack=%+v", client.watch, client.ack)
}

View File

@ -6,12 +6,16 @@ import (
"time"
proxyDomain "proxy-pool/internal/domain/proxy"
"proxy-pool/internal/domain/routing"
"proxy-pool/internal/gateway/snapshot"
)
var ErrNoCandidate = errors.New("no local proxy candidate is available")
type Request struct {
RoutingName string
Strategy routing.Strategy
OnUnavailable routing.OnUnavailableAction
Now time.Time
Scheme proxyDomain.Scheme
Upstreams []string

View File

@ -6,9 +6,11 @@ import (
"net"
"net/http"
"strings"
"time"
"proxy-pool/internal/domain/routing"
"proxy-pool/internal/gateway/dispatch"
"proxy-pool/internal/gateway/snapshot"
)
var (
@ -29,6 +31,37 @@ func (router *RulesRouter) Route(request *http.Request) (dispatch.Request, error
if router == nil || router.rules == nil || request == nil {
return dispatch.Request{}, ErrRouteNotFound
}
return routeWithMatcher(request, router.rules.Match)
}
// SnapshotRouter always matches against the rules stored with the current
// proxy snapshot so a request cannot combine routing from one version with
// proxy ownership from another.
type SnapshotRouter struct {
store *snapshot.Store
}
func NewSnapshotRouter(store *snapshot.Store) *SnapshotRouter {
return &SnapshotRouter{store: store}
}
func (router *SnapshotRouter) Route(request *http.Request) (dispatch.Request, error) {
if router == nil || router.store == nil || request == nil {
return dispatch.Request{}, ErrRouteNotFound
}
view := router.store.Current()
if view == nil || (!view.ValidUntil.IsZero() && !view.ValidUntil.After(time.Now().UTC())) {
return dispatch.Request{}, ErrRouteNotFound
}
return routeWithMatcher(request, view.MatchRouting)
}
type routeMatcher func(routing.Request) (routing.Rule, bool)
func routeWithMatcher(request *http.Request, match routeMatcher) (dispatch.Request, error) {
if request == nil || match == nil {
return dispatch.Request{}, ErrRouteNotFound
}
host := request.Host
if request.URL != nil && request.URL.Hostname() != "" {
host = request.URL.Hostname()
@ -43,7 +76,7 @@ func (router *RulesRouter) Route(request *http.Request) (dispatch.Request, error
for name := range request.Header {
headers[name] = request.Header.Get(name)
}
matched, ok := router.rules.Match(routing.Request{
matched, ok := match(routing.Request{
Host: strings.ToLower(strings.TrimSuffix(host, ".")),
Method: request.Method,
Path: path,
@ -54,7 +87,10 @@ func (router *RulesRouter) Route(request *http.Request) (dispatch.Request, error
}
switch matched.Action {
case routing.ActionProxy:
return dispatch.Request{Upstreams: append([]string(nil), matched.Upstreams...)}, nil
return dispatch.Request{
RoutingName: matched.Name, Upstreams: append([]string(nil), matched.Upstreams...),
Strategy: matched.Strategy, OnUnavailable: matched.OnUnavailable,
}, nil
case routing.ActionReject:
return dispatch.Request{}, fmt.Errorf("%w: %s", ErrRouteRejected, matched.Name)
case routing.ActionDirect:

View File

@ -6,8 +6,10 @@ import (
"net/http/httptest"
"reflect"
"testing"
"time"
"proxy-pool/internal/domain/routing"
"proxy-pool/internal/gateway/snapshot"
)
func TestRulesRouterReturnsMatchedUpstreams(t *testing.T) {
@ -54,3 +56,31 @@ func TestRulesRouterRejectsExplicitRejectAndMissingRoute(t *testing.T) {
t.Fatalf("missing Route() error = %v", err)
}
}
func TestSnapshotRouterMatchesRulesFromCurrentSnapshot(t *testing.T) {
store := snapshot.NewStore("cluster-a", "worker-a")
envelope := snapshot.Envelope{
ClusterID: "cluster-a", WorkerID: "worker-a", Epoch: 1, Version: 1, Full: true,
ValidUntil: time.Now().Add(time.Minute),
Routing: []routing.Rule{{
Name: "gateway-api", Match: routing.Match{HostRegex: `^api\.example\.test$`},
Upstreams: []string{"provider-a"}, Action: routing.ActionProxy,
Strategy: routing.Strategy{Type: routing.StrategyWeighted, Weights: map[string]uint32{"provider-a": 3}},
OnUnavailable: routing.OnUnavailableReject,
}},
}
envelope.Checksum = snapshot.ChecksumWithRouting(envelope.Proxies, envelope.Routing)
if err := store.Apply(envelope); err != nil {
t.Fatalf("Apply(): %v", err)
}
router := NewSnapshotRouter(store)
result, err := router.Route(httptest.NewRequest(http.MethodGet, "http://api.example.test/items", nil))
if err != nil {
t.Fatalf("Route(): %v", err)
}
if result.RoutingName != "gateway-api" || result.Strategy.Type != routing.StrategyWeighted ||
!reflect.DeepEqual(result.Strategy.Weights, map[string]uint32{"provider-a": 3}) {
t.Fatalf("Route() = %+v", result)
}
}

View File

@ -13,6 +13,7 @@ import (
"time"
proxyDomain "proxy-pool/internal/domain/proxy"
"proxy-pool/internal/domain/routing"
"proxy-pool/internal/domain/workerruntime"
)
@ -100,6 +101,7 @@ type Envelope struct {
Checksum string
ValidUntil time.Time
Proxies []proxyDomain.Proxy
Routing []routing.Rule
}
type Entry struct {
@ -115,6 +117,7 @@ type View struct {
Checksum string
ValidUntil time.Time
Entries []Entry
routing *routing.RuleSet
all []int
byScheme map[proxyDomain.Scheme][]int
@ -226,9 +229,13 @@ func (s *Store) Apply(envelope Envelope) error {
if !envelope.Full || envelope.Epoch == 0 || envelope.Version == 0 {
return ErrResyncRequired
}
if envelope.Checksum != Checksum(envelope.Proxies) {
if envelope.Checksum != ChecksumWithRouting(envelope.Proxies, envelope.Routing) {
return ErrChecksumMismatch
}
routes, err := routing.Compile(envelope.Routing)
if err != nil {
return fmt.Errorf("compile snapshot routing: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
@ -315,12 +322,22 @@ func (s *Store) Apply(envelope Envelope) error {
Checksum: envelope.Checksum,
ValidUntil: envelope.ValidUntil.UTC(),
Entries: entries,
routing: routes,
}
next.buildIndexes()
s.current.Store(next)
return nil
}
// MatchRouting matches a request against the immutable rules published with
// this proxy view. A View is the atomic consistency boundary for both sets.
func (v *View) MatchRouting(request routing.Request) (routing.Rule, bool) {
if v == nil || v.routing == nil {
return routing.Rule{}, false
}
return v.routing.Match(request)
}
func (s *Store) reclaimableRuntimes(nextProxyIDs map[string]struct{}) map[string]*runtimeRegistration {
result := make(map[string]*runtimeRegistration)
for proxyID, registration := range s.runtimes {
@ -382,8 +399,18 @@ func (s Selection) EntryAt(index int) (Entry, bool) {
}
func Checksum(proxies []proxyDomain.Proxy) string {
canonical := cloneAndSort(proxies)
encoded, err := json.Marshal(canonical)
return ChecksumWithRouting(proxies, nil)
}
func ChecksumWithRouting(proxies []proxyDomain.Proxy, rules []routing.Rule) string {
payload := struct {
Proxies []proxyDomain.Proxy `json:"proxies"`
Routing []routing.Rule `json:"routing"`
}{
Proxies: cloneAndSort(proxies),
Routing: cloneRoutingRules(rules),
}
encoded, err := json.Marshal(payload)
if err != nil {
panic(fmt.Sprintf("encode snapshot checksum: %v", err))
}
@ -391,6 +418,28 @@ func Checksum(proxies []proxyDomain.Proxy) string {
return hex.EncodeToString(digest[:])
}
func cloneRoutingRules(source []routing.Rule) []routing.Rule {
result := make([]routing.Rule, len(source))
for index, rule := range source {
result[index] = rule
result[index].Match.Methods = append([]string(nil), rule.Match.Methods...)
result[index].Upstreams = append([]string(nil), rule.Upstreams...)
if rule.Match.Headers != nil {
result[index].Match.Headers = make(map[string]string, len(rule.Match.Headers))
for name, value := range rule.Match.Headers {
result[index].Match.Headers[name] = value
}
}
if rule.Strategy.Weights != nil {
result[index].Strategy.Weights = make(map[string]uint32, len(rule.Strategy.Weights))
for upstream, weight := range rule.Strategy.Weights {
result[index].Strategy.Weights[upstream] = weight
}
}
}
return result
}
func cloneAndSort(source []proxyDomain.Proxy) []proxyDomain.Proxy {
cloned := make([]proxyDomain.Proxy, len(source))
for index, descriptor := range source {

View File

@ -8,6 +8,7 @@ import (
"time"
proxyDomain "proxy-pool/internal/domain/proxy"
"proxy-pool/internal/domain/routing"
"proxy-pool/internal/domain/workerruntime"
)
@ -83,6 +84,48 @@ func TestStoreRejectsWrongWorkerVersionGapAndChecksum(t *testing.T) {
}
}
func TestStorePublishesRoutingWithTheSameSnapshotVersion(t *testing.T) {
store := NewStore("cluster-a", "worker-a")
routes := []routing.Rule{{
Name: "gateway-api",
Match: routing.Match{HostRegex: `^api\.example\.test$`},
Upstreams: []string{"provider-a"},
Action: routing.ActionProxy,
Strategy: routing.Strategy{Type: routing.StrategyRandom},
OnUnavailable: routing.OnUnavailableReject,
}}
envelope := Envelope{
ClusterID: "cluster-a", WorkerID: "worker-a", Epoch: 1, Version: 1, Full: true,
Routing: routes,
}
envelope.Checksum = ChecksumWithRouting(envelope.Proxies, envelope.Routing)
if err := store.Apply(envelope); err != nil {
t.Fatalf("Apply(): %v", err)
}
view := store.Current()
matched, ok := view.MatchRouting(routing.Request{Host: "api.example.test", Method: "GET", Path: "/"})
if !ok || matched.Name != "gateway-api" || matched.Strategy.Type != routing.StrategyRandom ||
!reflect.DeepEqual(matched.Upstreams, []string{"provider-a"}) {
t.Fatalf("MatchRouting() = %+v, %v", matched, ok)
}
changedRouting := envelope
changedRouting.Version = 2
changedRouting.Routing = []routing.Rule{{
Name: "other", Match: routing.Match{HostRegex: `^other\.example\.test$`},
Upstreams: []string{"provider-b"}, Action: routing.ActionProxy,
Strategy: routing.Strategy{Type: routing.StrategyRoundRobin}, OnUnavailable: routing.OnUnavailableReject,
}}
changedRouting.Checksum = Checksum(envelope.Proxies)
if err := store.Apply(changedRouting); !errors.Is(err, ErrChecksumMismatch) {
t.Fatalf("Apply(changed routing with old checksum) error = %v, want ErrChecksumMismatch", err)
}
if current := store.Current(); current.Version != 1 {
t.Fatalf("Current().Version = %d, want unchanged version 1", current.Version)
}
}
func TestViewSelectFiltersBySchemeUpstreamTagAndExclude(t *testing.T) {
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
expiresSoon := now.Add(5 * time.Second)