feat: add precise task business key and creation time queries

This commit is contained in:
2026-09-17 22:19:36 +08:00
parent a50d5faac7
commit 7641423f9d
32 changed files with 1105 additions and 93 deletions

View 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
View 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
}

View 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
View 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")
}
}

View File

@@ -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) {

View File

@@ -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 当前任务状态;终态不会被重复请求重开。