feat: add durable Worker execution and WSS server
This commit is contained in:
25
pulse/model/attempt_evidence.go
Normal file
25
pulse/model/attempt_evidence.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
|
||||
// AttemptEvidence 保留迟到或冲突结果,作为核查证据;保存证据不会改写任务结果。
|
||||
type AttemptEvidence struct {
|
||||
// Record 包含命名空间、证据标识、并发版本与首次收到时间。
|
||||
core.Record
|
||||
// AttemptID 证据所属尝试,提交者仍需证明原执行秘密与进程身份。
|
||||
AttemptID core.ID `db:"attempt_id"`
|
||||
// MessageID 稳定结果消息标识,用于同一证据重报去重。
|
||||
MessageID core.ID `db:"message_id"`
|
||||
// Outcome Worker 报告的结果分类,不代表平台认可为任务终态。
|
||||
Outcome string `db:"outcome"`
|
||||
// ErrorCode 可选稳定错误分类;无错误分类时为空。
|
||||
ErrorCode *string `db:"error_code"`
|
||||
// Payload 结果或证据引用 JSON 文本,保留数字精度。
|
||||
Payload core.JSON `db:"payload"`
|
||||
// RequestDigest 结果语义摘要,排除会话身份与原始秘密。
|
||||
RequestDigest core.Digest `db:"request_digest"`
|
||||
// Reason 未用于更新权威状态的原因,例如授权失效或与已保存结果冲突。
|
||||
Reason string `db:"reason"`
|
||||
}
|
||||
|
||||
func (AttemptEvidence) TableName() string { return "pulse_attempt_evidence" }
|
||||
25
pulse/model/worker_instance.go
Normal file
25
pulse/model/worker_instance.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
|
||||
// WorkerInstance 保存进程实例的持久化会话头;重连推进代次,不能仅凭实例 ID 接管。
|
||||
type WorkerInstance struct {
|
||||
// Record 包含命名空间、标识、并发版本与 UTC 时间。
|
||||
core.Record
|
||||
// CredentialID 初次注册的凭据,当前进程生命周期内不可替换。
|
||||
CredentialID core.ID `db:"credential_id"`
|
||||
// QueueID 进程绑定的消费队列;切换队列应创建新实例。
|
||||
QueueID core.ID `db:"queue_id"`
|
||||
// SecretDigest 进程持有的随机恢复秘密摘要;知道实例 ID 不构成恢复授权。
|
||||
SecretDigest core.Digest `db:"secret_digest"`
|
||||
// RegistrationDigest 队列与处理器版本集合的摘要,重连不得改变注册语义。
|
||||
RegistrationDigest core.Digest `db:"registration_digest"`
|
||||
// SessionEpoch 已分配的最大连接代次,初始为零,重连递增。
|
||||
SessionEpoch uint64 `db:"session_epoch"`
|
||||
// CurrentSessionID 当前连接;注册事务创建会话之前为空。
|
||||
CurrentSessionID *core.ID `db:"current_session_id"`
|
||||
// Draining 持久化排空意图,重连保留;排空后不再给该进程新工作。
|
||||
Draining bool `db:"draining"`
|
||||
}
|
||||
|
||||
func (WorkerInstance) TableName() string { return "pulse_worker_instances" }
|
||||
139
pulse/worker/service.go
Normal file
139
pulse/worker/service.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"sort"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
var StaleSession = fault.New("STALE_SESSION", "connection is expired or superseded")
|
||||
var LeaseLost = fault.New("LEASE_LOST", "attempt no longer grants execution authority")
|
||||
var GateClosed = fault.New("EXECUTION_PAUSED", "queue or execution gate is paused")
|
||||
|
||||
type Service struct{ store Store }
|
||||
|
||||
func New(store Store) *Service { return &Service{store: store} }
|
||||
func (s *Service) Authorize(ctx context.Context, t auth.Token) error {
|
||||
return s.store.Authorize(ctx, t)
|
||||
}
|
||||
func NewSecret() (string, core.Digest, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", core.Digest{}, err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw[:]), core.Digest(sha256.Sum256(raw[:])), nil
|
||||
}
|
||||
func SecretDigest(secret string) (core.Digest, error) {
|
||||
if len(secret) != 43 {
|
||||
return core.Digest{}, fault.Unauthorized
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.Strict().DecodeString(secret)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return core.Digest{}, fault.Unauthorized
|
||||
}
|
||||
return core.Digest(sha256.Sum256(raw)), nil
|
||||
}
|
||||
func NormalizeConnect(in ConnectInput) (ConnectInput, error) {
|
||||
if in.InstanceID.Validate() != nil || in.QueueID.Validate() != nil || in.Capacity < 1 || in.Capacity > 256 || len(in.Capabilities) < 1 || len(in.Capabilities) > 32 {
|
||||
return in, fault.Invalid("invalid worker identity, queue, capacity or capability count")
|
||||
}
|
||||
if _, err := SecretDigest(in.InstanceSecret); err != nil {
|
||||
return in, fault.Invalid("instance_secret must encode 32 random bytes")
|
||||
}
|
||||
in.Capabilities = append([]Capability(nil), in.Capabilities...)
|
||||
for _, c := range in.Capabilities {
|
||||
if !service.ValidName(c.Handler, 128) || !service.ValidName(c.HandlerVersion, 64) || !service.ValidName(c.PayloadVersion, 32) {
|
||||
return in, fault.Invalid("invalid worker capability")
|
||||
}
|
||||
}
|
||||
sort.Slice(in.Capabilities, func(i, j int) bool {
|
||||
a, b := in.Capabilities[i], in.Capabilities[j]
|
||||
if a.Handler != b.Handler {
|
||||
return a.Handler < b.Handler
|
||||
}
|
||||
if a.HandlerVersion != b.HandlerVersion {
|
||||
return a.HandlerVersion < b.HandlerVersion
|
||||
}
|
||||
return a.PayloadVersion < b.PayloadVersion
|
||||
})
|
||||
for i := 1; i < len(in.Capabilities); i++ {
|
||||
if in.Capabilities[i] == in.Capabilities[i-1] {
|
||||
return in, fault.Invalid("duplicate worker capability")
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
func (s *Service) Connect(ctx context.Context, t auth.Token, in ConnectInput) (Binding, Welcome, error) {
|
||||
in, err := NormalizeConnect(in)
|
||||
if err != nil {
|
||||
return Binding{}, Welcome{}, err
|
||||
}
|
||||
return s.store.Connect(ctx, t, in)
|
||||
}
|
||||
func (s *Service) Capacity(ctx context.Context, t auth.Token, b Binding, in CapacityInput) (Welcome, error) {
|
||||
if in.Sequence == 0 || in.Capacity > 256 {
|
||||
return Welcome{}, fault.Invalid("invalid capacity sequence or value")
|
||||
}
|
||||
return s.store.Capacity(ctx, t, b, in)
|
||||
}
|
||||
func (s *Service) Claim(ctx context.Context, t auth.Token, b Binding) (*Assignment, error) {
|
||||
return s.store.Claim(ctx, t, b)
|
||||
}
|
||||
func validRef(ref AttemptRef) error {
|
||||
if ref.TaskID.Validate() != nil || ref.AttemptID.Validate() != nil || ref.Epoch == 0 {
|
||||
return fault.Invalid("invalid attempt identity")
|
||||
}
|
||||
_, err := SecretDigest(ref.LeaseSecret)
|
||||
return err
|
||||
}
|
||||
func validCommand(message core.ID, ref AttemptRef) error {
|
||||
if message.Validate() != nil {
|
||||
return fault.Invalid("invalid operation message id")
|
||||
}
|
||||
return validRef(ref)
|
||||
}
|
||||
func (s *Service) Start(ctx context.Context, t auth.Token, b Binding, message core.ID, ref AttemptRef) (CommandResult, error) {
|
||||
if err := validCommand(message, ref); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.store.Start(ctx, t, b, message, ref)
|
||||
}
|
||||
func (s *Service) Renew(ctx context.Context, t auth.Token, b Binding, message core.ID, ref AttemptRef) (CommandResult, error) {
|
||||
if err := validCommand(message, ref); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return s.store.Renew(ctx, t, b, message, ref)
|
||||
}
|
||||
func (s *Service) Finish(ctx context.Context, t auth.Token, b Binding, message core.ID, in FinishInput) (CommandResult, error) {
|
||||
if err := validCommand(message, in.Ref); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if in.Outcome != "SUCCEEDED" && in.Outcome != "FAILED" && in.Outcome != "UNKNOWN" {
|
||||
return CommandResult{}, fault.Invalid("invalid result outcome")
|
||||
}
|
||||
if len(in.Result) == 0 || len(in.Result) > 256<<10 || bytes.Equal(bytes.TrimSpace(in.Result), []byte("null")) {
|
||||
return CommandResult{}, fault.Invalid("result must be non-null JSON up to 256 KiB")
|
||||
}
|
||||
if _, err := jsonvalue.Canonical(in.Result); err != nil {
|
||||
return CommandResult{}, fault.Invalid("invalid result JSON")
|
||||
}
|
||||
if (in.Outcome == "SUCCEEDED" && in.ErrorCode != "") || (in.Outcome != "SUCCEEDED" && !service.ValidName(in.ErrorCode, 64)) {
|
||||
return CommandResult{}, fault.Invalid("error_code must match the outcome")
|
||||
}
|
||||
return s.store.Finish(ctx, t, b, message, in)
|
||||
}
|
||||
func (s *Service) Resume(ctx context.Context, t auth.Token, b Binding, ref AttemptRef) (LeaseStatus, error) {
|
||||
if err := validRef(ref); err != nil {
|
||||
return LeaseStatus{}, err
|
||||
}
|
||||
return s.store.Resume(ctx, t, b, ref)
|
||||
}
|
||||
38
pulse/worker/service_test.go
Normal file
38
pulse/worker/service_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
func TestRegistrationAndSecretBoundaries(t *testing.T) {
|
||||
secret, digest, err := NewSecret()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := SecretDigest(secret)
|
||||
if err != nil || parsed != digest {
|
||||
t.Fatal("secret round trip")
|
||||
}
|
||||
for _, bad := range []string{secret + "=", secret + "\n", strings.Repeat("!", 43), secret[:42]} {
|
||||
if _, err := SecretDigest(bad); err == nil {
|
||||
t.Fatal("malformed secret accepted")
|
||||
}
|
||||
}
|
||||
input := ConnectInput{InstanceID: core.ID(strings.Repeat("1", 32)), InstanceSecret: secret, QueueID: core.ID(strings.Repeat("2", 32)), Capacity: 1, Capabilities: []Capability{{"z", "v1", "v1"}, {"a", "v1", "v1"}}}
|
||||
normalized, err := NormalizeConnect(input)
|
||||
if err != nil || normalized.Capabilities[0].Handler != "a" || input.Capabilities[0].Handler != "z" {
|
||||
t.Fatalf("normalization changed caller input: %v", err)
|
||||
}
|
||||
input.Capabilities = append(input.Capabilities, input.Capabilities[0])
|
||||
if _, err := NormalizeConnect(input); err == nil {
|
||||
t.Fatal("duplicate capability accepted")
|
||||
}
|
||||
input = normalized
|
||||
input.Capacity = 257
|
||||
if _, err := NormalizeConnect(input); err == nil {
|
||||
t.Fatal("excess capacity accepted")
|
||||
}
|
||||
}
|
||||
124
pulse/worker/types.go
Normal file
124
pulse/worker/types.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Package worker 定义执行控制契约;处理器本身在外部 Worker 进程执行。
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// Capability 精确声明可处理的版本,不做静默版本降级。
|
||||
type Capability struct {
|
||||
Handler string `json:"handler"` // Handler 处理器名称。
|
||||
HandlerVersion string `json:"handler_version"` // HandlerVersion 处理器版本。
|
||||
PayloadVersion string `json:"payload_version"` // PayloadVersion 参数版本。
|
||||
}
|
||||
|
||||
// ConnectInput 在每次连接时提交;同一进程恢复时保留实例和秘密。
|
||||
type ConnectInput struct {
|
||||
InstanceID core.ID `json:"instance_id"` // InstanceID 进程标识,重启换新值。
|
||||
InstanceSecret string `json:"instance_secret"` // InstanceSecret 32 字节随机秘密,只保存摘要。
|
||||
QueueID core.ID `json:"queue_id"` // QueueID 当前进程绑定的消费队列。
|
||||
Capabilities []Capability `json:"capabilities"` // Capabilities 1–32 个精确版本组合,重连固定。
|
||||
Capacity uint32 `json:"capacity"` // Capacity 总执行槽数量,1–256。
|
||||
}
|
||||
|
||||
// Binding 是接入层持有的认证结果,不作为公开请求字段接受或写入日志。
|
||||
type Binding struct {
|
||||
InstanceID core.ID // InstanceID 已证明秘密的进程标识。
|
||||
SessionID core.ID // SessionID 当前连接标识。
|
||||
Epoch uint64 // Epoch 连接代次。
|
||||
Proof core.Digest // Proof 进程秘密摘要,持久化验证旧会话隔离。
|
||||
}
|
||||
|
||||
// Welcome 给出数据库时钟与控制参数;时间用于 SDK 计算保守的单调时钟期限。
|
||||
type Welcome struct {
|
||||
SessionID core.ID `json:"session_id"` // SessionID 本次连接标识。
|
||||
Epoch uint64 `json:"epoch,string"` // Epoch 当前连接代次。
|
||||
ServerTime time.Time `json:"server_time"` // ServerTime 本次响应对应的数据库时间。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 会话有效期限,不能代替任务租约。
|
||||
LeaseDurationMS uint64 `json:"lease_duration_ms"` // LeaseDurationMS 单次续租窗口,毫秒。
|
||||
Draining bool `json:"draining"` // Draining 排空标记,跨重连保留。
|
||||
}
|
||||
|
||||
// CapacityInput 使用覆盖值和序号,重复消息不会重复增加执行容量。
|
||||
type CapacityInput struct {
|
||||
Sequence uint64 `json:"sequence,string"` // Sequence 本会话内从一开始递增。
|
||||
Capacity uint32 `json:"capacity"` // Capacity 总容量,零表示暂不领取;不释放已有授权。
|
||||
Drain bool `json:"drain"` // Drain 为真后持久化排空,不能通过后续消息清除。
|
||||
}
|
||||
|
||||
// Assignment 仅通过已认证执行连接交付,包含敏感尝试秘密。
|
||||
type Assignment struct {
|
||||
Task service.TaskView `json:"task"` // Task 受理快照及业务参数。
|
||||
AttemptID core.ID `json:"attempt_id"` // AttemptID 本次尝试标识。
|
||||
Epoch uint64 `json:"epoch,string"` // Epoch 任务执行代次,隔离旧尝试。
|
||||
LeaseSecret string `json:"lease_secret"` // LeaseSecret 执行秘密,禁止普通日志及查询返回。
|
||||
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 尚未开始时的预占有效期。
|
||||
ServerTime time.Time `json:"server_time"` // ServerTime 领取时数据库时间。
|
||||
}
|
||||
|
||||
// AttemptRef 证明对一次尝试的控制权;连接代次仍由 Binding 单独验证。
|
||||
type AttemptRef struct {
|
||||
TaskID core.ID `json:"task_id"` // TaskID 所属任务。
|
||||
AttemptID core.ID `json:"attempt_id"` // AttemptID 目标尝试。
|
||||
Epoch uint64 `json:"epoch,string"` // Epoch 目标执行代次。
|
||||
LeaseSecret string `json:"lease_secret"` // LeaseSecret 原始执行秘密,只在受控连接传输。
|
||||
}
|
||||
|
||||
// FinishInput 区分明确结果与未知效果,未知不能被解释为可以盲目重跑。
|
||||
type FinishInput struct {
|
||||
Ref AttemptRef `json:"ref"` // Ref 当前尝试授权证明。
|
||||
Outcome string `json:"outcome"` // Outcome 为 SUCCEEDED、FAILED 或 UNKNOWN。
|
||||
Result json.RawMessage `json:"result"` // Result 非 null JSON 结果或证据引用,最多 256 KiB。
|
||||
ErrorCode string `json:"error_code,omitempty"` // ErrorCode FAILED/UNKNOWN 必填稳定分类,不包含原始异常正文。
|
||||
}
|
||||
|
||||
// Receipt 是不可变的操作回执,不代表当前仍有执行权。
|
||||
type Receipt struct {
|
||||
Operation string `json:"operation"` // Operation start、renew 或 finish。
|
||||
TaskState pulse.TaskState `json:"task_state"` // TaskState 提交该操作时的任务状态。
|
||||
AttemptState pulse.AttemptState `json:"attempt_state"` // AttemptState 操作提交时的尝试状态。
|
||||
TaskRevision uint64 `json:"task_revision,string"` // TaskRevision 操作提交时的任务版本。
|
||||
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 操作提交时租约期限,历史值不能授权新执行。
|
||||
Disposition string `json:"disposition"` // Disposition 为 APPLIED 或 EVIDENCE_ONLY。
|
||||
}
|
||||
|
||||
// LeaseStatus 每次响应重新计算;处理器仅在 Active 为真、State 为 RUNNING 且期限内推进。
|
||||
// LEASED 的 Active 只代表预占仍有效,不能代替开始授权。
|
||||
type LeaseStatus struct {
|
||||
Active bool `json:"active"` // Active 当前会话仍持有未过期的有效尝试。
|
||||
State pulse.AttemptState `json:"state"` // State 当前尝试状态。
|
||||
LeaseUntil time.Time `json:"lease_until"` // LeaseUntil 当前租约,不因重放旧回执自动延长。
|
||||
ServerTime time.Time `json:"server_time"` // ServerTime 完成锁定后重新读取的数据库时间。
|
||||
}
|
||||
|
||||
// CommandResult 将原回执与当前执行权分开,避免旧 start ACK 使过期尝试复活。
|
||||
type CommandResult struct {
|
||||
Receipt Receipt `json:"receipt"` // Receipt 对应稳定消息身份的原回执。
|
||||
Lease LeaseStatus `json:"lease"` // Lease 当前授权判断。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 是否返回已保存的回执。
|
||||
}
|
||||
|
||||
// RecoveryStats 统计本批恢复,不作为持久化成功率或容量指标。
|
||||
type RecoveryStats struct {
|
||||
Examined int
|
||||
Requeued int
|
||||
Failed int
|
||||
NeedsReview int
|
||||
}
|
||||
type Store interface {
|
||||
Authorize(context.Context, auth.Token) error
|
||||
Connect(context.Context, auth.Token, ConnectInput) (Binding, Welcome, error)
|
||||
Capacity(context.Context, auth.Token, Binding, CapacityInput) (Welcome, error)
|
||||
Claim(context.Context, auth.Token, Binding) (*Assignment, error)
|
||||
Start(context.Context, auth.Token, Binding, core.ID, AttemptRef) (CommandResult, error)
|
||||
Renew(context.Context, auth.Token, Binding, core.ID, AttemptRef) (CommandResult, error)
|
||||
Finish(context.Context, auth.Token, Binding, core.ID, FinishInput) (CommandResult, error)
|
||||
Resume(context.Context, auth.Token, Binding, AttemptRef) (LeaseStatus, error)
|
||||
}
|
||||
Reference in New Issue
Block a user