53 KiB
Worker Control Plane Session and Runtime Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 实现 Worker 注册、Snapshot ACK 和 Runtime 心跳三个 gRPC RPC,并用 Redis 在多 Controller 副本间原子共享 Session、Snapshot 元数据和报告栅栏。
Architecture: protobuf 生成代码只属于传输契约,internal/controller/worker 提供不依赖 protobuf 的公共应用服务。workerruntime.ControlStore 同时由内存参考实现和 Redis Adapter 实现;Controller 将独立 gRPC Runner 与现有 HTTP Runtime、Provider Supervisor 放入同一 lifecycle Group,Gateway 请求热路径保持纯内存。
Tech Stack: Go 1.26.4、gRPC-Go v1.83.0、Protobuf-Go v1.36.11、protoc v35.0、protoc-gen-go-grpc v1.6.2、Redis 8.2、Redis Lua、mTLS/SPIFFE URI SAN、PowerShell、Docker Compose。
File Structure
gen/controlplane/v1/
controlplane.pb.go # protoc 生成消息类型
controlplane_grpc.pb.go # protoc 生成 gRPC Client/Server
internal/domain/workerruntime/
runtime.go # ControlStore 类型、错误和公共接口
validation.go # Service/Memory/Redis 共用规范化与摘要
validation_test.go # 公用边界规则测试
memory.go # 内存参考实现
memory_test.go # 领域行为测试
contracttest/contract.go # Memory/Redis 共用契约
internal/adapters/redisactivity/
keys.go # Worker Snapshot 元数据 key
runtime.go # ControlStore Go 适配器
scripts/runtime.lua # Session/ACK/Runtime 原子状态机
runtime_integration_test.go # Redis 8.2 契约与故障测试
internal/controller/worker/
service.go # 公共应用服务与命令/结果
service_test.go # 应用行为测试
grpc_handler.go # protobuf DTO 与 gRPC code 映射
grpc_handler_test.go # bufconn Client 测试
identity.go # SPIFFE Worker 身份校验
identity_test.go # TLS 身份测试
server.go # gRPC lifecycle Runner
server_test.go # Listener 与停机测试
internal/config/
config.go # controlPlane 配置类型
validate.go # listener、mTLS 与资源上限校验
config_test.go # 严格配置矩阵
internal/controller/bootstrap/
bootstrap.go # Worker Service/Runner 装配
infrastructure.go # Redis ControlStore 接线
bootstrap_test.go # 依赖与 lifecycle 测试
bootstrap_integration_test.go # 双存储 + gRPC fixture
scripts/
install-protoc.ps1 # 下载并校验固定 protoc 35.0
generate-proto.ps1 # 固定工具生成 Go 契约
verify-proto.ps1 # descriptor + 漂移检查
docs/configuration/reference.md # controlPlane 配置说明
docs/api/control-plane.md # 当前实现状态与恢复语义
Task 1: 固定依赖并生成 gRPC 契约
Files:
-
Modify:
go.mod -
Modify:
go.sum -
Create:
scripts/install-protoc.ps1 -
Create:
scripts/generate-proto.ps1 -
Modify:
scripts/verify-proto.ps1 -
Modify:
scripts/verify.ps1 -
Modify:
.github/workflows/ci.yml -
Create:
gen/controlplane/v1/controlplane.pb.go -
Create:
gen/controlplane/v1/controlplane_grpc.pb.go -
Step 1: 记录依赖与 descriptor 基线
Run:
go test -count=1 -timeout 60s ./...
./scripts/verify-proto.ps1
Expected: 全部 PASS,descriptor 非空;gen/controlplane/v1 尚不存在。
- Step 2: 固定运行时与 Go tool 依赖
Run:
go get google.golang.org/grpc@v1.83.0 google.golang.org/protobuf@v1.36.11
go get -tool google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
go get -tool google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2
go mod tidy
Expected: go.mod 的直接依赖包含 gRPC/Protobuf,并包含两个 tool 声明;Go 版本
仍为 1.26.0。
- Step 3: 新增固定 protoc 安装脚本
scripts/install-protoc.ps1 只支持当前 CI 和开发机的 x64 Linux/Windows,
其他平台明确 throw。固定以下官方发布件与 SHA-256:
$version = "35.0"
$artifacts = @{
"linux-x86_64" = @{
File = "protoc-35.0-linux-x86_64.zip"
SHA256 = "a45cda0989c17dd950db55f6fbe1e5814c50fda08e87aa422980ac1f89dddbbc"
}
"windows-x86_64" = @{
File = "protoc-35.0-win64.zip"
SHA256 = "d1cede9e308cc3eb072392af1c02ccae4bdd3d2f374ec2970dbd8cdfdaa91363"
}
}
$baseURL = "https://github.com/protocolbuffers/protobuf/releases/download/v$version"
脚本将 zip 缓存到 .tmp-proto/downloads,先用 Get-FileHash -Algorithm SHA256
严格比较再 Expand-Archive -Force到 .tmp-proto/protoc-35.0。最后执行
protoc --version,必须精确等于 libprotoc 35.0,并把可执行文件路径作为唯一
pipeline output 返回。
- Step 4: 新增可重复生成脚本
scripts/generate-proto.ps1 使用仓库内临时工具目录,不执行全局安装:
param(
[string]$Protoc = "",
[string]$IncludePath = $env:PROTOC_INCLUDE,
[string]$OutputRoot = ""
)
$ErrorActionPreference = "Stop"
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$protoRoot = Join-Path $repositoryRoot "api/proto"
$source = Join-Path $protoRoot "controlplane/v1/controlplane.proto"
$toolRoot = Join-Path $repositoryRoot ".tmp-proto/tools"
New-Item -ItemType Directory -Force -Path $toolRoot | Out-Null
if ([string]::IsNullOrWhiteSpace($Protoc)) {
$Protoc = & (Join-Path $PSScriptRoot "install-protoc.ps1")
}
$suffix = if ($IsWindows -or $env:OS -eq "Windows_NT") { ".exe" } else { "" }
$protocGenGo = Join-Path $toolRoot ("protoc-gen-go" + $suffix)
$protocGenGoGRPC = Join-Path $toolRoot ("protoc-gen-go-grpc" + $suffix)
go build -o $protocGenGo google.golang.org/protobuf/cmd/protoc-gen-go
if ($LASTEXITCODE -ne 0) { throw "build protoc-gen-go failed" }
go build -o $protocGenGoGRPC google.golang.org/grpc/cmd/protoc-gen-go-grpc
if ($LASTEXITCODE -ne 0) { throw "build protoc-gen-go-grpc failed" }
$protocCommand = Get-Command $Protoc -ErrorAction Stop
$protocVersion = & $protocCommand.Source --version
if ($protocVersion -ne "libprotoc 35.0") { throw "protoc 35.0 is required" }
if ([string]::IsNullOrWhiteSpace($IncludePath)) {
$installationRoot = Split-Path (Split-Path $protocCommand.Source -Parent) -Parent
$IncludePath = @((Join-Path $installationRoot "include"), "/usr/include", "/usr/local/include") |
Where-Object { Test-Path (Join-Path $_ "google/protobuf/timestamp.proto") } |
Select-Object -First 1
}
if ([string]::IsNullOrWhiteSpace($OutputRoot)) { $OutputRoot = $repositoryRoot }
if ([string]::IsNullOrWhiteSpace($IncludePath) -or
-not (Test-Path (Join-Path $IncludePath "google/protobuf/timestamp.proto"))) {
throw "protoc well-known type include directory was not found"
}
New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null
& $protocCommand.Source `
"--proto_path=$protoRoot" `
"--proto_path=$IncludePath" `
"--plugin=protoc-gen-go=$protocGenGo" `
"--plugin=protoc-gen-go-grpc=$protocGenGoGRPC" `
"--go_out=$OutputRoot" `
"--go_opt=module=proxy-pool" `
"--go-grpc_out=$OutputRoot" `
"--go-grpc_opt=module=proxy-pool" `
$source
if ($LASTEXITCODE -ne 0) { throw "protobuf Go generation failed" }
- Step 5: 生成并编译契约
Run:
./scripts/generate-proto.ps1
go fmt ./gen/controlplane/v1
go mod tidy
go test -count=1 -timeout 60s ./gen/controlplane/v1
Expected: 两个生成文件存在,package 为 controlplanev1,测试命令 PASS。
- Step 6: 扩展漂移验证与 CI
修改 verify-proto.ps1:保留 descriptor 校验,再生成到 .tmp-proto/generated,
对以下两个相对路径逐字节比较:
$generated = @(
"gen/controlplane/v1/controlplane.pb.go",
"gen/controlplane/v1/controlplane_grpc.pb.go"
)
foreach ($relative in $generated) {
$committed = Join-Path $repositoryRoot $relative
$candidate = Join-Path $outputRoot $relative
if (-not (Test-Path $committed) -or
-not [System.Linq.Enumerable]::SequenceEqual(
[System.IO.File]::ReadAllBytes($committed),
[System.IO.File]::ReadAllBytes($candidate))) {
throw "generated protobuf drift: $relative"
}
}
verify-proto.ps1 默认先调用 install-protoc.ps1,descriptor 和 Go 生成共用返回的
固定 compiler。它把 $outputRoot 固定到 .tmp-proto/generated,通过
& "$PSScriptRoot/generate-proto.ps1" -Protoc $Protoc -IncludePath $IncludePath -OutputRoot $outputRoot
生成候选文件;生成失败立即 throw,不使用历史候选结果。verify.ps1 在
存在 protoc 时运行扩展后的脚本。CI 新增独立 proto job:
ubuntu-latest 设置 Go 后直接执行 pwsh ./scripts/verify-proto.ps1,不使用
apt 的浮动 protobuf-compiler。现有 Windows test job 继续由 go test 和
go build 验证已提交的生成包。
- Step 7: 验证并提交生成基线
Run:
./scripts/verify-proto.ps1
go test -count=1 -timeout 60s ./...
git diff --check
git add go.mod go.sum scripts/install-protoc.ps1 scripts/generate-proto.ps1 scripts/verify-proto.ps1 scripts/verify.ps1 .github/workflows/ci.yml gen/controlplane/v1/controlplane.pb.go gen/controlplane/v1/controlplane_grpc.pb.go
git diff --cached --name-only
git commit -m "build: generate worker control plane grpc contract"
Expected: 验证 PASS,提交只包含依赖、生成工具和生成代码。
Task 2: 增加严格的 ControlPlane 配置
Files:
-
Modify:
internal/config/config.go -
Modify:
internal/config/validate.go -
Modify:
internal/config/config_test.go -
Modify:
configs/proxy-pool.yaml -
Modify:
deploy/config/local.yaml -
Modify:
deploy/kubernetes/base/configmap.yaml -
Modify:
docs/configuration/reference.md -
Step 1: 写配置红灯测试
在 config_test.go 新增表驱动测试,至少包含以下输入与结果:
func TestValidateControlPlane(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
want string
}{
{"missing listen", func(c *Config) { c.ControlPlane = validControlPlane(); c.ControlPlane.Listen = "" }, "controlPlane listen"},
{"public plaintext", func(c *Config) { c.ControlPlane = validControlPlane(); c.ControlPlane.Listen = "0.0.0.0:8443" }, "requires mtls"},
{"short session ttl", func(c *Config) { c.ControlPlane = validControlPlane(); c.ControlPlane.SessionTTL = Duration(29 * time.Second) }, "sessionTTL"},
{"stale below heartbeat", func(c *Config) { c.ControlPlane = validControlPlane(); c.ControlPlane.MaxStaleAge = Duration(9 * time.Second) }, "maxStaleAge"},
{"invalid protocol", func(c *Config) { c.ControlPlane = validControlPlane(); c.ControlPlane.ProtocolVersion = 0 }, "protocolVersion"},
{"missing mtls files", func(c *Config) { c.ControlPlane = validMTLSControlPlane(); c.ControlPlane.TLS.CertFile = "" }, "certFile"},
{"trust domain with port", func(c *Config) { c.ControlPlane = validMTLSControlPlane(); c.ControlPlane.TLS.TrustDomain = "proxy.example:443" }, "trustDomain"},
{"environment path", func(c *Config) { c.ControlPlane = validMTLSControlPlane(); c.ControlPlane.TLS.Environment = "prod/eu" }, "environment"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := mustLoadValidConfig(t)
tt.mutate(cfg)
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want %q", err, tt.want)
}
})
}
}
- Step 2: 运行测试确认失败
Run: go test -count=1 -timeout 60s ./internal/config -run TestValidateControlPlane
Expected: FAIL,Config.ControlPlane 尚未定义。
- Step 3: 定义配置类型
在 config.go 增加:
type Config struct {
Version int `yaml:"version"`
Defaults Defaults `yaml:"defaults"`
Security Security `yaml:"security"`
Gateway Listener `yaml:"gateway"`
Distribution Distribution `yaml:"distribution"`
Admin Listener `yaml:"admin"`
ControlPlane ControlPlane `yaml:"controlPlane"`
Metrics Metrics `yaml:"metrics"`
Storage Storage `yaml:"storage"`
Routing []Routing `yaml:"routing"`
Upstreams map[string]Upstream `yaml:"upstreams"`
}
type ControlPlane struct {
Enabled bool `yaml:"enabled"`
Listen string `yaml:"listen"`
ProtocolVersion uint32 `yaml:"protocolVersion"`
HeartbeatInterval Duration `yaml:"heartbeatInterval"`
SessionTTL Duration `yaml:"sessionTTL"`
MaxStaleAge Duration `yaml:"maxStaleAge"`
MaxMessageBytes int `yaml:"maxMessageBytes"`
MaxRuntimeCounters int `yaml:"maxRuntimeCounters"`
MaxConcurrentStreams uint32 `yaml:"maxConcurrentStreams"`
TLS ControlPlaneTLS `yaml:"tls"`
}
type ControlPlaneTLS struct {
Mode string `yaml:"mode"`
CertFile string `yaml:"certFile"`
KeyFile string `yaml:"keyFile"`
ClientCAFile string `yaml:"clientCAFile"`
TrustDomain string `yaml:"trustDomain"`
Environment string `yaml:"environment"`
}
- Step 4: 实现唯一验证规则
在 Validate 中调用 validateControlPlane。该函数必须执行:
func validateControlPlane(item ControlPlane) error {
if !item.Enabled { return nil }
host, err := validateListenAddress("controlPlane", item.Listen)
if err != nil { return err }
if item.ProtocolVersion != 1 { return fmt.Errorf("validate controlPlane protocolVersion: must be 1") }
heartbeat, ttl, stale := item.HeartbeatInterval.Value(), item.SessionTTL.Value(), item.MaxStaleAge.Value()
if heartbeat <= 0 || ttl < 3*heartbeat { return fmt.Errorf("validate controlPlane sessionTTL: must be at least three heartbeat intervals") }
if stale < heartbeat { return fmt.Errorf("validate controlPlane maxStaleAge: must not be shorter than heartbeatInterval") }
if item.MaxMessageBytes <= 0 || item.MaxMessageBytes > 64<<20 { return fmt.Errorf("validate controlPlane maxMessageBytes: must be in [1, 67108864]") }
if item.MaxRuntimeCounters <= 0 || item.MaxRuntimeCounters > MaximumPoolSize { return fmt.Errorf("validate controlPlane maxRuntimeCounters: must be in [1, %d]", MaximumPoolSize) }
if item.MaxConcurrentStreams == 0 { return fmt.Errorf("validate controlPlane maxConcurrentStreams: must be positive") }
switch item.TLS.Mode {
case "disabled":
if isPublicHost(host) { return fmt.Errorf("validate controlPlane tls: non-loopback listen requires mtls") }
case "mtls":
if item.TLS.CertFile == "" || item.TLS.KeyFile == "" || item.TLS.ClientCAFile == "" ||
item.TLS.TrustDomain == "" || item.TLS.Environment == "" {
return fmt.Errorf("validate controlPlane tls: mtls requires certFile, keyFile, clientCAFile, trustDomain and environment")
}
if !validTrustDomain(item.TLS.TrustDomain) {
return fmt.Errorf("validate controlPlane tls.trustDomain: must be a lowercase DNS name without port")
}
if !controlPlaneIdentityPattern.MatchString(item.TLS.Environment) {
return fmt.Errorf("validate controlPlane tls.environment: must be one URI path segment")
}
default:
return fmt.Errorf("validate controlPlane tls.mode: must be disabled or mtls")
}
return nil
}
validTrustDomain 要求小写 DNS 名、总长度不超过 253,每个 label 为
1..63 字节且不以 - 开头/结尾;禁止 userinfo、port、路径、query 和
fragment。controlPlaneIdentityPattern 为
^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$,因此 environment 不能注入额外 URI segment。
- Step 5: 使配置测试转绿
Run: go test -count=1 -timeout 60s ./internal/config
Expected: PASS,包括未知字段拒绝、Redacted 深拷贝和部署配置解析。
- Step 6: 更新真实配置与参考文档
在默认、本地和 Kubernetes ConfigMap 的 metrics 前加入关闭态:
controlPlane:
enabled: false
在配置参考中加入完整 loopback 示例和 mTLS 字段说明;明确默认关闭、非回环强制 mTLS、Session TTL 比例、消息/Counter 上限以及 PostgreSQL 不参与控制面状态。
- Step 7: 验证并提交配置单元
Run:
go test -count=1 -timeout 60s ./internal/config ./examples/config ./deploy
go run ./deploy/tools/configcheck deploy/config/local.yaml
git diff --check
git add internal/config/config.go internal/config/validate.go internal/config/config_test.go configs/proxy-pool.yaml deploy/config/local.yaml deploy/kubernetes/base/configmap.yaml docs/configuration/reference.md
git diff --cached --name-only
git commit -m "feat: configure worker control plane listener"
Expected: 全部 PASS,提交不包含证书或 Secret。
Task 3: 建立 Workerruntime 公共 ControlStore 契约
Files:
-
Modify:
internal/domain/workerruntime/runtime.go -
Create:
internal/domain/workerruntime/validation.go -
Create:
internal/domain/workerruntime/validation_test.go -
Modify:
internal/domain/workerruntime/memory.go -
Modify:
internal/domain/workerruntime/memory_test.go -
Create:
internal/domain/workerruntime/contracttest/contract.go -
Create:
internal/domain/workerruntime/contract_external_test.go -
Step 1: 写未 ACK Session 红灯测试
新增行为测试:OpenSession 接受 ACK 零值,Runtime 在 ACK 前返回
ErrSnapshotMismatch;记录引用并正向 ACK 后空 Runtime 成功。
session := Session{WorkerID: "worker-a", InstanceID: "instance-a", SessionID: "session-a", ProtocolVersion: 1}
if err := store.OpenSession(ctx, session, time.Minute); err != nil { t.Fatalf("OpenSession(): %v", err) }
epoch, err := store.CurrentOwnershipEpoch(ctx)
if err != nil { t.Fatalf("CurrentOwnershipEpoch(): %v", err) }
report := Report{WorkerID: "worker-a", SessionID: "session-a", Sequence: 1, SnapshotVersion: 7, OwnershipEpoch: epoch, ObservedAt: now}
if err := store.ReplaceRuntime(ctx, report, time.Minute); !errors.Is(err, ErrSnapshotMismatch) { t.Fatalf("pre-ACK error = %v", err) }
reference := SnapshotReference{WorkerID: "worker-a", Version: 7, OwnershipEpoch: epoch, Checksum: sha256.Sum256([]byte("snapshot-7"))}
if err := store.RecordIssuedSnapshot(ctx, reference, time.Minute); err != nil { t.Fatalf("RecordIssuedSnapshot(): %v", err) }
if err := store.AcknowledgeSnapshot(ctx, SnapshotAcknowledgement{WorkerID: "worker-a", SessionID: "session-a", Reference: reference, Applied: true}, time.Minute); err != nil { t.Fatalf("AcknowledgeSnapshot(): %v", err) }
if err := store.ReplaceRuntime(ctx, report, time.Minute); err != nil { t.Fatalf("ReplaceRuntime(): %v", err) }
- Step 2: 运行测试确认编译失败
Run: go test -count=1 -timeout 60s ./internal/domain/workerruntime
Expected: FAIL,缺少 ControlStore 类型和方法。
- Step 3: 定义类型、错误和接口
在 runtime.go 定义:
var (
ErrInvalidSnapshotReference = errors.New("invalid worker snapshot reference")
ErrInvalidAcknowledgement = errors.New("invalid worker snapshot acknowledgement")
ErrSnapshotMismatch = errors.New("worker snapshot does not match acknowledged state")
ErrStaleSnapshotReference = errors.New("stale worker snapshot reference")
ErrConflictingSnapshotReference = errors.New("conflicting worker snapshot reference")
ErrStaleAcknowledgement = errors.New("stale worker snapshot acknowledgement")
)
type SnapshotReference struct {
WorkerID string
Version uint64
OwnershipEpoch uint64
Checksum [sha256.Size]byte
}
type SnapshotAcknowledgement struct {
WorkerID string
SessionID string
Reference SnapshotReference
Applied bool
ErrorCode string
}
type ControlStore interface {
CurrentOwnershipEpoch(context.Context) (uint64, error)
OpenSession(context.Context, Session, time.Duration) error
RecordIssuedSnapshot(context.Context, SnapshotReference, time.Duration) error
AcknowledgeSnapshot(context.Context, SnapshotAcknowledgement, time.Duration) error
ReplaceRuntime(context.Context, Report, time.Duration) error
}
Session 增加 Zone、ProtocolVersion、克隆后的 Labels、
AckedChecksum [sha256.Size]byte 以及 RuntimeEnabled bool;OpenSession 要求
version、epoch 和 checksum 全部为零且 RuntimeEnabled=false。本 Task 先保留旧
SessionWriter 类型,仅为了让尚未迁移的
Redis Adapter 在中间提交仍可编译;Memory 测试和新代码不再调用
ReplaceSession。Task 4 迁移所有 Redis 调用后立即删除该类型和 Adapter 方法,
最终树不保留兼容 seam。
validation.go 封装所有三层共用的边界,Memory、Redis Adapter 和 Worker
Service 禁止自行复制正则或 digest 逻辑:
func ValidIdentifier(string) bool
func NormalizeLabels(map[string]string) (map[string]string, error)
func NormalizeSession(Session) (Session, error)
func NormalizeSnapshotReference(SnapshotReference) (SnapshotReference, error)
func NormalizeAcknowledgement(SnapshotAcknowledgement) (SnapshotAcknowledgement, error)
func NormalizeReport(Report) (Report, [sha256.Size]byte, error)
NormalizeLabels 返回深拷贝,并执行 32 个、64/256 字节和总计 4 KiB 上限。
NormalizeReport 转 UTC、克隆并按 Proxy ID 排序 Counters,拒绝重复 ID/负数,
再对规范 JSON 计算唯一 SHA-256 digest。Redis wire payload 可独立编码,但
Lua 比较的 digest 必须使用该公用结果。
- Step 4: 实现内存 Session 与 epoch
MemoryStore 新增 epoch uint64、references map[string]memoryReference;构造时
epoch 初始化为 1。OpenSession 总是替换同 Worker 的 Session、清除报告并复制
labels,过期时间为 now + ttl。
- Step 5: 实现内存 Snapshot 引用与 ACK
RecordIssuedSnapshot 先要求 reference.OwnershipEpoch == CurrentOwnershipEpoch(),
再按 (ownership_epoch, version) 比较当前引用:新 tuple 必须严格前进;相同
tuple + 相同 checksum 为幂等续期;相同 tuple + 不同 checksum 返回
ErrConflictingSnapshotReference;倒退返回 ErrStaleSnapshotReference。
ACK 必须按以下顺序执行:Context → 输入 → 当前 Session/TTL → 与 Session
已 ACK 上界比较 → 当前 Reference/TTL → 与 Reference 的 tuple/checksum 比较 →
负向/正向分支。ACK tuple 落后 Session 上界或当前 Reference 均返回
ErrStaleAcknowledgement;超前 Reference 或同 tuple 不同 checksum 返回
ErrSnapshotMismatch。负向 ACK 在匹配当前 Reference 时续期、清报告并设置
RuntimeEnabled=false,但不推进 ACK 上界;正向 ACK 对
version/epoch/checksum 全部相同的重放幂等,并设置
RuntimeEnabled=true。已开启 Session 的相同正向 ACK 重放只续期 Session,
不删除 Runtime 或 sequence/digest fence;若相同 ACK 上界因负向 ACK 处于
RuntimeEnabled=false,该正向 ACK 清理任何残留 Runtime 并重新开启。
倒退上界返回 ErrStaleAcknowledgement。Checksum 始终用
固定数组比较,不把内容转成日志字符串。
- Step 6: 拆分 Runtime 错误
在内存与公共规范化逻辑中,把 Snapshot version/epoch 与 ACK 不一致改为
ErrSnapshotMismatch;sequence 倒退继续返回 ErrStaleReport,相同 sequence
内容不同继续返回 ErrConflictingReport。
- Step 7: 写负向 ACK、替换和过期红灯测试
新增以下公共行为:
-
负向 ACK 续期 Session、清除旧 Runtime、ACK 上界保持零。
-
已 ACK snapshot 7 后对 snapshot 8 负向 ACK,延迟到达的 snapshot 7 Runtime 返回
ErrSnapshotMismatch;只有后续正向 ACK 才重新打开 Runtime。 -
上报 sequence 100 后重放相同正向 ACK,Runtime 仍为 Fresh,随后 sequence 99 仍返回
ErrStaleReport,证明 ACK 重放未清除 fence。 -
新 instance 注册后旧 Session 的 ACK/Runtime 返回
ErrStaleSession。 -
SnapshotReference 过期后 ACK 返回
ErrSnapshotMismatch。 -
同一正向 ACK 的 version/epoch/checksum 幂等,旧 ACK 返回
ErrStaleAcknowledgement。 -
同 tuple 不同 checksum 的 SnapshotReference 返回
ErrConflictingSnapshotReference,且当前引用不变。 -
相同 Runtime sequence 的不同内容返回
ErrConflictingReport。 -
相同 Runtime sequence + digest 幂等重放仍续期 Session 和 Report TTL。
-
Step 8: 建立共享契约并使内存实现转绿
contracttest 用封装时间推进方式的 fixture,使 Memory 与 Redis 可运行
同一契约:
type Fixture struct {
Store workerruntime.ControlStore
Reader workerruntime.RuntimeReader
OwnedProxy workerruntime.OwnedProxy
Advance func(time.Duration)
}
type Factory func(*testing.T) Fixture
func Run(t *testing.T, factory Factory)
Memory factory 的 Store 与 Reader 指向同一 MemoryStore,OwnedProxy 使用
proxy-a/worker-a 和当前 epoch,Advance 推进受控 time.Time;Redis integration
factory 先通过公共 Activity/Ownership API 创建并分配同名代理,把 assignment
写入 OwnedProxy,它的
Advance 使用有界 time.Sleep。Run 对任意 ControlStore 运行上述行为,
每个 subtest 都调用 factory 获得独立 Store/namespace,避免状态串扰。
外部测试用 NewMemoryStore 注册该契约。
Run: go test -count=1 -timeout 60s ./internal/domain/workerruntime
Expected: PASS。
- Step 9: 提交领域契约
Run:
gofmt -w internal/domain/workerruntime
go test -count=1 -timeout 60s ./...
git diff --check
git add internal/domain/workerruntime/runtime.go internal/domain/workerruntime/validation.go internal/domain/workerruntime/validation_test.go internal/domain/workerruntime/memory.go internal/domain/workerruntime/memory_test.go internal/domain/workerruntime/contracttest/contract.go internal/domain/workerruntime/contract_external_test.go
git diff --cached --name-only
git commit -m "feat: define worker control store contract"
Expected staged files 精确等于上述 7 个路径,且全仓测试 PASS。
Task 4: 用 Redis Lua 原子实现 ControlStore
Files:
-
Modify:
internal/adapters/redisactivity/keys.go -
Modify:
internal/adapters/redisactivity/adapter_test.go -
Modify:
internal/adapters/redisactivity/scripts.go -
Modify:
internal/adapters/redisactivity/runtime.go -
Modify:
internal/adapters/redisactivity/scripts/runtime.lua -
Modify:
internal/adapters/redisactivity/scripts/ownership.lua -
Modify:
internal/adapters/redisactivity/ownership_integration_test.go -
Modify:
internal/adapters/redisactivity/runtime_integration_test.go -
Modify:
internal/adapters/redisactivity/capacity_integration_test.go -
Step 1: 让 Redis Adapter 运行共享契约并确认失败
在 runtime_integration_test.go 的 Redis 8.2 fixture 中断言
redisactivity.Adapter 实现 workerruntime.ControlStore。每次 factory 调用生成唯一
namespace;仅在验证跨 Controller fence 的专项 subtest 内,两个 Adapter
共享该 subtest 的 namespace。工厂传入 workerruntime/contracttest.Run。
Run:
./scripts/test-redis.ps1
Expected: FAIL,Adapter 缺少新方法。
- Step 2: 增加同槽位 Snapshot key
keyspace 新增:
workerSnapshots string
workerSnapshotExpiry string
值分别为 prefix + ":worker-snapshots" 与
prefix + ":worker-snapshot-expiry",继续共享 pp:{activity}: hash tag。
adapter_test.go 更新完整 keyspace 断言,并对 Session、Snapshot、Runtime、
Owner 和 epoch 全部 key 执行 Redis Cluster slot 一致性检查。
- Step 3: 增加明确脚本状态与 Wire DTO
新增 scriptSnapshotMismatch = "snapshot_mismatch"、
scriptStaleAcknowledgement = "stale_acknowledgement"。Wire DTO 中所有 uint64
继续使用十进制字符串,checksum 使用 64 字符小写 hex,Go 边界仍是 [32]byte。
runtimeSessionWire 新增 ackedChecksum 和 runtimeEnabled:未 ACK Session 表示为
version="0"、epoch="0"、checksum=""、runtimeEnabled=false;已 ACK 上界
必须为两个正数和 64 位 checksum。runtimeEnabled=false 也可与非零旧 ACK
上界共存,表示新 Snapshot 负向 ACK 后的 fail-closed 状态。
- Step 4: 扩展 Lua 当前 epoch 与 Session 注册
current_epoch 分支执行 SETNX epoch_key '1'、PERSIST epoch_key 后返回
当前十进制 epoch。ownership.lua 的 Assign/Renew 不再对全局 epoch_key
执行 PEXPIREAT;Assign 在 INCR 后执行 PERSIST,Renew 也对现有 key
执行 PERSIST,以迁移历史带 TTL 的 namespace。该 namespace 只保留这一个
持久单调标量,避免 TTL 到期后 epoch 回退造成 ABA。
open_session 分支必须:
- 校验 ACK version/epoch 都是字符串
"0"、ackedChecksum == ""且runtimeEnabled == false。 - 删除该 Worker runtime 和 runtime expiry。
- 写入新 Session 与服务端时间 expiry。
- 不读取客户端时间决定 TTL。
- Step 5: 实现 SnapshotReference 原子写入
record_snapshot 校验 version/epoch 正数、checksum 为 64 位小写 hex,且
epoch 精确等于当前持久 epoch_key。它按 (epoch, version) 实现与 Memory
相同的严格单调规则:同 tuple 同 checksum 幂等续期,同 tuple 不同 checksum
返回 conflict,倒退返回 stale,两种失败都不修改 Hash/ZSET。
成功时写入 Worker Hash 并以 Redis TIME 设置 ZSET expiry。每次 Lua 调用先用
ZRANGEBYSCORE ... LIMIT 0 cleanup_limit 有界删除过期 Snapshot Hash/ZSET 条目;
新引用覆盖旧引用。
- Step 6: 实现 ACK 原子状态机
ack_snapshot 在一个 Lua 调用中完成:
-- 校验当前 session 与 snapshot reference 后:
if applied == false then
redis.call('HDEL', runtime_key, worker_id)
redis.call('ZREM', runtime_expiry_key, worker_id)
session.runtimeEnabled = false
session.expiresAtMs = now + ttl_ms
redis.call('HSET', sessions_key, worker_id, cjson.encode(session))
redis.call('ZADD', session_expiry_key, session.expiresAtMs, worker_id)
return cjson.encode({status = 'ok'})
end
-- 先判断:倒退返回 stale_acknowledgement。
-- 相同正向 ACK + runtimeEnabled=true:只续期 Session,不修改 Runtime/fence。
-- 相同正向 ACK + runtimeEnabled=false:清理 Runtime并重新开启。
-- 严格前进正向 ACK:清理 Runtime、保存 version/epoch/checksum 并开启。
引用不匹配、引用过期或未发布统一返回 snapshot_mismatch;Session 缺失/替换返回
unavailable,Go 映射为 ErrStaleSession。
- Step 7: 修改 Runtime 分支错误分类
Session 未 ACK、runtimeEnabled=false 或报告 version/epoch 不等于 ACK 时返回
snapshot_mismatch。sequence
倒退仍返回 stale,digest 冲突仍返回 conflict。相同 sequence + digest
的幂等重放不能直接 return;它要用当前 Redis TIME 重写 Session/Report
expiresAtMs 与两个 expiry ZSET score。新 sequence 成功时完整替换报告,
并同时续期 Session 与报告 TTL。
- Step 8: 实现 Go Adapter 方法与映射
runtime.go 实现五个 ControlStore 方法;所有调用经过 Context、nil Adapter、
固定长度、数量上限和 TTL 校验。CurrentOwnershipEpoch 解析字符串时拒绝零值、负数
和超出 uint64 的响应。
字段规范化和 Runtime digest 一律调用 workerruntime 公用方法,Adapter 只追加
MaxRuntimeCounters 配置上限和 wire 编码。
runRuntime 对 Lua 始终传入以下同槽 key 顺序,所有分支共用该索引:
[]string{
a.keys.workerSessions,
a.keys.workerSessionExpiry,
a.keys.workerSnapshots,
a.keys.workerSnapshotExpiry,
a.keys.workerRuntime,
a.keys.workerRuntimeExpiry,
a.keys.owners,
a.keys.epoch,
}
capacity_integration_test.go 不再调用已删除的 ReplaceSession;共用测试 helper
按 OpenSession -> RecordIssuedSnapshot -> AcknowledgeSnapshot 建立已 ACK Session,
再执行原有 capacity 断言。
迁移 runtime_integration_test.go 和 capacity_integration_test.go 后,删除
workerruntime.SessionWriter 以及 Redis Adapter 的 ReplaceSession;保留名称不变的
ReportWriter.ReplaceRuntime 和 RuntimeReader,因为它们仍是有效的窄端口。
- Step 9: 增加真实 Redis 并发与故障测试
新增:
-
两个 Adapter 同 namespace 注册同 Worker,新 Session 隔离旧 Session。
-
100 个并发相同 ACK 全部幂等成功,最终只有一个 ACK 上界。
-
相同 Snapshot tuple 换 checksum 返回冲突,旧 Reference/Session/Runtime 完全不变。
-
Snapshot 8 负向 ACK 后,延迟的 snapshot 7 Runtime 在两个 Adapter 上都被
snapshot_mismatch拒绝,不得重建已清除的 sequence/report。 -
sequence 100 后幂等重放当前正向 ACK,旧 Runtime 仍 Fresh,sequence 99 仍被拒绝,证明 ACK 重放未清除 Runtime fence。
-
正向 ACK 校验失败后 Session/Runtime/Reference 均无部分写入。
-
100ms Session/Snapshot TTL 到期后 fail-closed。
-
人为给 epoch key 设置短 TTL 后调用
CurrentOwnershipEpoch,断言 key 变为 persistent;后续分配的 epoch 严格增长,旧 Runtime 对新归属仍为 not Fresh。 -
Redis 断开时五个方法返回 Store unavailable 类错误而非 panic。
-
Step 10: 验证并提交 Redis 单元
Run:
go test -count=1 -timeout 60s ./internal/adapters/redisactivity ./internal/domain/workerruntime
./scripts/test-redis.ps1
go test -count=1 -timeout 60s ./...
git diff --check
git add internal/domain/workerruntime/runtime.go internal/adapters/redisactivity/keys.go internal/adapters/redisactivity/adapter_test.go internal/adapters/redisactivity/scripts.go internal/adapters/redisactivity/runtime.go internal/adapters/redisactivity/scripts/runtime.lua internal/adapters/redisactivity/scripts/ownership.lua internal/adapters/redisactivity/ownership_integration_test.go internal/adapters/redisactivity/runtime_integration_test.go internal/adapters/redisactivity/capacity_integration_test.go
git diff --cached --name-only
git commit -m "feat: persist worker sessions and snapshot acknowledgements"
Expected: 内存/Redis 共用契约与真实 Redis 8.2 集成测试 PASS。
Task 5: 实现 Worker 公共应用服务
Files:
-
Create:
internal/controller/worker/service.go -
Create:
internal/controller/worker/service_test.go -
Step 1: 写注册服务红灯测试
使用记录型 ControlStore 与确定性 Session ID 生成器,验证:协议版本 1、响应
返回 heartbeat/max stale age、OpenSession 收到配置的 SessionTTL、Session ACK
version/epoch/checksum 零值以及 Current epoch 透传。Proto 响应不断言不存在的
SessionTTL 字段。
- Step 2: 运行测试确认失败
Run: go test -count=1 -timeout 60s ./internal/controller/worker
Expected: FAIL,package 或 NewService 尚不存在。
- Step 3: 定义公共命令、结果和选项
service.go 定义:
type RegisterCommand struct {
WorkerID string
InstanceID string
Zone string
ProtocolVersion uint32
Labels map[string]string
}
type SnapshotAcknowledgement struct {
WorkerID string
SessionID string
Version uint64
OwnershipEpoch uint64
Checksum []byte
Applied bool
ErrorCode string
ErrorMessage string
}
type Registration struct {
WorkerID string
SessionID string
OwnershipEpoch uint64
HeartbeatInterval time.Duration
MaxStaleAge time.Duration
}
type RuntimeDecision struct {
AcceptedOwnershipEpoch uint64
RequireFullSnapshot bool
}
type Options struct {
ProtocolVersion uint32
HeartbeatInterval time.Duration
SessionTTL time.Duration
MaxStaleAge time.Duration
MaxRuntimeCounters int
SessionID func() (string, error)
}
type Service interface {
Register(context.Context, RegisterCommand) (Registration, error)
Acknowledge(context.Context, SnapshotAcknowledgement) error
ReportRuntime(context.Context, workerruntime.Report) (RuntimeDecision, error)
}
var (
ErrInvalidCommand = errors.New("invalid worker control command")
ErrProtocolVersion = errors.New("unsupported worker protocol version")
ErrUnavailable = errors.New("worker control service unavailable")
)
默认 Session ID 生成器使用 crypto/rand.Read 读取 16 字节并编码成 32 字符小写
hex;失败必须返回内部错误,不降级到时间戳或伪随机数。
NewService 返回实现上述接口的非导出类型,调用方不依赖具体结构。
服务的 ID、labels、Session、ACK 和 Runtime 校验调用 workerruntime 公用规范化
方法,并把领域的 invalid sentinel 统一包装为 ErrInvalidCommand。
- Step 4: 实现 Register
验证 ID pattern、Zone、32 个标签和 4 KiB 总长度;读取 epoch、生成 Session、调用
OpenSession。存储错误对上层只暴露稳定的 ErrUnavailable,原始错误只用于
errors.Is 链,不包含 Session ID。
- Step 5: 写并实现 ACK 行为
测试并实现:32 字节 checksum、error code pattern、512 字节 message 上限、正向/
负向 ACK 调用 ControlStore;Service 校验 ErrorMessage 的字节长度后,构造
workerruntime.SnapshotAcknowledgement时只传入 ErrorCode,不向 Store 传递
自由文本。
- Step 6: 写并实现 Runtime 决策
ReportRuntime 对 ErrSnapshotMismatch 返回:
RuntimeDecision{AcceptedOwnershipEpoch: currentEpoch, RequireFullSnapshot: true}, nil
成功返回当前 epoch 和 RequireFullSnapshot:false;stale/conflict/session/storage 错误
保持不同 sentinel,供 gRPC 层映射。
- Step 7: 覆盖边界与并发
测试协议不兼容、Session ID 生成失败、重复 Proxy ID、Counter 超限、Context 取消、 空 Runtime 心跳、Store 不可用和 100 个并发注册最终只有最后 Session 有效。
- Step 8: 验证并提交服务
Run:
gofmt -w internal/controller/worker
go test -count=1 -timeout 60s ./internal/controller/worker
git diff --check
git add internal/controller/worker/service.go internal/controller/worker/service_test.go
git diff --cached --name-only
git commit -m "feat: handle worker sessions and runtime reports"
Task 6: 实现 gRPC Handler、SPIFFE 身份与 Server Runner
Files:
-
Create:
internal/controller/worker/grpc_handler.go -
Create:
internal/controller/worker/grpc_handler_test.go -
Create:
internal/controller/worker/identity.go -
Create:
internal/controller/worker/identity_test.go -
Create:
internal/controller/worker/server.go -
Create:
internal/controller/worker/server_test.go -
Step 1: 写 bufconn 注册红灯测试
使用生成的 NewWorkerControlPlaneClient 连接 bufconn,调用 RegisterWorker 并断言
DTO、Duration 和 Session;测试通过真实 gRPC 编解码,不直接调用 Handler 方法。
- Step 2: 运行测试确认失败
Run: go test -count=1 -timeout 60s ./internal/controller/worker -run TestGRPC
Expected: FAIL,缺少 gRPC Handler。
- Step 3: 实现 Handler 和 DTO 映射
GRPCHandler 嵌入生成的 UnimplementedWorkerControlPlaneServer,只覆盖三个 RPC。
AcknowledgeSnapshot 使用 emptypb.Empty;timestamp 用 AsTime 前先调用
CheckValid;Runtime uint32 安全转换为 int64。
Handler 只依赖公共接口,并在进入 Service 前完成身份校验:
type IdentityAuthorizer interface {
Authorize(context.Context, string) error
}
type GRPCHandler struct {
controlplanev1.UnimplementedWorkerControlPlaneServer
service Service
identity IdentityAuthorizer
}
三个 RPC 都以请求中的 worker_id 调用 Authorize;身份失败固定映射
codes.PermissionDenied,不把证书主体或 URI 写入 gRPC message。
- Step 4: 固定 gRPC 状态码映射
实现唯一映射:
switch {
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, "worker control request canceled")
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, "worker control request deadline exceeded")
case errors.Is(err, ErrInvalidCommand),
errors.Is(err, workerruntime.ErrInvalidReport),
errors.Is(err, workerruntime.ErrInvalidAcknowledgement),
errors.Is(err, workerruntime.ErrInvalidSnapshotReference):
return status.Error(codes.InvalidArgument, "invalid worker control request")
case errors.Is(err, ErrProtocolVersion):
return status.Error(codes.FailedPrecondition, "unsupported worker protocol version")
case errors.Is(err, workerruntime.ErrStaleSession):
return status.Error(codes.FailedPrecondition, "worker session is stale")
case errors.Is(err, workerruntime.ErrSnapshotMismatch):
return status.Error(codes.FailedPrecondition, "worker snapshot does not match issued snapshot")
case errors.Is(err, workerruntime.ErrStaleAcknowledgement):
return status.Error(codes.Aborted, "worker snapshot acknowledgement is stale")
case errors.Is(err, workerruntime.ErrStaleReport):
return status.Error(codes.Aborted, "worker runtime sequence is stale")
case errors.Is(err, workerruntime.ErrConflictingReport):
return status.Error(codes.AlreadyExists, "worker runtime sequence conflicts")
default:
return status.Error(codes.Unavailable, "worker control plane unavailable")
}
- Step 5: 验证两个未实现 RPC
通过生成 Client 调用 WatchSnapshots,断言首次 Recv() 返回
codes.Unimplemented;调用 ReportOutcomes后执行 CloseAndRecv(),断言
codes.Unimplemented。不要添加空成功响应。
- Step 6: 写 SPIFFE 身份红灯测试
测试 URI SAN spiffe://proxy.example/prod/worker/worker-a 可授权 worker-a,以下输入
返回 PermissionDenied:缺失 TLSInfo、缺失 URI SAN、错误 trust domain/environment、
Checker 类型、请求 worker ID 不一致、多个冲突 Worker URI。
- Step 7: 实现 IdentityAuthorizer
从 peer.FromContext 取得 credentials.TLSInfo,只读取已验证链叶子证书 URI SAN。
使用 URL path segment 精确比较,不做 substring 或前缀授权。plaintext mode 注入
AllowLoopbackIdentity,只在配置已验证为回环监听时构造。
- Step 8: 写 Server Runner 红灯测试
测试 Run(ctx) 在 127.0.0.1:0 启动、Context 取消后有界退出、占用端口时报错;
mTLS 测试运行时生成 CA/Server/Worker 证书,不提交 .pem 或 .key。
另用预绑定 net.Listener 调用 Serve(ctx, listener),通过
listener.Addr() 发起真实 Client 请求。
真实 gRPC Client 测试还覆盖:
-
超过
MaxMessageBytes的 Register 请求返回codes.ResourceExhausted。 -
正确 CA + Worker URI SAN 完成 mTLS 握手并调用成功。
-
缺失客户端证书、错误 CA 在握手期失败。
-
合法证书但 Worker ID 不一致时 RPC 返回
codes.PermissionDenied。 -
服务依赖返回
context.Canceled/context.DeadlineExceeded时,Client 分别收到codes.Canceled/codes.DeadlineExceeded。 -
Step 9: 实现 gRPC Server
NewServer 配置:
grpc.MaxRecvMsgSize/MaxSendMsgSizegrpc.MaxConcurrentStreams- keepalive enforcement 最小 ping 10 秒、无活动连接禁止 ping
- mTLS 使用
tls.RequireAndVerifyClientCert和独立 Client CA pool - Context 取消后 goroutine 执行
GracefulStop,到 ShutdownTimeout 后调用Stop
type ServerOptions struct {
ShutdownTimeout time.Duration
}
func DefaultServerOptions() ServerOptions {
return ServerOptions{ShutdownTimeout: 15 * time.Second}
}
NewServer 对零值应用 15 秒默认值,对负值返回 ErrInvalidServer。
Server 同时提供生产绑定与可测试绑定:
func (server *Server) Run(ctx context.Context) error
func (server *Server) Serve(ctx context.Context, listener net.Listener) error
Run 使用已验证的 controlPlane.listen 创建 TCP Listener,然后委托 Serve;
Serve 拥有传入 Listener 的关闭责任,且拒绝 nil Context/Listener。
- Step 10: 验证并提交传输单元
Run:
gofmt -w internal/controller/worker
go test -count=1 -timeout 60s ./internal/controller/worker
git diff --check
git add internal/controller/worker/grpc_handler.go internal/controller/worker/grpc_handler_test.go internal/controller/worker/identity.go internal/controller/worker/identity_test.go internal/controller/worker/server.go internal/controller/worker/server_test.go
git diff --cached --name-only
git commit -m "feat: serve worker control plane grpc"
Task 7: 接入 Controller Infrastructure 与 Lifecycle
Files:
-
Modify:
internal/controller/bootstrap/infrastructure.go -
Modify:
internal/controller/bootstrap/infrastructure_test.go -
Modify:
internal/controller/bootstrap/bootstrap.go -
Modify:
internal/controller/bootstrap/bootstrap_test.go -
Modify:
internal/controller/bootstrap/bootstrap_integration_test.go -
Step 1: 写 Infrastructure 红灯测试
验证 controlPlane.enabled=true 时,即使 Distribution/Admin/Provider 都关闭,也会
打开 Redis、构造 workerruntime.ControlStore 和 Redis readiness;Redis URL 缺失或
Ping 失败时启动失败。
- Step 2: 修改 ports 与 Redis 打开条件
ports 新增:
workerControl workerruntime.ControlStore
Redis 条件改为:
configuration.Distribution.Enabled || configuration.Admin.Enabled ||
configuration.ControlPlane.Enabled || providersEnabled
同一个 redisactivity.Adapter 同时赋给 opened.activity 与
opened.workerControl,不创建第二客户端或第二 namespace。
Adapter 的 Runtime 批次上限由公用 helper 取当前容量与 ControlPlane 上限
的较大值,避免 control-plane-only 配置退化到 1:
func runtimeCounterCapacity(configuration *config.Config) int {
capacity := credentialCapacity(configuration)
if configuration.ControlPlane.Enabled && configuration.ControlPlane.MaxRuntimeCounters > capacity {
return configuration.ControlPlane.MaxRuntimeCounters
}
return capacity
}
redisactivity.Options.MaxRuntimeCounters 只使用该 helper。表驱动测试覆盖
control-plane-only 100000、Provider/Gateway 容量较大以及 ControlPlane 关闭三种情况。
- Step 3: 写 Bootstrap 红灯测试
使用记录型 worker runner factory,验证开启 controlPlane 时构造 Service 与 Runner;
缺少 ControlStore、Runner factory 返回 nil、构造错误均返回 ErrStartup。
- Step 4: 接入 Worker Service 与 Runner
新增独立 workerRuntimeFactory seam,生产实现调用 worker.NewServer。将
Bootstrap 内部签名固定为:
type workerRuntimeFactory interface {
New(config.ControlPlane, worker.Service) (controllerRunner, error)
}
func run(
ctx context.Context,
options Options,
infrastructure infrastructure,
factory runtimeFactory,
workerFactory workerRuntimeFactory,
) error
Run 传入 productionRuntimeFactory{} 和 productionWorkerRuntimeFactory{};所有现有
Bootstrap 测试显式传入记录型 Worker factory。Bootstrap 使用配置构造
worker.Options,并把 Worker Runner 追加到 runners:
if loaded.Value.ControlPlane.Enabled {
if nilInterface(opened.workerControl) { return errors.Join(ErrStartup, ErrInvalidOptions) }
controlPlane := loaded.Value.ControlPlane
service, err := worker.NewService(opened.workerControl, worker.Options{
ProtocolVersion: controlPlane.ProtocolVersion,
HeartbeatInterval: controlPlane.HeartbeatInterval.Value(),
SessionTTL: controlPlane.SessionTTL.Value(),
MaxStaleAge: controlPlane.MaxStaleAge.Value(),
MaxRuntimeCounters: controlPlane.MaxRuntimeCounters,
})
if err != nil { return fmt.Errorf("%w: build Worker service: %w", ErrStartup, err) }
runner, err := workerFactory.New(controlPlane, service)
if err != nil { return fmt.Errorf("%w: build Worker gRPC runtime: %w", ErrStartup, err) }
if nilInterface(runner) { return errors.Join(ErrStartup, ErrInvalidOptions) }
runners = append(runners, runner)
}
productionWorkerRuntimeFactory.New 调用
worker.NewServer(controlPlane, service, worker.DefaultServerOptions()),不在 Bootstrap 复制
TLS/listener 构造逻辑。
- Step 5: 把控制面纳入 Readiness
selectMetricsReadiness 在 controlPlane 启用时至少检查 Redis。已有 Distribution 或
Provider 时继续复用 Redis readiness;Admin-only 规则保持不变。测试 Redis 失败使
/readyz 返回 503,但 /livez 仍返回 200。
- Step 6: 测试生命周期联动
启动 HTTP、gRPC、Provider 三个记录型 Runner;任一 Runner 返回错误时,其余 Context 都被取消;正常取消时三者全部有界退出,资源关闭只执行一次。
- Step 7: 扩展真实 Controller fixture
在 bootstrap_integration_test.go 用 loopback plaintext controlPlane 和真实 Redis
启动 Controller。集成测试的 workerRuntimeFactory 预绑定
127.0.0.1:0,返回调用 server.Serve(ctx, listener) 的 Runner,并向测试暴露
listener.Addr()。通过该真实地址创建生成 gRPC Client 完成 Register。测试另用
redisactivity.New 连接同一 fixture Redis 和同一 namespace,先通过现有
UpsertFetched 和两次 ApplyHealth 公共方法把 proxy-a 推进到
checking -> available,再调用 Assign(ctx, now, "proxy-a", "worker-a", ttl) 获得
权威 ownership epoch,并用该 epoch 调用 RecordIssuedSnapshot。通过 gRPC 完成
ACK 和 counters 为空的 Runtime 后,用测试 Adapter 调用:
ReadRuntime(ctx, []workerruntime.OwnedProxy{{
ProxyID: "proxy-a", WorkerID: "worker-a", OwnershipEpoch: assignment.Epoch,
}})
断言返回 Fresh:true、Active:0、Reserved:0,以公共 seam 证明空报告
既是完整稀疏替换,也完成 Session 心跳续期。
- Step 8: 验证并提交 Bootstrap 单元
Run:
gofmt -w internal/controller/bootstrap
go test -count=1 -timeout 60s ./internal/controller/bootstrap
./scripts/test-controller.ps1
git diff --check
git add internal/controller/bootstrap/infrastructure.go internal/controller/bootstrap/infrastructure_test.go internal/controller/bootstrap/bootstrap.go internal/controller/bootstrap/bootstrap_test.go internal/controller/bootstrap/bootstrap_integration_test.go
git diff --cached --name-only
git commit -m "feat: run worker grpc control plane in controller"
Task 8: 更新契约文档、实施进度与质量门禁
Files:
-
Modify:
docs/api/control-plane.md -
Modify:
docs/development/implementation-plan.md -
Modify:
docs/requirements/traceability.md -
Modify:
docs/requirements/completion-audit.md -
Modify:
docs/adr/005-redis-activity-pool.md -
Modify:
README.md -
Step 1: 更新准确状态
文档必须明确:
-
Register/ACK/Runtime gRPC 已实现并有 Redis 8.2 证据。
-
Runtime 空报告承担心跳,Session/ACK/report 使用 Redis 服务端 TTL。
-
负向 ACK 关闭 Session Runtime 写入栅栏,直到新正向 ACK,延迟旧报告不可恢复。
-
WatchSnapshots/ReportOutcomes 仍返回 Unimplemented。
-
Gateway 命令、Snapshot payload/stream、Outcome 和 Checker 仍未闭环。
-
100,000 QPS 仍是未验证设计目标。
-
Step 2: 更新实施计划检查项
只勾选 Task 10 的“Worker heartbeat receiving path and session lifecycle”。不得勾选
完整 Snapshot、Gateway 客户端、Checker 或部署拓扑任务。完成数从
52/74 (70.3%) 更新为 53/74 (71.6%),README、完成度审计和实施计划
使用同一统计日期 2026-07-31。
- Step 3: 校准 Kubernetes 清单
Controller 8443 端口和 NetworkPolicy 保留,但 ConfigMap 继续
controlPlane.enabled:false,直到环境 Overlay 提供 mTLS 文件、trust domain 与
environment。本步只读核对 controller.yaml 和 networkpolicy.yaml,不修改、
不暂存;文档说明静态端口不代表控制面默认启用。
- Step 4: 执行定向测试
Run:
go test -count=1 -timeout 60s ./internal/domain/workerruntime ./internal/adapters/redisactivity ./internal/controller/worker ./internal/controller/bootstrap ./internal/config ./docs ./deploy
./scripts/test-redis.ps1
./scripts/test-controller.ps1
./scripts/verify-proto.ps1
Expected: 全部 PASS。
- Step 5: 执行全仓质量门禁
Run:
./scripts/verify.ps1
go test -count=1 -timeout 60s ./...
go vet ./...
go build ./...
git diff --check
Expected: 全部 PASS;本机 CGO 关闭时明确记录 race 由 Linux CI 执行。
- Step 6: 执行边界扫描
Run:
$gatewayMatches = rg -n "redis|postgres|grpc" internal/gateway
if ($LASTEXITCODE -gt 1) { throw "gateway boundary scan failed" }
$gatewayMatches
rg -n "session_id|worker_id|proxy_id|proxy_ip" internal/platform/metrics internal/controller/worker
git status --short
Expected: Gateway 没有新增存储或 gRPC Client 导入;指标没有高基数标签;工作树只含 本任务预期文件。
- Step 7: 提交并推送
Run:
git add README.md docs/api/control-plane.md docs/development/implementation-plan.md docs/requirements/traceability.md docs/requirements/completion-audit.md docs/adr/005-redis-activity-pool.md
git diff --cached --name-only
git commit -m "docs: record worker control plane delivery"
git push origin build/proxy-pool-architecture
git rev-list --left-right --count origin/build/proxy-pool-architecture...HEAD
Expected: 最终输出 0 0,远端分支与本地同步。