feat: add scoped workflow instance queries and SDK pagination

This commit is contained in:
2026-09-18 08:34:45 +08:00
parent 17a8e0126c
commit 082898e705
33 changed files with 1598 additions and 18 deletions

View File

@@ -64,6 +64,8 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
retryFinish(t, g, finished, "SUCCEEDED", false)
assertApplicationCounts(t, f, 4, 2, 1)
// 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。
f.exec(`DROP TABLE weave_version_scopes`)
f.exec(`ALTER TABLE weave_instances DROP INDEX ix_workflow_created,DROP INDEX ix_workflow_state_created,DROP INDEX ix_workflow_business_state_created,DROP INDEX ix_workflow_definition_created,DROP INDEX ix_workflow_definition_state_created,DROP INDEX ix_workflow_definition_business_created,DROP INDEX ix_workflow_definition_business_state_created`)
f.exec(`ALTER TABLE weave_signals DROP INDEX ix_signal_history`)
f.exec(`ALTER TABLE weave_poll_states DROP CHECK ck_poll_recovery_budget, DROP COLUMN first_round, DROP COLUMN first_round_attempts_used`)
f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_workflow_owner`)

View File

@@ -209,6 +209,7 @@ func checkModelColumns(t *testing.T, ctx context.Context, db *sql.DB) {
pulse.Occurrence{},
weave.Definition{},
weave.DefinitionVersion{},
weave.VersionScope{},
weave.Instance{},
weave.Step{},
weave.Activation{},

View File

@@ -0,0 +1,57 @@
-- 固定版本查询范围与列表索引;禁止外键,不改写版本、实例或任务历史。
CREATE TABLE weave_version_scopes (
namespace_id BINARY(16) NOT NULL COMMENT '命名空间,参与版本身份及查询授权',
definition_version_id BINARY(16) NOT NULL COMMENT '固定定义版本标识,范围包含所有未执行分支',
definition_id BINARY(16) NOT NULL COMMENT '所属定义标识,服务端核对其与版本及实例的归属',
content_digest BINARY(32) NOT NULL COMMENT '原不可变定义摘要,防止跨版本使用范围',
queue_ids JSON NOT NULL COMMENT '去重队列标识数组,空数组表示无任务队列;只用于查询筛选,返回前仍需核验原定义',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '查询索引建立时间,UTC;不能替代原定义发布时间',
PRIMARY KEY(namespace_id,definition_version_id),
KEY ix_version_scope_definition(namespace_id,definition_id,definition_version_id),
CONSTRAINT ck_version_scope_queues CHECK (JSON_SCHEMA_VALID('{"type":"array","maxItems":128,"uniqueItems":true,"items":{"type":"string","pattern":"^[0-9a-f]{32}$"}}',queue_ids))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='不可变流程版本的查询队列范围,供实例列表在数据库内筛选当前权限';
-- 只遍历定义结构中的步骤块,不将输入常量中的task/queue_id误当成流程权限。
-- 合法结构最多4层组合嵌套;此处多保留一层用于识别异常数据,公共读取仍严格校验原定义。
INSERT INTO weave_version_scopes(namespace_id,definition_version_id,definition_id,content_digest,queue_ids)
WITH RECURSIVE nodes AS (
SELECT v.namespace_id,v.id AS version_id,s.node,0 AS depth
FROM weave_definition_versions v
JOIN JSON_TABLE(v.specification,'$.body.steps[*]' COLUMNS(node JSON PATH '$')) s
UNION ALL
SELECT n.namespace_id,n.version_id,s.node,n.depth+1
FROM nodes n
JOIN JSON_TABLE(
CASE JSON_UNQUOTE(JSON_EXTRACT(n.node,'$.kind'))
WHEN 'BRANCH' THEN JSON_MERGE_PRESERVE(COALESCE(JSON_EXTRACT(n.node,'$.branch.cases[*].body.steps[*]'),JSON_ARRAY()),COALESCE(JSON_EXTRACT(n.node,'$.branch.default.steps[*]'),JSON_ARRAY()))
WHEN 'PARALLEL' THEN COALESCE(JSON_EXTRACT(n.node,'$.parallel.branches[*].body.steps[*]'),JSON_ARRAY())
ELSE JSON_ARRAY()
END,'$[*]' COLUMNS(node JSON PATH '$')) s
WHERE n.depth<5
), distinct_queues AS (
SELECT DISTINCT namespace_id,version_id,
CASE JSON_UNQUOTE(JSON_EXTRACT(node,'$.kind'))
WHEN 'TASK' THEN JSON_UNQUOTE(JSON_EXTRACT(node,'$.task.queue_id'))
WHEN 'POLL' THEN JSON_UNQUOTE(JSON_EXTRACT(node,'$.poll.task.queue_id'))
END AS queue_id
FROM nodes
WHERE JSON_UNQUOTE(JSON_EXTRACT(node,'$.kind')) IN ('TASK','POLL')
), scopes AS (
SELECT namespace_id,version_id,JSON_ARRAYAGG(queue_id) AS queue_ids
FROM distinct_queues GROUP BY namespace_id,version_id
)
SELECT v.namespace_id,v.id,v.definition_id,v.content_digest,COALESCE(s.queue_ids,JSON_ARRAY())
FROM weave_definition_versions v LEFT JOIN scopes s ON s.namespace_id=v.namespace_id AND s.version_id=v.id;
CREATE TRIGGER trg_version_scope_immutable BEFORE UPDATE ON weave_version_scopes
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Workflow query scopes are immutable';
-- 定义、业务键及状态都是可选等值筛选;创建时间和ID作为稳定分页位置。
ALTER TABLE weave_instances
ADD KEY ix_workflow_created(namespace_id,created_at,id),
ADD KEY ix_workflow_state_created(namespace_id,state,created_at,id),
ADD KEY ix_workflow_business_state_created(namespace_id,business_key,state,created_at,id),
ADD KEY ix_workflow_definition_created(namespace_id,definition_id,created_at,id),
ADD KEY ix_workflow_definition_state_created(namespace_id,definition_id,state,created_at,id),
ADD KEY ix_workflow_definition_business_created(namespace_id,definition_id,business_key,created_at,id),
ADD KEY ix_workflow_definition_business_state_created(namespace_id,definition_id,business_key,state,created_at,id);

View File

@@ -88,8 +88,8 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) {
}
indexes := func() []string {
t.Helper()
// v15替换retry_of索引、v17增加内部结果唯一索引、v20增加信号索引,下方单独验证定义。
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
// 排除后续新增/替换索引;原有项在此逐项比较,v24新增项由WorkflowQueryUpgrade核对。
rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events','weave_version_scopes') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history','ix_workflow_created','ix_workflow_state_created','ix_workflow_business_state_created','ix_workflow_definition_created','ix_workflow_definition_state_created','ix_workflow_definition_business_created','ix_workflow_definition_business_state_created') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`)
if err != nil {
t.Fatal(err)
}

