feat: add scoped workflow instance queries and SDK pagination
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
257
internal/storage/mysql/workflow_query.go
Normal file
257
internal/storage/mysql/workflow_query.go
Normal 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
|
||||
}
|
||||
228
internal/storage/mysql/workflow_query_integration_test.go
Normal file
228
internal/storage/mysql/workflow_query_integration_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
27
internal/storage/mysql/workflow_query_test.go
Normal file
27
internal/storage/mysql/workflow_query_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user