feat: refresh worker snapshots before expiry
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-08-02 10:27:56 +08:00
parent 84ed10bd7a
commit b9b323af9b
11 changed files with 241 additions and 14 deletions

View File

@ -55,7 +55,8 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
View 匹配请求,并在内存中按 Sequential、Random、Round Robin、Weighted 或 Least
Connections 选择上游。无候选时支持 reject、受 `waitTimeout` 限制的本地容量等待,
以及仍经过目标地址策略的 direct`proxy-gateway` 通过独立控制面拨号地址维护
Session并仅在持有未过期 Snapshot 时 Ready凭据材料只保留在当前节点内存 View。
Session并在每份 Snapshot 有效期的一半前接收版本递增的完整刷新;仅在持有未过期
Snapshot 时 Ready凭据材料只保留在当前节点内存 View。
- **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
CIDR、可信代理、严格请求解析和敏感信息最小化。

View File

@ -25,7 +25,9 @@ Controller 已实现并验证 `RegisterWorker`、`AcknowledgeSnapshot` 和
监听允许明文 fixture 模式。单消息大小、并发流数和 gRPC keepalive 策略由
`controlPlane` 配置限定。
`WatchSnapshots` 已在 Register 后发送与当前 ownership epoch 对应的基础完整快照,
`WatchSnapshots` 已在 Register 后发送与当前 ownership epoch 对应的基础完整快照,并在
每份快照有效期的一半前重新构建、下发版本递增的完整快照;这样 ownership、Routing 和
凭据变化会在同一长连接内收敛,而无需等待有效期到达后重新注册。
Gateway 校验后 ACK 并开始 Runtime 心跳。Controller 会从 Redis 的有界 Worker ownership
索引构建已归属 Proxy 内容,并将租约到期收紧到 Proxy 的 `usable_until`。Proxy 引用的
凭据材料按 `secret_ref + credential_version` 去重,随完整 Snapshot 经 mTLS 下发,仅保留在
@ -108,11 +110,10 @@ Gateway 接收完整快照时必须拒绝缺失、格式错误或已到期的 `v
保存在本地不可变视图。该整体期限到达后,调度直接按无候选处理,不再使用旧视图
发起新的上游连接,也不查询 Redis 或 PostgreSQL 补偿。
Controller 只会下发尚未到期的完整快照,并在最近一次成功下发快照的
`valid_until` 到达时结束 `WatchSnapshots` 流。Gateway 的 `SessionSupervisor` 会在流
结束或可恢复控制面错误后按带 jitter 的有界退避重建 Register/Watch 会话;参数、认证
和协议不兼容错误直接返回。新快照通过校验并原子替换前,旧视图仍按其整体有效期
fail-closed。
Controller 只会下发尚未到期的完整快照,并在每份快照有效期的一半前发送下一版完整
快照。刷新构建失败、流结束或可恢复控制面错误时Gateway 的 `SessionSupervisor` 会按
带 jitter 的有界退避重建 Register/Watch 会话;参数、认证和协议不兼容错误直接返回。
新快照通过校验并原子替换前,旧视图仍按其整体有效期 fail-closed。
Delta 声明 `base_version`。Worker 只有在本地版本恰好等于 base 且 checksum
验证成功时才能应用;否则丢弃 Delta 并请求完整 Snapshot。构建在后台完成

View File

@ -22,7 +22,7 @@ go run ./cmd/proxy-controller -config CONFIG_FILE
PostgreSQL 管理面迁移、Redis 活动池、Distribution/Admin 独立监听与优雅停机;
Controller Metrics 独立监听、`/livez`、`/readyz` 和基础 Prometheus 运行时指标;
Provider 自动补池、分布式配额、动态重载和 Admin 低基数统计已装配。Gateway 进程、
Worker 控制面会话、Snapshot 凭据分发和 Snapshot 就绪探针已装配Checker 的任务流与
Worker 控制面会话、持续完整 Snapshot 刷新、凭据分发和 Snapshot 就绪探针已装配Checker 的任务流与
租约契约已接入控制面,但 Redis 共享调度、独立执行器与代表性负载验证仍在后续实施范围。
所有时间值使用 Go duration例如 `500ms`、`30s`、`5m`。示例中的

View File

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

View File

@ -114,6 +114,16 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两
维护该索引。Memory 单测、Redis 集成契约和配置/Reaper 单测覆盖恢复、阈值、延后与
Drain/ACK 后清理。
## Worker Snapshot 刷新2026-08-02
- `OwnedSnapshotSource` 每次调用只构建一份权威完整快照;新增
`RefreshingSnapshotSource` 以有效期一半为上限持续调用该窄接口,并传递最新版本和
checksum。短于全局 `maxStaleAge` 的 Proxy/ownership 租约会进一步缩短下一次刷新间隔。
- gRPC Handler 每次成功发送完整 Snapshot 都重置服务端到期计时器;流中刷新失败或中断时
Gateway 使用既有 `SessionSupervisor` 退避重连。Gateway 本地 Store 对从完整 Snapshot
消失且仍有 Active/Reserved 的 Proxy 已按 draining 继续上报,但 Controller 尚未具备
自动 `BeginDrain`/`AcknowledgeDrain` 编排或 Worker 分配器,不能视为自动 Drain 闭环。
## Git 同步事实2026-07-29
- PostgreSQL 管理面基础文档已提交为 `7951c29`

View File

@ -60,9 +60,13 @@ func NewServer(controlPlane config.ControlPlane, service Service, options Server
}
}
if snapshots == nil {
snapshots, err = NewInitialSnapshotSource(service, controlPlane.MaxStaleAge.Value(), time.Now)
initial, initialErr := NewInitialSnapshotSource(service, controlPlane.MaxStaleAge.Value(), time.Now)
if initialErr != nil {
return nil, fmt.Errorf("%w: build initial snapshot source: %v", ErrInvalidServer, initialErr)
}
snapshots, err = NewRefreshingSnapshotSource(initial, snapshotRefreshEvery(controlPlane.MaxStaleAge.Value()))
if err != nil {
return nil, fmt.Errorf("%w: build initial snapshot source: %v", ErrInvalidServer, err)
return nil, fmt.Errorf("%w: build refreshing snapshot source: %v", ErrInvalidServer, err)
}
}
serverOptions = append(serverOptions,

View File

@ -156,7 +156,11 @@ func NewService(store workerruntime.ControlStore, options Options) (Service, err
if err != nil {
return nil, errors.Join(ErrInvalidCommand, err)
}
result.snapshots = source
refreshed, refreshErr := NewRefreshingSnapshotSource(source, snapshotRefreshEvery(options.MaxStaleAge))
if refreshErr != nil {
return nil, errors.Join(ErrInvalidCommand, refreshErr)
}
result.snapshots = refreshed
}
return result, nil
}

View File

@ -25,6 +25,113 @@ type SnapshotSource interface {
Watch(context.Context, SnapshotWatchRequest) (<-chan *controlplanev1.WorkerSnapshot, error)
}
// RefreshingSnapshotSource keeps a Worker snapshot stream current without
// requiring the Gateway to reconnect after every validity window. It only
// forwards complete snapshots, so Gateway delta handling remains fail-closed.
type RefreshingSnapshotSource struct {
source SnapshotSource
refreshEvery time.Duration
}
func NewRefreshingSnapshotSource(source SnapshotSource, refreshEvery time.Duration) (*RefreshingSnapshotSource, error) {
if source == nil || refreshEvery <= 0 {
return nil, ErrSnapshotsUnavailable
}
return &RefreshingSnapshotSource{source: source, refreshEvery: refreshEvery}, nil
}
func snapshotRefreshEvery(validFor time.Duration) time.Duration {
if validFor <= 0 {
return 0
}
if half := validFor / 2; half > 0 {
return half
}
return validFor
}
func (source *RefreshingSnapshotSource) Watch(
ctx context.Context,
request SnapshotWatchRequest,
) (<-chan *controlplanev1.WorkerSnapshot, error) {
if source == nil || source.source == nil || ctx == nil {
return nil, ErrSnapshotsUnavailable
}
first, err := source.next(ctx, request)
if err != nil {
return nil, err
}
updates := make(chan *controlplanev1.WorkerSnapshot, 1)
updates <- first
go source.refresh(ctx, updates, request, first)
return updates, nil
}
func (source *RefreshingSnapshotSource) refresh(
ctx context.Context,
updates chan<- *controlplanev1.WorkerSnapshot,
request SnapshotWatchRequest,
current *controlplanev1.WorkerSnapshot,
) {
defer close(updates)
version := current.GetVersion()
checksum := append([]byte(nil), current.GetChecksum()...)
timer := time.NewTimer(source.delayFor(current))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
request.LastAppliedVersion = version
request.LastChecksum = append(request.LastChecksum[:0], checksum...)
next, err := source.next(ctx, request)
if err != nil {
return
}
select {
case <-ctx.Done():
return
case updates <- next:
version = next.GetVersion()
checksum = append(checksum[:0], next.GetChecksum()...)
timer.Reset(source.delayFor(next))
}
}
}
}
func (source *RefreshingSnapshotSource) next(
ctx context.Context,
request SnapshotWatchRequest,
) (*controlplanev1.WorkerSnapshot, error) {
stream, err := source.source.Watch(ctx, request)
if err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case snapshot, ok := <-stream:
if !ok || snapshot == nil || snapshot.GetVersion() <= request.LastAppliedVersion {
return nil, ErrSnapshotsUnavailable
}
return snapshot, nil
}
}
func (source *RefreshingSnapshotSource) delayFor(snapshot *controlplanev1.WorkerSnapshot) time.Duration {
delay := source.refreshEvery
if snapshot == nil || snapshot.GetGeneratedAt() == nil || snapshot.GetValidUntil() == nil {
return delay
}
lifetime := snapshot.GetValidUntil().AsTime().Sub(snapshot.GetGeneratedAt().AsTime())
if half := lifetime / 2; half > 0 && half < delay {
return half
}
return delay
}
type OwnershipEpochReader interface {
CurrentOwnershipEpoch(context.Context) (uint64, error)
}
@ -73,3 +180,4 @@ func (source *InitialSnapshotSource) Watch(ctx context.Context, request Snapshot
}
var _ SnapshotSource = (*InitialSnapshotSource)(nil)
var _ SnapshotSource = (*RefreshingSnapshotSource)(nil)

View File

@ -3,9 +3,11 @@ package worker
import (
"context"
"errors"
"sync"
"testing"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
controlplanev1 "proxy-pool/gen/controlplane/v1"
"proxy-pool/internal/controlplane/snapshotwire"
ownershipDomain "proxy-pool/internal/domain/ownership"
@ -35,6 +37,47 @@ func TestInitialSnapshotSourceIssuesNextFullSnapshot(t *testing.T) {
}
}
func TestRefreshingSnapshotSourceIssuesSequentialFullSnapshots(t *testing.T) {
base := &refreshingSnapshotSourceStub{}
source, err := NewRefreshingSnapshotSource(base, 10*time.Millisecond)
if err != nil {
t.Fatalf("NewRefreshingSnapshotSource() = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
updates, err := source.Watch(ctx, SnapshotWatchRequest{
WorkerID: "worker-a", SessionID: "session-a", LastAppliedVersion: 4, LastChecksum: []byte{4},
})
if err != nil {
t.Fatalf("Watch() = %v", err)
}
first := receiveSnapshot(t, updates)
second := receiveSnapshot(t, updates)
if first.GetVersion() != 5 || second.GetVersion() != 6 || second.GetVersion() <= first.GetVersion() {
t.Fatalf("refreshed versions = (%d, %d)", first.GetVersion(), second.GetVersion())
}
if calls := base.Calls(); len(calls) < 2 || calls[0] != 4 || calls[1] != 5 {
t.Fatalf("base requested versions = %v", calls)
}
if checksums := base.Checksums(); len(checksums) < 2 || string(checksums[0]) != string([]byte{4}) ||
string(checksums[1]) != string([]byte{5}) {
t.Fatalf("base requested checksums = %v", checksums)
}
}
func TestRefreshingSnapshotSourceRefreshesBeforeShortSnapshotExpiry(t *testing.T) {
source, err := NewRefreshingSnapshotSource(&refreshingSnapshotSourceStub{}, 2*time.Second)
if err != nil {
t.Fatalf("NewRefreshingSnapshotSource() = %v", err)
}
now := time.Now().UTC()
if delay := source.delayFor(&controlplanev1.WorkerSnapshot{
GeneratedAt: timestamppb.New(now), ValidUntil: timestamppb.New(now.Add(100 * time.Millisecond)),
}); delay != 50*time.Millisecond {
t.Fatalf("delayFor(short-lived snapshot) = %s, want 50ms", delay)
}
}
func TestOwnedSnapshotSourceBuildsBoundedProxySnapshot(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
expiresAt := now.Add(10 * time.Minute)
@ -167,6 +210,57 @@ type epochReaderStub struct {
err error
}
type refreshingSnapshotSourceStub struct {
mu sync.Mutex
calls []uint64
checksums [][]byte
}
func (source *refreshingSnapshotSourceStub) Watch(
_ context.Context,
request SnapshotWatchRequest,
) (<-chan *controlplanev1.WorkerSnapshot, error) {
source.mu.Lock()
source.calls = append(source.calls, request.LastAppliedVersion)
source.checksums = append(source.checksums, append([]byte(nil), request.LastChecksum...))
source.mu.Unlock()
updates := make(chan *controlplanev1.WorkerSnapshot, 1)
updates <- &controlplanev1.WorkerSnapshot{
Version: request.LastAppliedVersion + 1, Checksum: []byte{byte(request.LastAppliedVersion + 1)},
}
return updates, nil
}
func (source *refreshingSnapshotSourceStub) Calls() []uint64 {
source.mu.Lock()
defer source.mu.Unlock()
return append([]uint64(nil), source.calls...)
}
func (source *refreshingSnapshotSourceStub) Checksums() [][]byte {
source.mu.Lock()
defer source.mu.Unlock()
checksums := make([][]byte, len(source.checksums))
for index := range source.checksums {
checksums[index] = append([]byte(nil), source.checksums[index]...)
}
return checksums
}
func receiveSnapshot(t *testing.T, updates <-chan *controlplanev1.WorkerSnapshot) *controlplanev1.WorkerSnapshot {
t.Helper()
select {
case snapshot, ok := <-updates:
if !ok || snapshot == nil {
t.Fatal("snapshot updates closed before next full snapshot")
}
return snapshot
case <-time.After(time.Second):
t.Fatal("timed out waiting for snapshot")
return nil
}
}
func (reader epochReaderStub) CurrentOwnershipEpoch(context.Context) (uint64, error) {
return reader.epoch, reader.err
}

View File

@ -15,6 +15,10 @@
- Redis 集成标签测试在未设置 `PROXY_POOL_TEST_REDIS_URL` 时现能正确跳过所有契约
子测试;`go test ./...`、`go test -tags=integration ./internal/adapters/redisactivity`
`scripts/verify.ps1` 已通过race 仍由 Linux CI 执行。
- Worker 控制面改为持续完整 Snapshot 发布Controller 在每份 Snapshot 有效期的一半
前重建并下发递增版本Gateway 在同一长连接内原子替换视图;构建失败或流中断时才由
SessionSupervisor 按原有退避重连。该机制复用 `RefreshingSnapshotSource`,不降低
Delta 的 fail-closed 校验,也不把自动 Drain/ACK 记为已完成。
## 2026-07-30

View File

@ -40,8 +40,8 @@
14. [已完成] 收敛健康故障生命周期:将 `UNHEALTHY` 的持续时长纳入权威活动池状态,
通过 Controller 后台的有界回收清理无 Worker ownership 的持续异常 Proxy拥有
Worker 的候选延后到既有 Drain/ACK 清除所有权后再处理。
15. [进行中] 收敛 Worker 发布生命周期:补齐权威 Proxy/Routing 的增量发布和
自动 Drain 编排,使配置停用、健康淘汰与 Snapshot 撤销形成可观测闭环。
15. [进行中] 收敛 Worker 发布生命周期:已完成权威 Proxy/Routing 的持续完整快照
刷新;后续补齐自动 Drain 编排,使配置停用、健康淘汰与 Snapshot 撤销形成可观测闭环。
## 串并行关系