feat: persist workflow signals and arbitrate poll completion

This commit is contained in:
2026-09-18 03:52:50 +08:00
parent e2baffbe37
commit 7d5ebfcb09
34 changed files with 1958 additions and 113 deletions

View File

@@ -19,20 +19,30 @@ type Signal struct {
RequestDigest core.Digest `db:"request_digest"`
// Type 事件或信号的稳定类型标识。
Type string `db:"type"`
// PayloadVersion 固定信号契约版本;为空仅表示不能参与新等待匹配的历史记录。
PayloadVersion *string `db:"payload_version"`
// TargetStepKey 信号目标步骤键,用于早到信号的后续匹配。
TargetStepKey string `db:"target_step_key"`
// CorrelationKey 外部业务操作关联键,防止旧信号误用于新工作。
// TargetActivationSequence 目标激活序号,早到首轮为1;为空仅表示身份未核实的历史信号。
TargetActivationSequence *uint32 `db:"target_activation_sequence"`
// CorrelationKey 精确UTF8操作关联,新信号最多512字节,历史兼容764字节,保留尾部空格。
CorrelationKey string `db:"correlation_key"`
// Payload 版本化输入或事件内容;仅承载通用 JSON,不解释行业规则。
Payload core.JSON `db:"payload"`
// ActorID 经认证的操作主体标识,不能直接信任请求自报值。
ActorID string `db:"actor_id"`
// EvidenceRef 可选受控证据引用,最多512个无空白字符;平台不请求该地址。
EvidenceRef *string `db:"evidence_ref"`
// State 当前状态;只允许定义的状态转换并配合事务校验。
State SignalState `db:"state"`
// Disposition 稳定消费/拒绝分类;PENDING时为空,不记录原始错误或业务参数。
Disposition *string `db:"disposition"`
// AcceptedAt 信号可靠受理时间,不能用稍后的消费时间替代。
AcceptedAt time.Time `db:"accepted_at"`
// ExpiresAt 过期时间,统一 UTC;到期后的行为由对应资源策略决定。
ExpiresAt time.Time `db:"expires_at"`
// AvailableAt 未匹配信号下次到期检查时间;数据库故障只推迟检查,不延长原受理截止。
AvailableAt time.Time `db:"available_at"`
// ConsumedWaitID 消费该信号的等待记录;尚未消费时为空。
ConsumedWaitID *core.ID `db:"consumed_wait_id"`
}

View File

