feat: add precise task business key and creation time queries
This commit is contained in:
104
sdk/go/examples/task-query/main.go
Normal file
104
sdk/go/examples/task-query/main.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// task-query 演示业务键精确匹配、创建时间边界和任务摘要分页,不启动Worker。
|
||||
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 {
|
||||
token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID")
|
||||
if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read private credentials: %w", err)
|
||||
}
|
||||
var credentials struct {
|
||||
Token string `json:"token"`
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &credentials); err != nil {
|
||||
return fmt.Errorf("invalid credentials file")
|
||||
}
|
||||
if token == "" {
|
||||
token = credentials.Token
|
||||
}
|
||||
if queue == "" {
|
||||
queue = credentials.QueueID
|
||||
}
|
||||
}
|
||||
client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if queue == "" {
|
||||
return fmt.Errorf("ORBIT_QUEUE_ID is required")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
op, err := orbit.NewWorkerMessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
businessKey := "object /+% é " + op + " "
|
||||
var tasks []orbit.Task
|
||||
for i, key := range []string{businessKey, businessKey, businessKey[:len(businessKey)-1]} {
|
||||
out, err := client.SubmitTask(ctx, fmt.Sprintf("query-%s-%d", op, i), orbit.SubmitTaskRequest{QueueID: queue, Handler: "query." + op, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"object_ref":"examples/input"}`), BusinessKey: key, TaskTimeoutMS: 60000})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks = append(tasks, out.Task)
|
||||
}
|
||||
from, before := tasks[0].CreatedAt, tasks[2].CreatedAt.Add(time.Microsecond)
|
||||
query := orbit.ListTasksRequest{QueueID: queue, State: orbit.TaskWaiting, BusinessKey: businessKey, CreatedFrom: &from, CreatedBefore: &before, Limit: 1}
|
||||
matched, pages := 0, 0
|
||||
for {
|
||||
page, err := client.ListTasks(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pages++
|
||||
for _, task := range page.Items {
|
||||
if task.BusinessKey != businessKey {
|
||||
return errors.New("business key was not exact")
|
||||
}
|
||||
matched++
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
query.Cursor = page.NextCursor
|
||||
}
|
||||
if matched != 2 || pages != 2 {
|
||||
return errors.New("unexpected filtered pages")
|
||||
}
|
||||
query.Cursor = ""
|
||||
end := tasks[1].CreatedAt
|
||||
query.CreatedBefore = &end
|
||||
bounded, err := client.ListTasks(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bounded.Items) != 1 || bounded.Items[0].ID != tasks[0].ID {
|
||||
return errors.New("exclusive time boundary failed")
|
||||
}
|
||||
for i, task := range tasks {
|
||||
if _, err := client.CancelTask(ctx, task.ID, fmt.Sprintf("query-cancel-%s-%d", op, i), orbit.CancelTaskRequest{ExpectedRevision: task.Revision, Reason: "complete query example"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("matched=%d pages=%d before_excluded=true cancelled=%d\n", matched, pages, len(tasks))
|
||||
return nil
|
||||
}
|
||||
56
sdk/go/task_query.go
Normal file
56
sdk/go/task_query.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ListTasks 查询同一队列内的任务摘要,筛选固定后沿NextCursor分页;不承诺跨页快照。
|
||||
func (c *Client) ListTasks(ctx context.Context, in ListTasksRequest) (TaskPage, error) {
|
||||
var out TaskPage
|
||||
if !validID(in.QueueID) || in.Limit < 0 || in.Limit > 100 || len(in.Cursor) > 1024 {
|
||||
return out, errors.New("invalid task query queue or page")
|
||||
}
|
||||
switch in.State {
|
||||
case "", TaskWaiting, TaskLeased, TaskRunning, TaskNeedsReview, TaskSucceeded, TaskFailed, TaskCancelled:
|
||||
default:
|
||||
return out, errors.New("invalid task state")
|
||||
}
|
||||
if !utf8.ValidString(in.BusinessKey) || utf8.RuneCountInString(in.BusinessKey) > 191 || strings.ContainsAny(in.BusinessKey, "\x00\r\n") {
|
||||
return out, errors.New("invalid business key filter")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("queue_id", in.QueueID)
|
||||
if in.State != "" {
|
||||
q.Set("state", string(in.State))
|
||||
}
|
||||
if in.BusinessKey != "" {
|
||||
q.Set("business_key", in.BusinessKey)
|
||||
}
|
||||
if in.Limit != 0 {
|
||||
q.Set("limit", strconv.Itoa(in.Limit))
|
||||
}
|
||||
if in.Cursor != "" {
|
||||
q.Set("cursor", in.Cursor)
|
||||
}
|
||||
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 task creation 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")
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/tasks?"+q.Encode(), "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
64
sdk/go/task_query_integration_test.go
Normal file
64
sdk/go/task_query_integration_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationTaskQuerySDK(t *testing.T) {
|
||||
api, queue, _ := integrationWorkerClient(t)
|
||||
ctx, stop := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer stop()
|
||||
op := workerTestID(t)
|
||||
key := "query /+% é " + op + " "
|
||||
var tasks []Task
|
||||
for _, k := range []string{key, key, key[:len(key)-1]} {
|
||||
v, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: queue, Handler: "query." + op, HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"sequence":9007199254740993}`), BusinessKey: k, TaskTimeoutMS: 60000})
|
||||
if err != nil || v.Task.BusinessKey != k {
|
||||
t.Fatal(v, err)
|
||||
}
|
||||
tasks = append(tasks, v.Task)
|
||||
}
|
||||
from := tasks[0].CreatedAt
|
||||
before := tasks[2].CreatedAt.Add(time.Microsecond)
|
||||
query := ListTasksRequest{QueueID: queue, State: TaskWaiting, BusinessKey: key, CreatedFrom: &from, CreatedBefore: &before, Limit: 1}
|
||||
page, err := api.ListTasks(ctx, query)
|
||||
if err != nil || len(page.Items) != 1 || page.Items[0].ID != tasks[1].ID || page.NextCursor == "" {
|
||||
t.Fatal(page, err)
|
||||
}
|
||||
query.Cursor = page.NextCursor
|
||||
last, err := api.ListTasks(ctx, query)
|
||||
if err != nil || len(last.Items) != 1 || last.Items[0].ID != tasks[0].ID || last.NextCursor != "" {
|
||||
t.Fatal(last, err)
|
||||
}
|
||||
changed := query
|
||||
changed.BusinessKey = key[:len(key)-1]
|
||||
_, err = api.ListTasks(ctx, changed)
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != "INVALID_ARGUMENT" {
|
||||
t.Fatal("cursor accepted changed filter", err)
|
||||
}
|
||||
query.Cursor = ""
|
||||
end := tasks[1].CreatedAt
|
||||
query.CreatedBefore = &end
|
||||
bounded, err := api.ListTasks(ctx, query)
|
||||
if err != nil || len(bounded.Items) != 1 || bounded.Items[0].ID != tasks[0].ID || bounded.NextCursor != "" {
|
||||
t.Fatal("exclusive upper boundary", bounded, err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if _, err := api.CancelTask(ctx, task.ID, workerTestID(t), CancelTaskRequest{ExpectedRevision: task.Revision, Reason: "complete task query regression"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
query.State = TaskCancelled
|
||||
query.CreatedBefore = &before
|
||||
query.Limit = 20
|
||||
cancelled, err := api.ListTasks(ctx, query)
|
||||
if err != nil || len(cancelled.Items) != 2 {
|
||||
t.Fatal("created time changed after cancellation", cancelled, err)
|
||||
}
|
||||
t.Log("exact Unicode/trailing-space filter, inclusive lower/exclusive upper time, cursor binding and stable creation order verified over real HTTP")
|
||||
}
|
||||
47
sdk/go/task_query_test.go
Normal file
47
sdk/go/task_query_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package orbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskQueryEncodingAndBounds(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 := "业务 /+% é "
|
||||
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/tasks" || r.Header.Get("Idempotency-Key") != "" || r.Header.Get("Authorization") != "Bearer example-token" || 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": TaskPage{Items: []TaskSummary{{ID: id, Revision: 9007199254740993, BusinessKey: key}}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
c, _ := NewClient(server.URL, Options{Token: "example-token"})
|
||||
base := ListTasksRequest{QueueID: id, State: TaskWaiting, BusinessKey: key, CreatedFrom: &from, CreatedBefore: &before, Limit: 2, Cursor: "opaque+cursor/="}
|
||||
page, err := c.ListTasks(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(*ListTasksRequest){"queue": func(v *ListTasksRequest) { v.QueueID = "../queue" }, "state": func(v *ListTasksRequest) { v.State = "unknown" }, "limit": func(v *ListTasksRequest) { v.Limit = 101 }, "cursor": func(v *ListTasksRequest) { v.Cursor = strings.Repeat("a", 1025) }, "utf8": func(v *ListTasksRequest) { v.BusinessKey = "\xff" }, "key": func(v *ListTasksRequest) { v.BusinessKey = strings.Repeat("字", 192) }, "newline": func(v *ListTasksRequest) { v.BusinessKey = "x\n" }, "equal": func(v *ListTasksRequest) { v.CreatedBefore = &from }, "nanosecond": func(v *ListTasksRequest) { z := from.Add(time.Nanosecond); v.CreatedFrom = &z }, "year": func(v *ListTasksRequest) { z := time.Time{}; v.CreatedFrom = &z }} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
in := base
|
||||
change(&in)
|
||||
if _, err := c.ListTasks(context.Background(), in); err == nil {
|
||||
t.Fatal("invalid query accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatal("invalid query reached HTTP")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
@@ -42,22 +41,6 @@ func (c *Client) GetTask(ctx context.Context, id string) (Task, error) {
|
||||
err := c.call(ctx, "GET", "/v1/tasks/"+id, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
func (c *Client) ListTasks(ctx context.Context, in ListTasksRequest) (TaskPage, error) {
|
||||
var out TaskPage
|
||||
q := url.Values{}
|
||||
q.Set("queue_id", in.QueueID)
|
||||
if in.State != "" {
|
||||
q.Set("state", string(in.State))
|
||||
}
|
||||
if in.Limit != 0 {
|
||||
q.Set("limit", strconv.Itoa(in.Limit))
|
||||
}
|
||||
if in.Cursor != "" {
|
||||
q.Set("cursor", in.Cursor)
|
||||
}
|
||||
err := c.call(ctx, "GET", "/v1/tasks?"+q.Encode(), "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CancelTask 保存取消意图;运行中等待结果确认,重复请求保留原键与原版本。
|
||||
func (c *Client) CancelTask(ctx context.Context, id, key string, in CancelTaskRequest) (CancelTaskResult, error) {
|
||||
|
||||
@@ -108,6 +108,12 @@ type CancelTaskResult struct {
|
||||
Deduplicated bool `json:"deduplicated"`
|
||||
}
|
||||
type ListTasksRequest struct {
|
||||
// BusinessKey 可选精确业务关联键;空表示不筛选,区分大小写、Unicode形式及尾部空格。
|
||||
BusinessKey string
|
||||
// CreatedFrom 可选创建时间下界(包含),UTC微秒精度。
|
||||
CreatedFrom *time.Time
|
||||
// CreatedBefore 可选创建时间上界(不包含),与下界同时给出时必须更晚。
|
||||
CreatedBefore *time.Time
|
||||
// QueueID 队列标识,必须属于当前命名空间。
|
||||
QueueID string
|
||||
// State 当前任务状态;终态不会被重复请求重开。
|
||||
|
||||
Reference in New Issue
Block a user