feat: supervise gateway control plane sessions
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:07:51 +08:00
parent c2d0c08fcb
commit 9641edd582
7 changed files with 309 additions and 9 deletions

View File

@ -42,8 +42,9 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
审计与 Outbox不保存 Proxy 明细或逐次提取记录。
- **Gateway 组件**HTTP 正向代理、HTTPS CONNECT、双向 Tunnel、重试、超时、
目的地址保护、本地快照存储、容量调度和 Worker 控制面 Register/Watch/ACK/
Runtime 会话组件已有实现与定向测试。Controller 可向 Worker 下发无凭据引用的
已归属 Proxy 快照;`proxy-gateway` 命令、凭据分发和 Routing Snapshot 发布仍待装配。
Runtime 会话组件已有实现与定向测试。`SessionSupervisor` 会为可恢复控制面中断执行
有界退避重连。Controller 可向 Worker 下发无凭据引用的已归属 Proxy 快照;
`proxy-gateway` 命令、凭据分发和 Routing Snapshot 发布仍待装配。
- **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
CIDR、可信代理、严格请求解析和敏感信息最小化。

View File

@ -93,9 +93,10 @@ Gateway 接收完整快照时必须拒绝缺失、格式错误或已到期的 `v
发起新的上游连接,也不查询 Redis 或 PostgreSQL 补偿。
Controller 只会下发尚未到期的完整快照,并在最近一次成功下发快照的
`valid_until` 到达时结束 `WatchSnapshots` 流。Gateway 的会话调用方必须在流结束后
按带 jitter 的有界退避重建 Register/Watch 会话;在新快照通过校验并原子替换前,
旧视图仍按其整体有效期 fail-closed。
`valid_until` 到达时结束 `WatchSnapshots` 流。Gateway 的 `SessionSupervisor` 会在流
结束或可恢复控制面错误后按带 jitter 的有界退避重建 Register/Watch 会话;参数、认证
和协议不兼容错误直接返回。新快照通过校验并原子替换前,旧视图仍按其整体有效期
fail-closed。
Delta 声明 `base_version`。Worker 只有在本地版本恰好等于 base 且 checksum
验证成功时才能应用;否则丢弃 Delta 并请求完整 Snapshot。构建在后台完成

View File

@ -57,6 +57,7 @@ Provider、Pool、Routing、Distribution 在首版需要共享事务和一致性
- 首条命中 Routing、Upstream 选择和 Proxy least-connections 选择。
- 本地原子容量预留、建连、Active 计数和结果上报。
- 本地不可变 Snapshot热路径无数据库/Redis/Provider API。
- 独立的控制面 SessionSupervisor快照流到期或中断后以有界退避重建会话。
- SSRF 与 DNS Rebinding 防护。
### 3.2 proxy-controller

View File

@ -215,8 +215,9 @@ Snapshot 并保持连接Gateway 已具备 Register/Watch/ACK/Runtime 会话
按 Worker 的可下发 ownership 索引已进入 Redis 原子脚本,并可构建无凭据引用的
已归属 Proxy payload。Snapshot 签发与 session 匹配在同一 Redis Lua 操作中完成,
重注册会清除旧引用,避免迟到 Stream 覆盖新 session。Worker 服务端会在最近完整
Snapshot 的 `valid_until` 到达时结束流,令 Gateway 调用方可按退避策略重建会话;
Routing payload、凭据分发、Gateway 命令与 Outcome 上报仍未实现。
Snapshot 的 `valid_until` 到达时结束流;公用 `SessionSupervisor` 已为 Gateway 调用方
提供可恢复错误的有界指数退避重连,并在参数/认证/协议错误时停止。Routing payload、
凭据分发、Gateway 命令与 Outcome 上报仍未实现。
已新增公用 `domain/activitypool` 契约及并发安全内存参考实现Provider
Reconciler 通过 `UpsertFetched` 写入带供应商 TTL 和分配安全余量的批次;已覆盖

View File

@ -54,8 +54,9 @@
索引构建无凭据引用的已归属 Proxy payload并以租约收紧可用期Routing payload、
凭据分发、Outcome 和 Checker 尚未闭环。Snapshot 签发在 Redis 中原子匹配当前
`session_id`,重注册会清除旧引用,迟到旧 Stream 不会覆盖新 session。Controller
在最近成功下发的 Snapshot `valid_until` 到达时关闭流Gateway 会校验并执行
Snapshot 整体 `valid_until`,过期视图不再分配新 Proxy。
在最近成功下发的 Snapshot `valid_until` 到达时关闭流Gateway 的公用
`SessionSupervisor` 已实现可恢复错误的有界退避重连。Gateway 会校验并执行 Snapshot
整体 `valid_until`,过期视图不再分配新 Proxy。
- `PostgreSQL 管理面`:已定义 `adminstate` 事务 seam、并发安全 MemoryStore、
公用契约、100 并发 Routing CAS、租约 Outbox 和只含六张管理表的 Schemapgx
Adapter 已在真实 PostgreSQL 18 上通过同一契约、迁移幂等、审计/Outbox

View File

@ -0,0 +1,154 @@
package controlplane
import (
"context"
"errors"
"math/rand"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var ErrInvalidReconnectOptions = errors.New("invalid gateway reconnect options")
// SessionLifecycle is the long-running worker control-plane session boundary.
// SessionRunner satisfies this interface.
type SessionLifecycle interface {
Run(context.Context) error
}
// ReconnectOptions controls bounded exponential reconnect delays. Jitter is a
// percentage applied symmetrically to each base delay.
type ReconnectOptions struct {
InitialDelay time.Duration
MaxDelay time.Duration
Jitter int
}
// SessionSupervisorRuntime makes reconnect timing deterministic in tests.
// Nil fields use the production timer and random source.
type SessionSupervisorRuntime struct {
Sleeper SessionSleeper
Random SessionRandom
}
type SessionSleeper interface {
Sleep(context.Context, time.Duration) error
}
type SessionRandom interface {
Float64() float64
}
// SessionSupervisor owns retry policy around repeated worker sessions without
// coupling it to SnapshotWatcher or RuntimeReporter.
type SessionSupervisor struct {
lifecycle SessionLifecycle
options ReconnectOptions
runtime SessionSupervisorRuntime
}
func NewSessionSupervisor(lifecycle SessionLifecycle, options ReconnectOptions, runtimes ...SessionSupervisorRuntime) (*SessionSupervisor, error) {
if lifecycle == nil || options.InitialDelay <= 0 || options.MaxDelay < options.InitialDelay || options.Jitter < 0 || options.Jitter > 100 || len(runtimes) > 1 {
return nil, ErrInvalidReconnectOptions
}
runtime := SessionSupervisorRuntime{Sleeper: timerSessionSleeper{}, Random: globalSessionRandom{}}
if len(runtimes) == 1 {
if runtimes[0].Sleeper != nil {
runtime.Sleeper = runtimes[0].Sleeper
}
if runtimes[0].Random != nil {
runtime.Random = runtimes[0].Random
}
}
return &SessionSupervisor{lifecycle: lifecycle, options: options, runtime: runtime}, nil
}
// Run restarts recoverable sessions until the context is canceled. A session
// that ends without an error is treated as a closed snapshot stream.
func (supervisor *SessionSupervisor) Run(ctx context.Context) error {
if supervisor == nil || ctx == nil {
return ErrInvalidReconnectOptions
}
for failedAttempts := uint(0); ; failedAttempts++ {
err := supervisor.lifecycle.Run(ctx)
if ctx.Err() != nil {
return ctx.Err()
}
if err == nil {
err = ErrSnapshotStreamClosed
}
if !retryableSessionError(err) {
return err
}
if err := supervisor.runtime.Sleeper.Sleep(ctx, supervisor.retryDelay(failedAttempts)); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
}
}
func (supervisor *SessionSupervisor) retryDelay(failedAttempts uint) time.Duration {
delay := supervisor.options.InitialDelay
for attempt := uint(0); attempt < failedAttempts && delay < supervisor.options.MaxDelay; attempt++ {
if delay >= supervisor.options.MaxDelay/2 {
delay = supervisor.options.MaxDelay
break
}
delay *= 2
}
if supervisor.options.Jitter == 0 {
return delay
}
random := supervisor.runtime.Random.Float64()
if random < 0 {
random = 0
} else if random > 1 {
random = 1
}
spread := float64(supervisor.options.Jitter) / 100
delay = time.Duration(float64(delay) * (1 + (2*random-1)*spread))
if delay < time.Millisecond {
delay = time.Millisecond
}
if delay > supervisor.options.MaxDelay {
return supervisor.options.MaxDelay
}
return delay
}
func retryableSessionError(err error) bool {
if errors.Is(err, ErrInvalidOptions) {
return false
}
switch status.Code(err) {
case codes.InvalidArgument, codes.PermissionDenied, codes.Unauthenticated, codes.Unimplemented:
return false
default:
return true
}
}
type timerSessionSleeper struct{}
func (timerSessionSleeper) Sleep(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
type globalSessionRandom struct{}
func (globalSessionRandom) Float64() float64 {
return rand.Float64()
}
var _ SessionLifecycle = (*SessionRunner)(nil)

View File

@ -0,0 +1,141 @@
package controlplane
import (
"context"
"errors"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestSessionSupervisorRetriesRecoverableFailuresWithCappedBackoff(t *testing.T) {
runner := &sequenceSessionLifecycle{results: []error{
ErrSnapshotStreamClosed,
errors.New("temporary control plane failure"),
status.Error(codes.PermissionDenied, "worker identity is not authorized"),
}}
sleeper := &recordingSessionSleeper{}
supervisor, err := NewSessionSupervisor(runner, ReconnectOptions{
InitialDelay: 100 * time.Millisecond,
MaxDelay: 150 * time.Millisecond,
}, SessionSupervisorRuntime{Sleeper: sleeper, Random: fixedSessionRandom(0.5)})
if err != nil {
t.Fatalf("NewSessionSupervisor(): %v", err)
}
err = supervisor.Run(context.Background())
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("Run() code = %s, want PermissionDenied; error=%v", status.Code(err), err)
}
if runner.calls != 3 {
t.Fatalf("Run() calls = %d, want 3", runner.calls)
}
if got, want := sleeper.delays, []time.Duration{100 * time.Millisecond, 150 * time.Millisecond}; !equalSessionDelays(got, want) {
t.Fatalf("backoff delays = %v, want %v", got, want)
}
}
func TestSessionSupervisorStopsWhenContextCancelsDuringBackoff(t *testing.T) {
runner := &sequenceSessionLifecycle{results: []error{ErrSnapshotStreamClosed}}
sleeper := blockingSessionSleeper{started: make(chan struct{})}
supervisor, err := NewSessionSupervisor(runner, ReconnectOptions{
InitialDelay: time.Second,
MaxDelay: time.Second,
}, SessionSupervisorRuntime{Sleeper: sleeper, Random: fixedSessionRandom(0.5)})
if err != nil {
t.Fatalf("NewSessionSupervisor(): %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() { result <- supervisor.Run(ctx) }()
select {
case <-sleeper.started:
case <-time.After(time.Second):
t.Fatal("reconnect backoff did not start")
}
cancel()
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context canceled", err)
}
if runner.calls != 1 {
t.Fatalf("Run() calls = %d, want 1", runner.calls)
}
}
func TestSessionSupervisorRejectsInvalidOptionsAndDoesNotRetryPermanentErrors(t *testing.T) {
if _, err := NewSessionSupervisor(&sequenceSessionLifecycle{}, ReconnectOptions{}); !errors.Is(err, ErrInvalidReconnectOptions) {
t.Fatalf("NewSessionSupervisor(invalid options) error = %v, want ErrInvalidReconnectOptions", err)
}
runner := &sequenceSessionLifecycle{results: []error{status.Error(codes.InvalidArgument, "invalid worker")}}
sleeper := &recordingSessionSleeper{}
supervisor, err := NewSessionSupervisor(runner, ReconnectOptions{
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
}, SessionSupervisorRuntime{Sleeper: sleeper, Random: fixedSessionRandom(0.5)})
if err != nil {
t.Fatalf("NewSessionSupervisor(): %v", err)
}
if err := supervisor.Run(context.Background()); status.Code(err) != codes.InvalidArgument {
t.Fatalf("Run() code = %s, want InvalidArgument; error=%v", status.Code(err), err)
}
if runner.calls != 1 || len(sleeper.delays) != 0 {
t.Fatalf("permanent error calls=%d delays=%v, want one call and no delay", runner.calls, sleeper.delays)
}
}
type sequenceSessionLifecycle struct {
results []error
calls int
}
func (runner *sequenceSessionLifecycle) Run(context.Context) error {
runner.calls++
if len(runner.results) == 0 {
return ErrSnapshotStreamClosed
}
result := runner.results[0]
runner.results = runner.results[1:]
return result
}
type recordingSessionSleeper struct {
delays []time.Duration
}
func (sleeper *recordingSessionSleeper) Sleep(_ context.Context, delay time.Duration) error {
sleeper.delays = append(sleeper.delays, delay)
return nil
}
type blockingSessionSleeper struct {
started chan struct{}
}
func (sleeper blockingSessionSleeper) Sleep(ctx context.Context, _ time.Duration) error {
close(sleeper.started)
<-ctx.Done()
return ctx.Err()
}
type fixedSessionRandom float64
func (random fixedSessionRandom) Float64() float64 {
return float64(random)
}
func equalSessionDelays(left, right []time.Duration) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}