feat: add scoped workflow instance queries and SDK pagination
This commit is contained in:
116
sdk/go/examples/workflow-query/main.go
Normal file
116
sdk/go/examples/workflow-query/main.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// workflow-query 发布有限等待流程,按精确业务键和固定版本分页,并按控制版本结清示例实例。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
raw, err := os.ReadFile(os.Getenv("ORBIT_CREDENTIALS_FILE"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var credentials struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if json.Unmarshal(raw, &credentials) != nil || credentials.Token == "" {
|
||||
return errors.New("invalid private credentials file")
|
||||
}
|
||||
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: credentials.Token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
operation, err := orbit.NewWorkerMessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := orbit.WorkflowSpecification{SchemaVersion: 1, InputType: orbit.WorkflowType{Kind: "NULL"}, TimeoutMS: 120000, FailurePolicy: "REVIEW", Body: orbit.WorkflowBlock{
|
||||
Steps: []orbit.WorkflowStep{{Key: "receipt", Kind: "SIGNAL", Signal: &orbit.WorkflowSignal{Name: "object.ready", PayloadVersion: "v1", Correlation: orbit.WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"` + operation + `"`)}, PayloadType: orbit.WorkflowType{Kind: "BOOLEAN"}, TimeoutMS: 120000}}},
|
||||
OutputType: orbit.WorkflowType{Kind: "BOOLEAN"}, Output: orbit.WorkflowRef("receipt"),
|
||||
}}
|
||||
d, err := client.CreateWorkflowDefinition(ctx, operation, orbit.CreateWorkflowDefinitionRequest{Name: "query." + operation})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = client.PublishWorkflowVersion(ctx, d.Definition.ID, operation, orbit.PublishWorkflowVersionRequest{Version: "v1", Specification: spec, Reason: "publish query example"}); err != nil {
|
||||
return err
|
||||
}
|
||||
key := "对象 /+% é " + operation + " "
|
||||
var instances []orbit.WorkflowInstance
|
||||
for i, business := range []string{key, key, key[:len(key)-1]} {
|
||||
out, err := client.StartWorkflow(ctx, fmt.Sprintf("%s-%d", operation, i), orbit.StartWorkflowRequest{DefinitionID: d.Definition.ID, Version: "v1", Input: json.RawMessage(`null`), BusinessKey: business})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instances = append(instances, out.Instance)
|
||||
}
|
||||
from, before := instances[0].CreatedAt, instances[2].CreatedAt.Add(time.Microsecond)
|
||||
query := orbit.ListWorkflowsRequest{DefinitionID: d.Definition.ID, Version: "v1", BusinessKey: key, State: "WAITING", CreatedFrom: &from, CreatedBefore: &before, Limit: 1}
|
||||
seen := map[string]bool{}
|
||||
pages := 0
|
||||
for {
|
||||
page, err := client.ListWorkflows(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pages++
|
||||
for _, v := range page.Items {
|
||||
if v.BusinessKey != key || v.DefinitionID != d.Definition.ID || v.Version != "v1" || seen[v.ID] {
|
||||
return errors.New("workflow query changed filters or repeated an item")
|
||||
}
|
||||
seen[v.ID] = true
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
query.Cursor = page.NextCursor
|
||||
}
|
||||
if len(seen) != 2 || pages != 2 || !seen[instances[0].ID] || !seen[instances[1].ID] {
|
||||
return errors.New("expected exactly two matching workflow instances")
|
||||
}
|
||||
fmt.Printf("workflow_query_verified=true matched=%d pages=%d exact_trailing_space=true\n", len(seen), pages)
|
||||
// 取消记录保留,固定定义版本和审计也保留;失败时不要直接删除运行记录。
|
||||
for _, v := range instances {
|
||||
current, err := client.GetWorkflow(ctx, v.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = client.ControlWorkflow(ctx, v.ID, operation+"-cleanup", orbit.WorkflowControlRequest{Operation: "CANCEL", ExpectedControlRevision: current.ControlRevision, Reason: "query example cleanup"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
current, err = client.GetWorkflow(ctx, v.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.State == "CANCELLED" {
|
||||
break
|
||||
}
|
||||
if current.State == "NEEDS_REVIEW" {
|
||||
return errors.New("cleanup requires review")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("workflow_query_cleanup=CANCELLED count=3")
|
||||
return nil
|
||||
}
|
||||
97
sdk/go/workflow_query.go
Normal file
97
sdk/go/workflow_query.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ListWorkflowsRequest 在认证命名空间内筛选实例;所有队列权限必须覆盖整个固定定义版本。
|
||||
type ListWorkflowsRequest struct {
|
||||
DefinitionID string // DefinitionID 可选定义身份,未传入时跨已授权定义查询。
|
||||
Version string // Version 可选固定发布版本名,仅在指定定义时使用。
|
||||
State string // State 可选执行状态,与暂停标志独立。
|
||||
Paused *bool // Paused nil不筛选;false明确筛选未暂停实例。
|
||||
BusinessKey string // BusinessKey 非空时精确匹配,包含尾部空格;空表示不筛选。
|
||||
CreatedFrom *time.Time // CreatedFrom 可选创建时间下界,包含,UTC微秒精度。
|
||||
CreatedBefore *time.Time // CreatedBefore 可选创建时间上界,不包含。
|
||||
Limit int // Limit 默认20,上限100。
|
||||
Cursor string // Cursor 服务端返回的位置,绑定命名空间、全部筛选及队列权限范围。
|
||||
}
|
||||
|
||||
// Summary 仅返回实例元数据,不聚合业务输入输出、定义或策略快照。
|
||||
type WorkflowSummary struct {
|
||||
ID string `json:"id"` // ID 实例身份。
|
||||
NamespaceID string `json:"namespace_id"` // NamespaceID 授权及数据隔离范围。
|
||||
DefinitionID string `json:"definition_id"` // DefinitionID 稳定定义身份。
|
||||
DefinitionVersionID string `json:"definition_version_id"` // DefinitionVersionID 固定版本记录身份。
|
||||
Version string `json:"version"` // Version 启动时固定的发布版本名。
|
||||
State string `json:"state"` // State 执行状态,受理不代表业务成功。
|
||||
Revision uint64 `json:"revision,string"` // Revision 实例并发版本。
|
||||
Paused bool `json:"paused"` // Paused 独立暂停标志,不表示在途工作已停止。
|
||||
ControlRevision uint64 `json:"control_revision,string"` // ControlRevision 控制操作并发版本。
|
||||
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 最近状态更新时间。
|
||||
}
|
||||
|
||||
type WorkflowPage struct {
|
||||
Items []WorkflowSummary `json:"items"` // Items 按创建时间及ID倒序的授权摘要;空列表为[]。
|
||||
NextCursor string `json:"next_cursor,omitempty"` // NextCursor 沿相同筛选与权限继续使用,空表示本次无下一页。
|
||||
}
|
||||
|
||||
// ListWorkflows 查询当前凭据可读取的流程摘要;分页筛选和队列权限必须保持一致。
|
||||
func (c *Client) ListWorkflows(ctx context.Context, in ListWorkflowsRequest) (WorkflowPage, error) {
|
||||
var out WorkflowPage
|
||||
if in.Limit < 0 || in.Limit > 100 || len(in.Cursor) > 1024 || in.DefinitionID != "" && !validID(in.DefinitionID) || in.Version != "" && (in.DefinitionID == "" || !workflowVersionName(in.Version)) {
|
||||
return out, errors.New("invalid workflow query definition, version or page")
|
||||
}
|
||||
switch in.State {
|
||||
case "", "RUNNING", "WAITING", "CANCELLING", "NEEDS_REVIEW", "SUCCEEDED", "FAILED", "CANCELLED":
|
||||
default:
|
||||
return out, errors.New("invalid workflow state")
|
||||
}
|
||||
if !utf8.ValidString(in.BusinessKey) || utf8.RuneCountInString(in.BusinessKey) > 191 || strings.ContainsRune(in.BusinessKey, 0) {
|
||||
return out, errors.New("invalid workflow business key filter")
|
||||
}
|
||||
q := url.Values{}
|
||||
for name, v := range map[string]string{"definition_id": in.DefinitionID, "version": in.Version, "state": in.State, "business_key": in.BusinessKey, "cursor": in.Cursor} {
|
||||
if v != "" {
|
||||
q.Set(name, v)
|
||||
}
|
||||
}
|
||||
if in.Paused != nil {
|
||||
q.Set("paused", strconv.FormatBool(*in.Paused))
|
||||
}
|
||||
if in.Limit != 0 {
|
||||
q.Set("limit", strconv.Itoa(in.Limit))
|
||||
}
|
||||
for name, field := range map[string]*time.Time{"created_from": in.CreatedFrom, "created_before": in.CreatedBefore} {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
v := field.UTC()
|
||||
if v.Year() < 2000 || v.Year() > 9998 || v.Nanosecond()%1000 != 0 {
|
||||
return out, errors.New("invalid workflow creation time bound")
|
||||
}
|
||||
q.Set(name, v.Format(time.RFC3339Nano))
|
||||
}
|
||||
if in.CreatedFrom != nil && in.CreatedBefore != nil && !in.CreatedFrom.Before(*in.CreatedBefore) {
|
||||
return out, errors.New("created_from must be before created_before")
|
||||
}
|
||||
path := "/v1/workflows"
|
||||
if len(q) > 0 {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
err := c.call(ctx, "GET", path, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
84
sdk/go/workflow_query_integration_test.go
Normal file
84
sdk/go/workflow_query_integration_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationWorkflowQuerySDK(t *testing.T) {
|
||||
api, queue, token := integrationWorkerClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
spec := workflowSDKSpec(queue, "query."+workerTestID(t))
|
||||
// SIGNAL保持稳定WAITING,不启动Worker或等待短时任务截止。
|
||||
spec.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "receipt", Kind: "SIGNAL", Signal: &WorkflowSignal{Name: "done", PayloadVersion: "v1", Correlation: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"query"`)}, PayloadType: spec.InputType, TimeoutMS: 60000}}}, OutputType: spec.InputType, Output: WorkflowRef("receipt")}
|
||||
in := publishWorkflowSDK(t, ctx, api, spec)
|
||||
in.BusinessKey = "作业 /+%\n " + workerTestID(t) + " "
|
||||
var started []WorkflowInstance
|
||||
for _, key := range []string{in.BusinessKey, in.BusinessKey, strings.TrimSpace(in.BusinessKey)} {
|
||||
in.BusinessKey = key
|
||||
out, err := api.StartWorkflow(ctx, workerTestID(t), in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started = append(started, out.Instance)
|
||||
}
|
||||
paused, err := api.ControlWorkflow(ctx, started[0].ID, workerTestID(t), WorkflowControlRequest{Operation: "PAUSE", ExpectedControlRevision: started[0].ControlRevision, Reason: "verify paused query"})
|
||||
if err != nil || !paused.Instance.Paused {
|
||||
t.Fatal(paused, err)
|
||||
}
|
||||
from, before := started[0].CreatedAt, started[2].CreatedAt.Add(time.Microsecond)
|
||||
query := ListWorkflowsRequest{DefinitionID: in.DefinitionID, Version: "v1", State: "WAITING", BusinessKey: started[0].BusinessKey, CreatedFrom: &from, CreatedBefore: &before, Limit: 1}
|
||||
first, err := api.ListWorkflows(ctx, query)
|
||||
if err != nil || len(first.Items) != 1 || first.Items[0].ID != started[1].ID || first.NextCursor == "" {
|
||||
t.Fatal(first, err)
|
||||
}
|
||||
query.Cursor = first.NextCursor
|
||||
last, err := api.ListWorkflows(ctx, query)
|
||||
if err != nil || len(last.Items) != 1 || last.Items[0].ID != started[0].ID || !last.Items[0].Paused || last.NextCursor != "" {
|
||||
t.Fatal(last, err)
|
||||
}
|
||||
query.BusinessKey = strings.TrimSpace(query.BusinessKey)
|
||||
_, err = api.ListWorkflows(ctx, query)
|
||||
workflowAPIError(t, err, "INVALID_ARGUMENT")
|
||||
query.Cursor = ""
|
||||
if page, err := api.ListWorkflows(ctx, query); err != nil || len(page.Items) != 1 || page.Items[0].ID != started[2].ID {
|
||||
t.Fatal("exact business key", page, err)
|
||||
}
|
||||
b := false
|
||||
query.BusinessKey = started[0].BusinessKey
|
||||
query.Paused = &b
|
||||
if page, err := api.ListWorkflows(ctx, query); err != nil || len(page.Items) != 1 || page.Items[0].ID != started[1].ID {
|
||||
t.Fatal("false paused filter", page, err)
|
||||
}
|
||||
for _, suffix := range []string{"?state=WAITING&state=RUNNING", "?paused=1", "?business_key=", "?unknown=x", "?version=v1", "?limit=101"} {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", api.baseURL+"/v1/workflows"+suffix, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 400 {
|
||||
t.Fatal("invalid raw query", suffix, res.StatusCode)
|
||||
}
|
||||
}
|
||||
for _, v := range started {
|
||||
current, err := api.GetWorkflow(ctx, v.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = api.ControlWorkflow(ctx, v.ID, workerTestID(t), WorkflowControlRequest{Operation: "CANCEL", ExpectedControlRevision: current.ControlRevision, Reason: "query regression cleanup"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitWorkflowSDK(t, ctx, api, v.ID, "CANCELLED")
|
||||
}
|
||||
t.Log("real HTTP: exact key, time bounds, fixed version, paused false, filtered cursor, malformed query rejection and cancellation cleanup")
|
||||
}
|
||||
48
sdk/go/workflow_query_test.go
Normal file
48
sdk/go/workflow_query_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkflowQueryEncodingAndBounds(t *testing.T) {
|
||||
const id = "11111111111111111111111111111111"
|
||||
from := time.Date(2026, 9, 17, 8, 0, 0, 123456000, time.FixedZone("sample", 8*3600))
|
||||
before := from.Add(time.Hour)
|
||||
key := "业务 /+% é "
|
||||
paused := false
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
q := r.URL.Query()
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/workflows" || r.Header.Get("Idempotency-Key") != "" || r.Header.Get("Authorization") != "Bearer example-token" || q.Get("paused") != "false" || q.Get("definition_id") != id || q.Get("version") != "v1" || q.Get("business_key") != key || q.Get("created_from") != "2026-09-17T00:00:00.123456Z" || q.Get("created_before") != "2026-09-17T01:00:00.123456Z" || q.Get("cursor") != "opaque+cursor/=" {
|
||||
t.Error("query lost meaning", r.URL.String())
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"request_id": id, "data": WorkflowPage{Items: []WorkflowSummary{{ID: id, Revision: 9007199254740993, BusinessKey: key}}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
c, _ := NewClient(server.URL, Options{Token: "example-token"})
|
||||
base := ListWorkflowsRequest{Paused: &paused, DefinitionID: id, Version: "v1", State: "WAITING", BusinessKey: key, CreatedFrom: &from, CreatedBefore: &before, Limit: 2, Cursor: "opaque+cursor/="}
|
||||
page, err := c.ListWorkflows(context.Background(), base)
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].BusinessKey != key || page.Items[0].Revision != 9007199254740993 {
|
||||
t.Fatal(page, err)
|
||||
}
|
||||
for name, change := range map[string]func(*ListWorkflowsRequest){"definition": func(v *ListWorkflowsRequest) { v.DefinitionID = "../queue" }, "version without definition": func(v *ListWorkflowsRequest) { v.DefinitionID = "" }, "version": func(v *ListWorkflowsRequest) { v.Version = "../v1" }, "state": func(v *ListWorkflowsRequest) { v.State = "unknown" }, "limit": func(v *ListWorkflowsRequest) { v.Limit = 101 }, "cursor": func(v *ListWorkflowsRequest) { v.Cursor = strings.Repeat("a", 1025) }, "utf8": func(v *ListWorkflowsRequest) { v.BusinessKey = "\xff" }, "key": func(v *ListWorkflowsRequest) { v.BusinessKey = strings.Repeat("字", 192) }, "nul": func(v *ListWorkflowsRequest) { v.BusinessKey = "x\x00" }, "equal": func(v *ListWorkflowsRequest) { v.CreatedBefore = &from }, "nanosecond": func(v *ListWorkflowsRequest) { z := from.Add(time.Nanosecond); v.CreatedFrom = &z }, "year": func(v *ListWorkflowsRequest) { z := time.Time{}; v.CreatedFrom = &z }} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
in := base
|
||||
change(&in)
|
||||
if _, err := c.ListWorkflows(context.Background(), in); err == nil {
|
||||
t.Fatal("invalid query accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatal("invalid query reached HTTP")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user