@@ -23,6 +23,8 @@ type Wait struct {
SignalName *string `db:"signal_name"`
// PayloadVersion 激活时冻结的信号内容契约版本;TIMER及待核实历史记录为空。
PayloadVersion *string `db:"payload_version"`
// WinnerSignalID 胜出的外部信号;时间或查询结果胜出时为空,须核对双向归属。
WinnerSignalID *core.ID `db:"winner_signal_id"`
// CorrelationKey 精确UTF8业务关联且保留尾部空格;新受理最多512字节,历史兼容764字节,TIMER为空串。
CorrelationKey string `db:"correlation_key"`
// DeadlineAt 等待截止时间,用可靠受理时间判断信号是否按时到达。

94
weave/signal/service.go Normal file
View File

@@ -0,0 +1,94 @@
// Package signal 定义可靠外部信号的受理与查询;受理成功不等于已经推进流程。
package signal
import (
"context"
"encoding/json"
"strings"
"time"
"unicode"
"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/definition"
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
)
const SendAction = "workflows:signal"
const ReadAction = "workflows:read"
const MaxPerWorkflow = 1024 // MaxPerWorkflow 限制实例全部受理信号数,终结/拒绝记录也保留占用,原键恢复不受上限影响。
type Input struct {
Name string `json:"name"` // Name 固定定义信号名称,最多128个ASCII字符。
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定内容契约版本,最多32个ASCII字符。
TargetStepKey string `json:"target_step_key"` // TargetStepKey 固定定义的目标步骤,最多64个ASCII字符。
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 显式目标激活代次,首次及早到信号为1,不能自动跟随人工恢复。
CorrelationKey string `json:"correlation_key"` // CorrelationKey 1至512字节精确UTF8操作关联,保留尾部空格。
Payload json.RawMessage `json:"payload" nullable:"true"` // Payload 固定契约内容,最多256KiB,显式null与缺失不同。
Reason string `json:"reason"` // Reason 受理原因,1至1024个Unicode字符,进入审计。
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 可选受控证据引用,最多512个无空白/控制字符,不携带秘密。
}
type View struct {
ID core.ID `json:"id"` // ID 信号持久身份。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 授权和数据隔离范围。
WorkflowID core.ID `json:"workflow_id"` // WorkflowID 固定目标实例。
Revision uint64 `json:"revision,string"` // Revision 当前信号记录版本。
Name string `json:"name"` // Name 固定信号类型。
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定契约版本。
TargetStepKey string `json:"target_step_key"` // TargetStepKey 目标步骤名称。
TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 固定激活序号。
CorrelationKey string `json:"correlation_key"` // CorrelationKey 原始操作关联。
Payload json.RawMessage `json:"payload"` // Payload 原始精确内容。
ActorID string `json:"actor_id"` // ActorID 平台认证主体,不接受请求自报身份。
EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用。
State weave.SignalState `json:"state"` // State PENDING/CONSUMED/EXPIRED/REJECTED。
Disposition string `json:"disposition,omitempty"` // Disposition 稳定消费或拒绝原因。
AcceptedAt time.Time `json:"accepted_at"` // AcceptedAt 数据库可靠受理时间,截止不含等号。
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 固定实例总截止,不因重报延长。
ConsumedWaitID *core.ID `json:"consumed_wait_id,omitempty"` // ConsumedWaitID 消费它的唯一等待身份。
}
type Result struct {
Signal View `json:"signal"` // Signal 首次受理的冻结视图,当前状态通过Get查询。
Deduplicated bool `json:"deduplicated"` // Deduplicated 原身份恢复,不重报新事件或重新计时。
}
type Store interface {
Send(context.Context, auth.Token, core.ID, string, Input) (Result, error)
Get(context.Context, auth.Token, core.ID, core.ID) (View, error)
}
type Service struct{ store Store }
func New(store Store) *Service { return &Service{store: store} }
func (s *Service) Send(ctx context.Context, t auth.Token, workflow core.ID, key string, in Input) (Result, error) {
if err := service.ValidateKey(key); err != nil {
return Result{}, err
}
if workflow.Validate() != nil || !validName(in.Name, 128) || !validName(in.PayloadVersion, 32) || !validName(in.TargetStepKey, 64) || in.TargetActivationSequence == 0 {
return Result{}, fault.Invalid("valid signal name, contract, step and activation sequence required")
}
if !utf8.ValidString(in.CorrelationKey) || len(in.CorrelationKey) == 0 || len(in.CorrelationKey) > 512 || strings.ContainsRune(in.CorrelationKey, 0) {
return Result{}, fault.Invalid("signal correlation must be 1 to 512 UTF-8 bytes")
}
if err := definition.ValidateData(definition.Type{Kind: "JSON"}, in.Payload); err != nil {
return Result{}, fault.Invalid("valid signal payload required, maximum 256 KiB")
}
if !utf8.ValidString(in.Reason) || strings.TrimSpace(in.Reason) == "" || utf8.RuneCountInString(in.Reason) > 1024 || strings.ContainsRune(in.Reason, 0) {
return Result{}, fault.Invalid("signal reason required, maximum 1024 characters")
}
if !utf8.ValidString(in.EvidenceRef) || utf8.RuneCountInString(in.EvidenceRef) > 512 || strings.IndexFunc(in.EvidenceRef, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return Result{}, fault.Invalid("invalid signal evidence reference")
}
return s.store.Send(ctx, t, workflow, key, in)
}
func validName(value string, max int) bool {
return service.ValidName(value, max) && !strings.ContainsRune(value, ':')
}
func (s *Service) Get(ctx context.Context, t auth.Token, workflow, id core.ID) (View, error) {
if workflow.Validate() != nil || id.Validate() != nil {
return View{}, fault.Invalid("invalid workflow or signal id")
}
return s.store.Get(ctx, t, workflow, id)
}

View File

@@ -0,0 +1,55 @@
package signal
import (
"context"
"encoding/json"
"strings"
"testing"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
type captureStore struct {
calls int
input Input
}
func (s *captureStore) Send(_ context.Context, _ auth.Token, _ core.ID, _ string, in Input) (Result, error) {
s.calls++
s.input = in
return Result{}, nil
}
func (s *captureStore) Get(context.Context, auth.Token, core.ID, core.ID) (View, error) {
s.calls++
return View{}, nil
}
func TestSignalInputBoundary(t *testing.T) {
store := &captureStore{}
api := New(store)
id := core.ID("11111111111111111111111111111111")
base := Input{Name: "completed", PayloadVersion: "v1", TargetStepKey: "wait", TargetActivationSequence: 1, CorrelationKey: "关联 ", Payload: json.RawMessage(`{"n":9007199254740993}`), Reason: "verified source", EvidenceRef: "proof:record-1"}
for _, raw := range []string{`{"n":9007199254740993}`, `null`} {
in := base
in.Payload = json.RawMessage(raw)
if _, err := api.Send(context.Background(), auth.Token{}, id, "original", in); err != nil || string(store.input.Payload) != raw || store.input.CorrelationKey != base.CorrelationKey {
t.Fatal("signal input changed", err)
}
}
for _, change := range []func(*Input){
func(in *Input) { in.TargetActivationSequence = 0 }, func(in *Input) { in.CorrelationKey = "" }, func(in *Input) { in.CorrelationKey = strings.Repeat("中", 171) }, func(in *Input) { in.CorrelationKey = "x\x00y" },
func(in *Input) { in.Name = "a:b" }, func(in *Input) { in.Name = strings.Repeat("n", 129) }, func(in *Input) { in.PayloadVersion = "" }, func(in *Input) { in.TargetStepKey = "$root" },
func(in *Input) { in.Payload = nil }, func(in *Input) { in.Payload = json.RawMessage(`{"n":1,"n":2}`) }, func(in *Input) { in.Payload = json.RawMessage(`"\ud800"`) },
func(in *Input) { in.Reason = " " }, func(in *Input) { in.Reason = strings.Repeat("中", 1025) }, func(in *Input) { in.EvidenceRef = "proof:secret\nvalue" }, func(in *Input) { in.EvidenceRef = strings.Repeat("x", 513) },
} {
in := base
change(&in)
before := store.calls
if _, err := api.Send(context.Background(), auth.Token{}, id, "original", in); err == nil || before != store.calls {
t.Fatal("invalid signal reached store")
}
}
if _, err := api.Send(context.Background(), auth.Token{}, id, "", base); err == nil {
t.Fatal("missing identity accepted")
}
}