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

View File

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

View File

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

View File

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

View File

@ -114,6 +114,16 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两
维护该索引。Memory 单测、Redis 集成契约和配置/Reaper 单测覆盖恢复、阈值、延后与 维护该索引。Memory 单测、Redis 集成契约和配置/Reaper 单测覆盖恢复、阈值、延后与
Drain/ACK 后清理。 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 ## Git 同步事实2026-07-29
- PostgreSQL 管理面基础文档已提交为 `7951c29` - PostgreSQL 管理面基础文档已提交为 `7951c29`

View File

@ -60,9 +60,13 @@ func NewServer(controlPlane config.ControlPlane, service Service, options Server
} }
} }
if snapshots == nil { 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 { 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, serverOptions = append(serverOptions,

View File

@ -156,7 +156,11 @@ func NewService(store workerruntime.ControlStore, options Options) (Service, err
if err != nil { if err != nil {
return nil, errors.Join(ErrInvalidCommand, err) 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 return result, nil
} }

View File

@ -25,6 +25,113 @@ type SnapshotSource interface {
Watch(context.Context, SnapshotWatchRequest) (<-chan *controlplanev1.WorkerSnapshot, error) 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 { type OwnershipEpochReader interface {
CurrentOwnershipEpoch(context.Context) (uint64, error) CurrentOwnershipEpoch(context.Context) (uint64, error)
} }
@ -73,3 +180,4 @@ func (source *InitialSnapshotSource) Watch(ctx context.Context, request Snapshot
} }
var _ SnapshotSource = (*InitialSnapshotSource)(nil) var _ SnapshotSource = (*InitialSnapshotSource)(nil)
var _ SnapshotSource = (*RefreshingSnapshotSource)(nil)

View File

@ -3,9 +3,11 @@ package worker
import ( import (
"context" "context"
"errors" "errors"
"sync"
"testing" "testing"
"time" "time"
"google.golang.org/protobuf/types/known/timestamppb"
controlplanev1 "proxy-pool/gen/controlplane/v1" controlplanev1 "proxy-pool/gen/controlplane/v1"
"proxy-pool/internal/controlplane/snapshotwire" "proxy-pool/internal/controlplane/snapshotwire"
ownershipDomain "proxy-pool/internal/domain/ownership" 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) { func TestOwnedSnapshotSourceBuildsBoundedProxySnapshot(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
expiresAt := now.Add(10 * time.Minute) expiresAt := now.Add(10 * time.Minute)
@ -167,6 +210,57 @@ type epochReaderStub struct {
err error 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) { func (reader epochReaderStub) CurrentOwnershipEpoch(context.Context) (uint64, error) {
return reader.epoch, reader.err return reader.epoch, reader.err
} }

View File

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

View File

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