View File

@@ -225,6 +225,14 @@ func (s *WorkflowDefinitionStore) Publish(ctx context.Context, t auth.Token, id
if err != nil {
return err
}
scope, err := json.Marshal(append([]core.ID{}, checked.Queues...))
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO weave_version_scopes(namespace_id,definition_version_id,definition_id,content_digest,queue_ids,created_at) VALUES (?,?,?,?,?,?)`, t.NamespaceID, versionID, id, checked.Digest, string(scope), now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE weave_definitions SET revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, now, t.NamespaceID, id); err != nil {
return err
}

View File

@@ -0,0 +1,257 @@
package mysql
import (
"context"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"slices"
"time"
"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/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
// 查询游标只表示位置,不授予权限;改变权限范围必须重新查询。
type workflowCursor struct {
Version int `json:"v"`
Namespace core.ID `json:"n"`
Created time.Time `json:"t"`
ID core.ID `json:"i"`
Filter string `json:"f"`
}
func workflowQueryQueues(p auth.Permissions) []string {
if slices.Contains(p.Queues, "*") {
return []string{"*"}
}
queues := slices.Clone(p.Queues)
slices.Sort(queues)
return slices.Compact(queues)
}
func workflowFilterDigest(in instance.ListInput, queues []string) (string, error) {
// 页长和位置不属于筛选;nil暂停与false有不同语义。
raw, err := requestDigest(struct {
Domain string
Definition core.ID
Version string
State string
Paused *bool
Business string
From, Before *time.Time
Queues []string
}{"orbit.workflow-query.v1", in.DefinitionID, in.Version, string(in.State), in.Paused, in.BusinessKey, in.CreatedFrom, in.CreatedBefore, queues})
return hex.EncodeToString(raw[:]), err
}
func decodeWorkflowCursor(value string, ns core.ID, in instance.ListInput, filter string) (workflowCursor, error) {
var c workflowCursor
invalid := fault.Invalid("cursor does not match this workflow query or queue permissions")
raw, err := base64.RawURLEncoding.Strict().DecodeString(value)
if err != nil || len(raw) > 768 {
return c, invalid
}
if _, err = jsonvalue.Canonical(raw); err != nil {
return c, invalid
}
var fields map[string]json.RawMessage
if json.Unmarshal(raw, &fields) != nil || len(fields) != 5 {
return c, invalid
}
for _, k := range []string{"v", "n", "t", "i", "f"} {
v, ok := fields[k]
if !ok || string(v) == "null" {
return c, invalid
}
}
if json.Unmarshal(raw, &c) != nil || c.Version != 1 || c.Namespace != ns || c.Filter != filter || c.ID.Validate() != nil || c.Created.Year() < 2000 || c.Created.Year() > 9998 || c.Created.Nanosecond()%1000 != 0 {
return c, invalid
}
if in.CreatedFrom != nil && c.Created.Before(*in.CreatedFrom) || in.CreatedBefore != nil && !c.Created.Before(*in.CreatedBefore) {
return c, invalid
}
return c, nil
}
// 所有可选组合均使用固定索引名,不拼接用户输入;队列筛选在LIMIT之前完成。
func workflowListSQL(ns core.ID, in instance.ListInput, queues []string, filter string) (string, []any, error) {
index := "ix_workflow"
if in.DefinitionID != "" {
index += "_definition"
}
if in.BusinessKey != "" {
index += "_business"
}
if in.State != "" {
index += "_state"
}
index += "_created"
if in.DefinitionID == "" && in.BusinessKey != "" && in.State == "" {
index = "ix_workflow_business"
}
q := `SELECT i.id,i.namespace_id,i.definition_id,i.definition_version_id,COALESCE(v.version,''),i.state,i.revision,i.paused,i.control_revision,COALESCE(i.business_key,''),i.deadline_at,i.cancel_requested_at,i.stop_kind,i.stop_requested_at,i.completed_at,i.created_at,i.updated_at,
i.policy_snapshot,s.queue_ids,COALESCE(s.definition_id=i.definition_id AND s.content_digest=v.content_digest AND v.definition_id=i.definition_id AND d.id IS NOT NULL AND g.id IS NOT NULL,FALSE)
FROM weave_instances i FORCE INDEX (` + index + `)
LEFT JOIN weave_definition_versions v ON v.namespace_id=i.namespace_id AND v.id=i.definition_version_id
LEFT JOIN weave_version_scopes s ON s.namespace_id=i.namespace_id AND s.definition_version_id=i.definition_version_id
LEFT JOIN weave_definitions d ON d.namespace_id=i.namespace_id AND d.id=i.definition_id
LEFT JOIN pulse_execution_gates g ON g.namespace_id=i.namespace_id AND g.id=i.gate_id
WHERE i.namespace_id=?`
args := []any{ns}
if in.DefinitionID != "" {
q += " AND i.definition_id=?"
args = append(args, in.DefinitionID)
}
if in.BusinessKey != "" {
q += " AND i.business_key=?"
args = append(args, in.BusinessKey)
}
if in.State != "" {
q += " AND i.state=?"
args = append(args, in.State)
}
if in.Version != "" {
q += " AND v.version=?"
args = append(args, in.Version)
}
if in.Paused != nil {
q += " AND i.paused=?"
args = append(args, *in.Paused)
}
if in.CreatedFrom != nil {
q += " AND i.created_at>=?"
args = append(args, *in.CreatedFrom)
}
if in.CreatedBefore != nil {
q += " AND i.created_at<?"
args = append(args, *in.CreatedBefore)
}
if in.Cursor != "" {
c, err := decodeWorkflowCursor(in.Cursor, ns, in, filter)
if err != nil {
return "", nil, err
}
q += " AND (i.created_at<? OR (i.created_at=? AND i.id<?))"
args = append(args, c.Created, c.Created, c.ID)
}
if !slices.Contains(queues, "*") {
allowed, err := json.Marshal(queues)
if err != nil {
return "", nil, err
}
// 缺失或错配的索引不当作空权限;进入下方完整校验并使整页失败。
q += ` AND (s.definition_version_id IS NULL OR v.id IS NULL OR s.definition_id<>i.definition_id OR s.content_digest<>v.content_digest OR JSON_CONTAINS(?,s.queue_ids))`
args = append(args, string(allowed))
}
q += " ORDER BY i.created_at DESC,i.id DESC LIMIT ?"
args = append(args, in.Limit+1)
return q, args, nil
}
type workflowListRow struct {
view instance.Summary
policy, queues []byte
related bool
}
func (s *WorkflowInstanceStore) List(ctx context.Context, t auth.Token, in instance.ListInput) (instance.Page, error) {
out := instance.Page{Items: []instance.Summary{}}
err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
queues := workflowQueryQueues(p)
filter, err := workflowFilterDigest(in, queues)
if err != nil {
return err
}
q, args, err := workflowListSQL(t.NamespaceID, in, queues, filter)
if err != nil {
return err
}
rows, err := tx.QueryContext(ctx, q, args...)
if err != nil {
return err
}
candidates := make([]workflowListRow, 0, in.Limit+1)
for rows.Next() {
var r workflowListRow
v := &r.view
if err = rows.Scan(&v.ID, &v.NamespaceID, &v.DefinitionID, &v.DefinitionVersionID, &v.Version, &v.State, &v.Revision, &v.Paused, &v.ControlRevision, &v.BusinessKey, &v.DeadlineAt, &v.CancelRequestedAt, &v.StopKind, &v.StopRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt, &r.policy, &r.queues, &r.related); err != nil {
rows.Close()
return err
}
candidates = append(candidates, r)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
// 最多limit+1个候选,按固定版本缓存严格原文验证,不读取每个实例的大输入输出。
versions := map[core.ID]definition.VersionView{}
allQueues := []core.ID{}
for _, r := range candidates {
if !r.related {
return errors.New("workflow query relation or scope mismatch")
}
v := r.view
ver, ok := versions[v.DefinitionVersionID]
if !ok {
ver, err = readWorkflowVersion(ctx, tx, t.NamespaceID, v.DefinitionID, v.Version)
if err != nil {
return err
}
versions[v.DefinitionVersionID] = ver
allQueues = append(allQueues, ver.Analysis.Queues...)
}
if ver.ID != v.DefinitionVersionID || ver.DefinitionID != v.DefinitionID || ver.Version != v.Version {
return errors.New("workflow query fixed version mismatch")
}
var scope []core.ID
if json.Unmarshal(r.queues, &scope) != nil || scope == nil {
return errors.New("workflow query scope invalid")
}
slices.Sort(scope)
if !slices.Equal(scope, ver.Analysis.Queues) {
return errors.New("workflow query scope does not match definition")
}
for _, id := range scope {
if !p.AllowsQueue(id) {
return fault.NotFound
}
}
var policy workflowPolicy
if json.Unmarshal(r.policy, &policy) != nil || policy != (workflowPolicy{1, ver.ID, ver.ContentDigest, ver.Specification.FailurePolicy, ver.Analysis.MaxTaskAttempts}) {
return errors.New("workflow frozen policy mismatch")
}
}
// 在任何实例锁之前,整页涉及的队列统一排序;只读列表不锁实例,也不再次读取变化中的状态。
slices.Sort(allQueues)
if err = authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, slices.Compact(allQueues)); err != nil {
return err
}
more := len(candidates) > in.Limit
if more {
candidates = candidates[:in.Limit]
}
for _, r := range candidates {
out.Items = append(out.Items, r.view)
}
if more {
last := out.Items[len(out.Items)-1]
raw, err := json.Marshal(workflowCursor{1, t.NamespaceID, last.CreatedAt, last.ID, filter})
if err != nil {
return err
}
out.NextCursor = base64.RawURLEncoding.EncodeToString(raw)
}
return nil
})
if err != nil {
return instance.Page{}, err
}
return out, nil
}

View File

@@ -0,0 +1,228 @@
package mysql
import (
"context"
"encoding/json"
"os"
"slices"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
func TestIntegrationWorkflowQuery(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db, err := Open(ctx, dsn, 12, 4)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
api := instance.New(NewWorkflowInstanceStore(db))
t.Run("filters_ties_cursor_and_metadata", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
grantWorkflowInstance(t, f)
in := publishInstanceSpec(t, f, runtimeSpec(f.resources.QueueID, 1))
in.BusinessKey = "查询 +/%\n "
var ids []core.ID
for n := 0; n < 4; n++ {
v, err := api.Start(ctx, f.token, string(executionID(t)), in)
if err != nil {
t.Fatal(err)
}
ids = append(ids, v.Instance.ID)
}
stamp := time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC)
before := stamp.Add(time.Microsecond)
f.exec(`UPDATE weave_instances SET created_at=?,updated_at=? WHERE namespace_id=?`, stamp, stamp, f.resources.NamespaceID)
slices.Sort(ids)
slices.Reverse(ids)
// 覆盖全部8个固定索引组合;同时间戳分页不能重复或遗漏。
for mask := 0; mask < 8; mask++ {
filter := instance.ListInput{Limit: 1, CreatedFrom: &stamp, CreatedBefore: &before}
if mask&1 != 0 {
filter.DefinitionID = in.DefinitionID
}
if mask&2 != 0 {
filter.BusinessKey = in.BusinessKey
}
if mask&4 != 0 {
filter.State = "RUNNING"
}
var got []core.ID
for n := 0; n < 5; n++ {
page, err := api.List(ctx, f.token, filter)
if err != nil {
t.Fatal(mask, err)
}
for _, v := range page.Items {
got = append(got, v.ID)
}
if page.NextCursor == "" {
break
}
filter.Cursor = page.NextCursor
}
if !slices.Equal(got, ids) {
t.Fatal("page order", mask, got, ids)
}
}
base := instance.ListInput{DefinitionID: in.DefinitionID, Version: "v1", BusinessKey: in.BusinessKey, CreatedFrom: &stamp, CreatedBefore: &before, Limit: 1}
first, err := api.List(ctx, f.token, base)
if err != nil || first.NextCursor == "" {
t.Fatal(first, err)
}
raw, _ := json.Marshal(first)
for _, name := range []string{`"input"`, `"output"`, `"policy_snapshot"`, `"specification"`} {
if strings.Contains(string(raw), name) {
t.Fatal("large private payload leaked", name)
}
}
base.Cursor = first.NextCursor
for name, change := range map[string]func(*instance.ListInput){"version": func(v *instance.ListInput) { v.Version = "v2" }, "definition": func(v *instance.ListInput) { v.DefinitionID = executionID(t) }, "business": func(v *instance.ListInput) { v.BusinessKey = strings.TrimSpace(in.BusinessKey) }, "state": func(v *instance.ListInput) { v.State = "WAITING" }, "paused": func(v *instance.ListInput) { b := false; v.Paused = &b }, "from": func(v *instance.ListInput) { z := stamp.Add(-time.Second); v.CreatedFrom = &z }, "before": func(v *instance.ListInput) { z := before.Add(time.Second); v.CreatedBefore = &z }} {
changed := base
change(&changed)
if _, err := api.List(ctx, f.token, changed); fault.Code(err) != "INVALID_ARGUMENT" {
t.Fatal(name, err)
}
}
changed := base
changed.Limit = 2
if page, err := api.List(ctx, f.token, changed); err != nil || len(page.Items) != 2 {
t.Fatal("page size may change", page, err)
}
base.Cursor = ""
base.BusinessKey = strings.TrimSpace(in.BusinessKey)
if page, err := api.List(ctx, f.token, base); err != nil || len(page.Items) != 0 {
t.Fatal("business key trimmed", page, err)
}
base.BusinessKey = in.BusinessKey
base.CreatedFrom = &before
base.CreatedBefore = nil
if page, err := api.List(ctx, f.token, base); err != nil || len(page.Items) != 0 {
t.Fatal("lower inclusive bound", page, err)
}
base.CreatedFrom = nil
base.CreatedBefore = &stamp
if page, err := api.List(ctx, f.token, base); err != nil || len(page.Items) != 0 {
t.Fatal("upper exclusive bound", page, err)
}
f.exec(`UPDATE weave_instances SET paused=TRUE WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, ids[0])
paused := true
if page, err := api.List(ctx, f.token, instance.ListInput{Paused: &paused}); err != nil || len(page.Items) != 1 || page.Items[0].ID != ids[0] {
t.Fatal("paused filter", page, err)
}
other := newExecutionFixture(t, ctx, db)
grantWorkflowInstance(t, other)
if page, err := api.List(ctx, other.token, instance.ListInput{}); err != nil || len(page.Items) != 0 {
t.Fatal("namespace leak", page, err)
}
if _, err := api.List(ctx, other.token, instance.ListInput{Cursor: first.NextCursor}); fault.Code(err) != "INVALID_ARGUMENT" {
t.Fatal("namespace cursor", err)
}
grantWorkflowInstance(t, f, string(f.resources.QueueID))
if _, err := api.List(ctx, f.token, instance.ListInput{Cursor: first.NextCursor}); fault.Code(err) != "INVALID_ARGUMENT" {
t.Fatal("changed scope cursor", err)
}
})
t.Run("all_frozen_queues_even_before_their_tasks_exist", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
grantWorkflowInstance(t, f)
q2 := executionID(t)
f.exec(`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,'other',8,100,'{}')`, f.resources.NamespaceID, q2)
spec := runtimeSpec(f.resources.QueueID, 2)
spec.Body.Steps[1].Task.QueueID = q2
hiddenIn := publishInstanceSpec(t, f, spec)
hidden, err := api.Start(ctx, f.token, "two-queues", hiddenIn)
if err != nil {
t.Fatal(err)
}
visibleIn := publishInstanceSpec(t, f, runtimeSpec(f.resources.QueueID, 1))
visible, err := api.Start(ctx, f.token, "one-queue", visibleIn)
if err != nil {
t.Fatal(err)
}
// 切换定义新版本到可读队列,旧实例仍按v1全范围鉴权。
_, err = definition.New(NewWorkflowDefinitionStore(db)).Publish(ctx, f.token, hiddenIn.DefinitionID, "v2", definition.PublishInput{Version: "v2", Specification: runtimeSpec(f.resources.QueueID, 1), Reason: "new scoped version"})
if err != nil {
t.Fatal(err)
}
grantWorkflowInstance(t, f, string(f.resources.QueueID))
page, err := api.List(ctx, f.token, instance.ListInput{Limit: 1})
if err != nil || len(page.Items) != 1 || page.Items[0].ID != visible.Instance.ID || page.NextCursor != "" {
t.Fatal("scope filtering", page, err)
}
if instanceCount(f, `SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=? AND queue_id=?`, f.resources.NamespaceID, q2) != 0 {
t.Fatal("test requires unexecuted queue")
}
grantWorkflowInstance(t, f, string(q2), string(f.resources.QueueID))
page, err = api.List(ctx, f.token, instance.ListInput{Limit: 1})
if err != nil || page.NextCursor == "" {
t.Fatal(page, err)
}
grantWorkflowInstance(t, f, string(f.resources.QueueID), string(q2))
if next, err := api.List(ctx, f.token, instance.ListInput{Limit: 1, Cursor: page.NextCursor}); err != nil || len(next.Items) != 1 || next.Items[0].ID != hidden.Instance.ID {
t.Fatal("queue ordering changed fingerprint", next, err)
}
// 只有workflows:read就足够查询,tasks:read和定义发布权限不会被隐式要求。
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',JSON_ARRAY('workflows:read')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
if page, err := api.List(ctx, f.token, instance.ListInput{}); err != nil || len(page.Items) != 2 {
t.Fatal(page, err)
}
f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.actions',JSON_ARRAY('tasks:read')) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID)
if _, err := api.List(ctx, f.token, instance.ListInput{}); fault.Code(err) != "PERMISSION_DENIED" {
t.Fatal("action bypass", err)
}
})
t.Run("no_queue_workflow_and_corrupt_scope_fail_closed", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
grantWorkflowInstance(t, f)
in := publishInstanceSpec(t, f, timerSpec(f.resources.QueueID, 5000, false))
v, err := api.Start(ctx, f.token, "timer", in)
if err != nil {
t.Fatal(err)
}
grantWorkflowInstance(t, f, string(executionID(t)))
if page, err := api.List(ctx, f.token, instance.ListInput{}); err != nil || len(page.Items) != 1 {
t.Fatal("empty queue set", page, err)
}
if _, err := db.ExecContext(ctx, `UPDATE weave_version_scopes SET queue_ids=JSON_ARRAY(?) WHERE namespace_id=?`, string(executionID(t)), f.resources.NamespaceID); err == nil {
t.Fatal("scope mutable")
}
f.exec(`DELETE FROM weave_version_scopes WHERE namespace_id=?`, f.resources.NamespaceID)
if page, err := api.List(ctx, f.token, instance.ListInput{}); err == nil || len(page.Items) != 0 {
t.Fatal("missing scope granted access", page, err)
}
f.exec(`INSERT INTO weave_version_scopes(namespace_id,definition_version_id,definition_id,content_digest,queue_ids) SELECT namespace_id,id,definition_id,content_digest,JSON_ARRAY() FROM weave_definition_versions WHERE namespace_id=?`, f.resources.NamespaceID)
f.exec(`UPDATE weave_instances SET policy_snapshot=JSON_SET(policy_snapshot,'$.max_task_attempts',999) WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, v.Instance.ID)
if page, err := api.List(ctx, f.token, instance.ListInput{}); err == nil || len(page.Items) != 0 {
t.Fatal("policy mismatch", page, err)
}
})
t.Run("underreported_scope_does_not_grant_queues", func(t *testing.T) {
f := newExecutionFixture(t, ctx, db)
grantWorkflowInstance(t, f)
in := publishInstanceSpec(t, f, runtimeSpec(f.resources.QueueID, 1))
if _, err := api.Start(ctx, f.token, "task", in); err != nil {
t.Fatal(err)
}
f.exec(`DELETE FROM weave_version_scopes WHERE namespace_id=?`, f.resources.NamespaceID)
f.exec(`INSERT INTO weave_version_scopes(namespace_id,definition_version_id,definition_id,content_digest,queue_ids) SELECT namespace_id,id,definition_id,content_digest,JSON_ARRAY() FROM weave_definition_versions WHERE namespace_id=?`, f.resources.NamespaceID)
grantWorkflowInstance(t, f, string(executionID(t)))
if page, err := api.List(ctx, f.token, instance.ListInput{}); err == nil || len(page.Items) != 0 {
t.Fatal("scope bypass", page, err)
}
})
}

View File

@@ -0,0 +1,27 @@
package mysql
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
)
func TestWorkflowQueryCursorRejectsAmbiguousJSON(t *testing.T) {
ns := core.ID("11111111111111111111111111111111")
at := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
raw, _ := json.Marshal(workflowCursor{1, ns, at, ns, "digest"})
encode := func(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
if _, err := decodeWorkflowCursor(encode(string(raw)), ns, instance.ListInput{}, "digest"); err != nil {
t.Fatal(err)
}
for _, bad := range []string{`null`, `[]`, strings.Replace(string(raw), `"v":1`, `"v":1,"v":1`, 1), strings.Replace(string(raw), `"v":1`, `"V":1`, 1), strings.Replace(string(raw), `"v":1`, `"v":null`, 1), strings.Replace(string(raw), `"v":1`, `"v":2`, 1), strings.Replace(string(raw), `"f":"digest"`, `"f":"other"`, 1), strings.Replace(string(raw), `"v":1`, `"v":1,"x":0`, 1)} {
if _, err := decodeWorkflowCursor(encode(bad), ns, instance.ListInput{}, "digest"); err == nil {
t.Fatal("accepted ambiguous cursor", bad)
}
}
}

View File

@@ -0,0 +1,183 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"slices"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/weave/definition"
"os"
"reflect"
"testing"
"time"
driver "github.com/go-sql-driver/mysql"
)
// 用真实v23结构验证范围回填,只读取DSL节点,不采集常量中的伪queue_id。
func TestIntegrationWorkflowQueryUpgrade(t *testing.T) {
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("requires disposable MySQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
admin, err := Open(ctx, dsn, 2, 1)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
name := "orbit_query_upgrade_" + string(executionID(t))
if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second)
defer stop()
if _, err := admin.ExecContext(cleanup, "DROP DATABASE `"+name+"`"); err != nil {
t.Error(err)
}
}()
cfg, err := driver.ParseDSN(dsn)
if err != nil {
t.Fatal("invalid test DSN")
}
cfg.DBName = name
db, err := Open(ctx, cfg.FormatDSN(), 4, 2)
if err != nil {
t.Fatal(err)
}
defer db.Close()
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(ctx, q, args...); err != nil {
t.Fatal(err)
}
}
exec(`CREATE TABLE orbit_schema_migrations (version BIGINT UNSIGNED PRIMARY KEY,name VARCHAR(191) NOT NULL,checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,dirty BOOLEAN NOT NULL,applied_at DATETIME(6) NULL) ENGINE=InnoDB`)
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
for _, m := range migrations {
if m.Version > 23 {
break
}
statements, err := splitSQL(m.SQL)
if err != nil {
t.Fatal(err)
}
for _, q := range statements {
exec(q)
}
exec(`INSERT INTO orbit_schema_migrations(version,name,checksum,dirty,applied_at) VALUES (?,?,?,FALSE,UTC_TIMESTAMP(6))`, m.Version, m.Name, m.Checksum)
}
ns, id, def := executionID(t), executionID(t), executionID(t)
q1, q2, q3, decoy := executionID(t), executionID(t), executionID(t), executionID(t)
spec := allInitialNodes(q1)
// 多层PARALLEL/BRANCH的未选分支、默认分支、POLL各自使用队列;重复值只保存一次。
branches := spec.Body.Steps[0].Parallel.Branches
branches[1].Body.Steps[0].Poll.Task.QueueID = q2
nestedTask := *branches[4].Body.Steps[0].Branch.Cases[0].Body.Steps[0].Task
nestedTask.QueueID = q3
branches[4].Body.Steps[0].Branch.Cases[0].Body.Steps[0].Task = &nestedTask
// 无队列TIMER版本中,合法JSON输出常量故意含伪TASK/POLL和queue_id。
timer := timerSpec(q1, 5000, false)
timer.Body.OutputType = definition.Type{Kind: "JSON"}
timer.Body.Output = definition.Value{Kind: "LITERAL", Literal: json.RawMessage(`{"kind":"TASK","task":{"queue_id":"` + string(decoy) + `"},"body":{"steps":[{"kind":"POLL","poll":{"task":{"queue_id":"` + string(decoy) + `"}}}]}}`)}
specs := []definition.Specification{spec, timer}
ids := []core.ID{id, executionID(t)}
for i, spec := range specs {
checked, err := definition.Check(spec)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(spec)
if err != nil {
t.Fatal(err)
}
exec(`INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by,created_at,updated_at) VALUES (?,?,?, ?,1,?,?,?,'2026-01-02 03:04:05.123456','2026-01-02 03:04:05.123456')`, ns, ids[i], def, string(ids[i]), string(raw), checked.Digest, string(executionID(t)))
}
snapshot := func() [][]sql.NullString {
t.Helper()
rows, err := db.QueryContext(ctx, `SELECT * FROM weave_definition_versions WHERE namespace_id=? ORDER BY id`, ns)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
t.Fatal(err)
}
var out [][]sql.NullString
for rows.Next() {
row := make([]sql.NullString, len(columns))
args := make([]any, len(columns))
for i := range row {
args[i] = &row[i]
}
if err = rows.Scan(args...); err != nil {
t.Fatal(err)
}
out = append(out, row)
}
if err = rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
before := snapshot()
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, snapshot()) {
t.Fatal("scope backfill changed original immutable versions")
}
for i, id := range ids {
var raw []byte
if err = db.QueryRowContext(ctx, `SELECT queue_ids FROM weave_version_scopes WHERE namespace_id=? AND definition_version_id=? AND definition_id=?`, ns, id, def).Scan(&raw); err != nil {
t.Fatal(err)
}
var queues []core.ID
if json.Unmarshal(raw, &queues) != nil || queues == nil {
t.Fatal("invalid scope", string(raw))
}
slices.Sort(queues)
expected := []core.ID{}
if i == 0 {
expected = []core.ID{q1, q2, q3}
slices.Sort(expected)
}
if !slices.Equal(queues, expected) {
t.Fatal("incorrect DSL queue set", queues, expected)
}
}
for index, columns := range map[string]string{
"ix_workflow_created": "namespace_id,created_at,id",
"ix_workflow_state_created": "namespace_id,state,created_at,id",
"ix_workflow_business_state_created": "namespace_id,business_key,state,created_at,id",
"ix_workflow_definition_created": "namespace_id,definition_id,created_at,id",
"ix_workflow_definition_state_created": "namespace_id,definition_id,state,created_at,id",
"ix_workflow_definition_business_created": "namespace_id,definition_id,business_key,created_at,id",
"ix_workflow_definition_business_state_created": "namespace_id,definition_id,business_key,state,created_at,id",
} {
var actual string
if err = db.QueryRowContext(ctx, `SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='weave_instances' AND INDEX_NAME=?`, index).Scan(&actual); err != nil || actual != columns {
t.Fatal(index, actual, err)
}
}
if err = CheckSchema(ctx, db); err != nil {
t.Fatal(err)
}
var fks int
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fks); err != nil || fks != 0 {
t.Fatal("foreign keys", fks, err)
}
if err = Migrate(ctx, db); err != nil {
t.Fatal(err)
}
t.Log("v23 upgraded; nested TASK/POLL queues match original DSL, literal decoys excluded, original versions unchanged, seven pagination indexes installed, foreign keys=0")
}

View File

@@ -0,0 +1,75 @@
package httpserver
import (
"net/http"
"net/url"
"strconv"
"time"
"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/weave/instance"
weave "git.yuntuo.hk/k2safe/orbit/weave/model"
)
func workflowListQuery(r *http.Request) (instance.ListInput, error) {
var in instance.ListInput
q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
return in, fault.Invalid("invalid query encoding")
}
for k, v := range q {
if len(v) != 1 {
return in, fault.Invalid("duplicate query parameter")
}
switch k {
case "definition_id", "version", "state", "paused", "business_key", "created_from", "created_before", "limit", "cursor":
default:
return in, fault.Invalid("unknown query parameter")
}
if v[0] == "" {
return in, fault.Invalid("empty query parameter")
}
}
in.DefinitionID = core.ID(q.Get("definition_id"))
in.Version = q.Get("version")
in.State = weave.InstanceState(q.Get("state"))
in.BusinessKey = q.Get("business_key")
in.Cursor = q.Get("cursor")
if q.Has("limit") {
in.Limit, err = strconv.Atoi(q.Get("limit"))
if err != nil || in.Limit <= 0 {
return in, fault.Invalid("invalid limit")
}
}
if q.Has("paused") {
v := q.Get("paused")
if v != "true" && v != "false" {
return in, fault.Invalid("paused must be true or false")
}
b := v == "true"
in.Paused = &b
}
for name, field := range map[string]**time.Time{"created_from": &in.CreatedFrom, "created_before": &in.CreatedBefore} {
if !q.Has(name) {
continue
}
v, err := time.Parse(time.RFC3339Nano, q.Get(name))
if err != nil {
return in, fault.Invalid("invalid creation time bound")
}
*field = &v
}
return in, nil
}
func registerWorkflowQuery(mux *http.ServeMux, s *instance.Service) {
mux.HandleFunc("GET /v1/workflows", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
in, err := workflowListQuery(r)
if err != nil {
return nil, 0, err
}
out, err := s.List(r.Context(), t, in)
return out, 200, err
}))
}

View File

@@ -0,0 +1,21 @@
package httpserver
import (
"net/http/httptest"
"net/url"
"testing"
)
func TestWorkflowListQueryStrictFilters(t *testing.T) {
for _, query := range []string{"limit=0", "limit=", "limit=x", "state=", "state=RUNNING&state=WAITING", "paused=0", "paused=True", "unknown=x", "business_key=", "created_from=tomorrow", "version=%zz", "state=WAITING;limit=2"} {
r := httptest.NewRequest("GET", "/v1/workflows?"+query, nil)
if _, err := workflowListQuery(r); err == nil {
t.Fatal("accepted", query)
}
}
q := url.Values{"business_key": {"关联 +/%\n "}, "paused": {"false"}, "created_from": {"2026-09-18T08:00:00.123456+08:00"}, "limit": {"2"}}
in, err := workflowListQuery(httptest.NewRequest("GET", "/v1/workflows?"+q.Encode(), nil))
if err != nil || in.BusinessKey != q.Get("business_key") || in.Paused == nil || *in.Paused || in.Limit != 2 || in.CreatedFrom.UTC().Hour() != 0 {
t.Fatal(in, err)
}
}

View File

@@ -16,6 +16,7 @@ import (
func RegisterWorkflows(server *http.Server, instances *instance.Service, controls *control.Service, signals *signal.Service, reviews *recovery.Service) {
mux := server.Handler.(*http.ServeMux)
registerWorkflowHistory(mux, instances, signals)
registerWorkflowQuery(mux, instances)
mux.HandleFunc("POST /v1/workflows", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err