feat: persist workflow controls and settle stopped branches
This commit is contained in:
48
weave/control/service.go
Normal file
48
weave/control/service.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package control 定义流程暂停、继续与取消的版本化操作;解除暂停不会清除核查或停止意图。
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
const Action = "workflows:control"
|
||||
|
||||
// Input 的稳定操作身份由方法参数传入;并发控制只使用control_revision,不与后台结果版本竞争。
|
||||
type Input struct {
|
||||
Operation string `json:"operation"` // Operation 为PAUSE、RESUME或CANCEL;终结实例不接受新控制操作。
|
||||
ExpectedControlRevision uint64 `json:"expected_control_revision,string"` // ExpectedControlRevision 调用者观察到的控制版本,必须大于0。
|
||||
Reason string `json:"reason"` // Reason 必填操作原因,最多1024个Unicode字符,进入不可变审计。
|
||||
}
|
||||
type Result struct {
|
||||
Instance instance.View `json:"instance"` // Instance 首次操作提交后的冻结回执;当前结果用实例Get读取。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原操作恢复,不重复修改控制版本或计时。
|
||||
}
|
||||
type Store interface {
|
||||
Apply(context.Context, auth.Token, core.ID, string, Input) (Result, error)
|
||||
}
|
||||
type Service struct{ store Store }
|
||||
|
||||
func New(store Store) *Service { return &Service{store: store} }
|
||||
func (s *Service) Apply(ctx context.Context, t auth.Token, id core.ID, key string, in Input) (Result, error) {
|
||||
if err := service.ValidateKey(key); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if id.Validate() != nil || in.ExpectedControlRevision == 0 {
|
||||
return Result{}, fault.Invalid("workflow id and positive control revision required")
|
||||
}
|
||||
if in.Operation != "PAUSE" && in.Operation != "RESUME" && in.Operation != "CANCEL" {
|
||||
return Result{}, fault.Invalid("workflow operation must be PAUSE, RESUME or CANCEL")
|
||||
}
|
||||
if !utf8.ValidString(in.Reason) || strings.TrimSpace(in.Reason) == "" || utf8.RuneCountInString(in.Reason) > 1024 || strings.ContainsRune(in.Reason, 0) {
|
||||
return Result{}, fault.Invalid("workflow control reason required, maximum 1024 characters")
|
||||
}
|
||||
return s.store.Apply(ctx, t, id, key, in)
|
||||
}
|
||||
43
weave/control/service_test.go
Normal file
43
weave/control/service_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
type captureStore struct{ calls int }
|
||||
|
||||
func (s *captureStore) Apply(context.Context, auth.Token, core.ID, string, Input) (Result, error) {
|
||||
s.calls++
|
||||
return Result{}, nil
|
||||
}
|
||||
func TestControlRejectsUnsafeBoundary(t *testing.T) {
|
||||
store := &captureStore{}
|
||||
api := New(store)
|
||||
id := core.ID("11111111111111111111111111111111")
|
||||
base := Input{Operation: "PAUSE", ExpectedControlRevision: 1, Reason: "人工暂停"}
|
||||
for _, operation := range []string{"PAUSE", "RESUME", "CANCEL"} {
|
||||
in := base
|
||||
in.Operation = operation
|
||||
if _, err := api.Apply(context.Background(), auth.Token{}, id, "original", in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, change := range []func(*Input){func(in *Input) { in.ExpectedControlRevision = 0 }, func(in *Input) { in.Operation = "RETRY" }, func(in *Input) { in.Reason = " " }, func(in *Input) { in.Reason = strings.Repeat("中", 1025) }, func(in *Input) { in.Reason = "reason\x00hidden" }, func(in *Input) { in.Reason = string([]byte{0xff}) }} {
|
||||
in := base
|
||||
change(&in)
|
||||
before := store.calls
|
||||
if _, err := api.Apply(context.Background(), auth.Token{}, id, "original", in); err == nil || store.calls != before {
|
||||
t.Fatal("invalid input reached store")
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"", strings.Repeat("x", 129)} {
|
||||
if _, err := api.Apply(context.Background(), auth.Token{}, id, key, base); err == nil {
|
||||
t.Fatal("invalid operation identity reached store")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type View struct {
|
||||
BusinessKey string `json:"business_key"` // BusinessKey 原始追踪键。
|
||||
DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 数据库时间确定的固定总截止。
|
||||
CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` // CancelRequestedAt 已保存的取消意图时间。
|
||||
StopKind *string `json:"stop_kind,omitempty"` // StopKind 首次停止原因CANCEL或FAILURE;核查不会清除。
|
||||
StopRequestedAt *time.Time `json:"stop_requested_at,omitempty"` // StopRequestedAt 首次停止意图时间,后续操作不会延长。
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 终结时间,非终态为空。
|
||||
CreatedAt time.Time `json:"created_at"` // CreatedAt UTC受理时间。
|
||||
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近状态更新时间。
|
||||
|
||||
@@ -35,6 +35,10 @@ type Instance struct {
|
||||
DeadlineAt time.Time `db:"deadline_at"`
|
||||
// CancelRequestedAt 取消意图的持久化时间;不等于取消已完成;可为空,表示尚未设置该关联或值。
|
||||
CancelRequestedAt *time.Time `db:"cancel_requested_at"`
|
||||
// StopKind 首次停止原因,CANCEL为用户取消、FAILURE为固定失败策略;进入核查后仍保留,为空表示未要求结清。
|
||||
StopKind *string `db:"stop_kind"`
|
||||
// StopRequestedAt 首次停止意图的数据库时间,重报或后续取消不覆盖;与StopKind同时为空。
|
||||
StopRequestedAt *time.Time `db:"stop_requested_at"`
|
||||
// CompletedAt 终结时间;非终态为空,进入终态时必须写入。
|
||||
CompletedAt *time.Time `db:"completed_at"`
|
||||
// RetryOf 来源记录标识,人工重新发起时用于保留历史关联;可为空,表示尚未设置该关联或值。
|
||||
|
||||
@@ -17,7 +17,7 @@ type PendingAdvance struct {
|
||||
StepID core.ID `db:"step_id"`
|
||||
// ExpectedStepRevision 恢复推进时要求的步骤版本,避免使用过期检查点。
|
||||
ExpectedStepRevision uint64 `db:"expected_step_revision"`
|
||||
// Checkpoint 版本化 JSON;ROOT用于分批/暂停续接,TIMER/POLL另固定激活ID以隔离旧唤醒。
|
||||
// Checkpoint 版本化 JSON;ROOT用于分批/暂停/停止结清续接,TIMER/SIGNAL/POLL另固定激活ID以隔离旧唤醒。
|
||||
Checkpoint core.JSON `db:"checkpoint"`
|
||||
// AvailableAt 最早可被领取或处理的时间,统一 UTC。
|
||||
AvailableAt time.Time `db:"available_at"`
|
||||
|
||||
@@ -17,7 +17,7 @@ func (s InstanceState) Terminal() bool {
|
||||
return s == InstanceSucceeded || s == InstanceFailed || s == InstanceCancelled
|
||||
}
|
||||
func (i Instance) AllowsNewWork() bool {
|
||||
return !i.Paused && i.CancelRequestedAt == nil && (i.State == InstanceRunning || i.State == InstanceWaiting)
|
||||
return !i.Paused && i.StopKind == nil && i.CancelRequestedAt == nil && (i.State == InstanceRunning || i.State == InstanceWaiting)
|
||||
}
|
||||
|
||||
type NodeKind string
|
||||
|
||||
Reference in New Issue
Block a user