feat: publish immutable workflow definitions with typed SDK

This commit is contained in:
2026-09-18 00:28:00 +08:00
parent a664ec9370
commit 520e98d9d1
33 changed files with 2603 additions and 11 deletions

View File

@@ -0,0 +1,109 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestIntegrationWorkflowDefinitionSDK(t *testing.T) {
api, queue, _ := integrationWorkerClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
key := workerTestID(t)
created, err := api.CreateWorkflowDefinition(ctx, key, CreateWorkflowDefinitionRequest{Name: "workflow." + key, Description: "精确数字发布回归"})
if err != nil {
t.Fatal(err)
}
id := created.Definition.ID
typ := WorkflowType{Kind: "OBJECT", Fields: map[string]WorkflowType{"n": {Kind: "NUMBER"}}}
task := WorkflowTask{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`{"n":9007199254740993}`)}, ResultType: typ, TimeoutMS: 5000, Retry: RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: RecoveryReconcileOnly}
in := PublishWorkflowVersionRequest{Version: "v1", Reason: "SDK publication", Specification: WorkflowSpecification{SchemaVersion: 1, TimeoutMS: 30000, FailurePolicy: "REVIEW", InputType: typ, Body: WorkflowBlock{Steps: []WorkflowStep{{Key: "prepare", Kind: "TASK", Task: &task}, {Key: "wait", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 1000}}}, OutputType: typ, Output: WorkflowRef("prepare")}}}
// 真服务确认发布提交后,代理关闭客户端连接;SDK不得自动换键或默默重试。
target, _ := url.Parse(api.baseURL)
proxy := httputil.NewSingleHostReverseProxy(target)
var dropped atomic.Bool
var calls atomic.Int32
proxy.ModifyResponse = func(r *http.Response) error {
calls.Add(1)
if dropped.CompareAndSwap(false, true) {
raw, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
return err
}
if r.StatusCode != 201 || !strings.Contains(string(raw), `"deduplicated":false`) {
t.Error("publication was not committed before response loss")
}
return io.ErrUnexpectedEOF
}
return nil
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
conn, _, e := w.(http.Hijacker).Hijack()
if e == nil {
conn.Close()
}
}
gateway := httptest.NewServer(proxy)
defer gateway.Close()
client, err := NewClient(gateway.URL, Options{Token: api.token})
if err != nil {
t.Fatal(err)
}
if _, err := client.PublishWorkflowVersion(ctx, id, key, in); err == nil || calls.Load() != 1 || !dropped.Load() {
t.Fatal("response loss not surfaced", err)
}
published, err := client.PublishWorkflowVersion(ctx, id, key, in)
if err != nil || !published.Deduplicated {
t.Fatal("lost publication not recovered", err)
}
original := published.Version
if string(original.Specification.Body.Steps[0].Task.Input.Literal) != `{"n":9007199254740993}` || len(original.ContentDigest) != 64 || original.Analysis.Steps != 2 || original.Analysis.MaxTaskAttempts != 1 {
t.Fatal("SDK result changed precision or analysis")
}
same, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
if err != nil || !same.Deduplicated || !reflect.DeepEqual(same.Version, original) {
t.Fatal("same version", err)
}
in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740992}`)
_, err = api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != "STATE_CONFLICT" {
t.Fatal("overwrite accepted", err)
}
in.Version = "v2"
next, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
if err != nil || next.Version.ContentDigest == original.ContentDigest {
t.Fatal("v2", err)
}
stored, err := api.GetWorkflowVersion(ctx, id, "v1")
if err != nil || !reflect.DeepEqual(stored, original) {
t.Fatal("immutable v1", err)
}
metadata, err := api.GetWorkflowDefinition(ctx, id)
if err != nil || metadata.Revision != 3 {
t.Fatal("unexpected version count", err)
}
// 类型的空块输出支持显式null,不放宽普通任务的null参数约束。
in.Version = "v3"
in.Specification.Body = WorkflowBlock{OutputType: WorkflowType{Kind: "NULL"}, Output: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`null`)}}
v3, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in)
if err != nil || v3.Version.Analysis.Queues == nil || len(v3.Version.Analysis.Queues) != 0 {
t.Fatal("null block", err)
}
if _, err := api.GetWorkflowVersion(ctx, id, "../other"); err == nil {
t.Fatal("unsafe path accepted")
}
t.Log("real HTTP publication response dropped after commit; original identity restored precise immutable version, no second revision; all SDK definition routes exercised")
}