feat: deliver authenticated task APIs, SDK and P1 regression
This commit is contained in:
91
internal/storage/mysql/bootstrap.go
Normal file
91
internal/storage/mysql/bootstrap.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||
type BootstrapInput struct {
|
||||
Application string // Application 应用名称,每次初始化创建独立应用。
|
||||
Namespace string // Namespace 命名空间名称。
|
||||
Environment string // Environment 环境名称,必须显式指定。
|
||||
Queue string // Queue 初始队列名称。
|
||||
}
|
||||
|
||||
// BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。
|
||||
type BootstrapResult struct {
|
||||
ApplicationID core.ID `json:"application_id"` // ApplicationID 新建应用标识。
|
||||
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 新建隔离范围。
|
||||
QueueID core.ID `json:"queue_id"` // QueueID 初始队列标识。
|
||||
CredentialID core.ID `json:"credential_id"` // CredentialID 凭据记录标识。
|
||||
Token string `json:"token"` // Token 原始秘密,仅保存到受保护交付文件。
|
||||
ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 初始凭据到期时间,UTC。
|
||||
}
|
||||
|
||||
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
|
||||
for _, v := range []string{in.Application, in.Namespace, in.Queue} {
|
||||
if !service.ValidName(v, 128) {
|
||||
return fmt.Errorf("invalid bootstrap resource name")
|
||||
}
|
||||
}
|
||||
if !service.ValidName(in.Environment, 32) || deliver == nil {
|
||||
return fmt.Errorf("environment and secure delivery are required")
|
||||
}
|
||||
if err := CheckSchema(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
var out BootstrapResult
|
||||
for _, dest := range []*core.ID{&out.ApplicationID, &out.NamespaceID, &out.QueueID, &out.CredentialID} {
|
||||
v, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dest = v
|
||||
}
|
||||
secret, digest, err := auth.Issue(out.NamespaceID, out.CredentialID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Token = secret
|
||||
permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
|
||||
raw, err := json.Marshal(permissions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WithTx(ctx, db, func(tx *sql.Tx) error {
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.ExpiresAt = now.Add(30 * 24 * time.Hour)
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_applications(id,name) VALUES (?,?)", out.ApplicationID, in.Application); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)", out.NamespaceID, out.ApplicationID, in.Namespace, in.Environment); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
op, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAudit(ctx, tx, auth.Token{NamespaceID: out.NamespaceID, CredentialID: out.CredentialID}, op, out.CredentialID, "credential", "bootstrap.create", "database administrator bootstrap", nil, map[string]any{"permissions": permissions, "expires_at": out.ExpiresAt}); err != nil {
|
||||
return err
|
||||
}
|
||||
// 交付文件先持久化再提交;提交失败时保留文件以便核对不确定结果。
|
||||
return deliver(out)
|
||||
})
|
||||
}
|
||||
61
internal/storage/mysql/migrations/000002_task_api.up.sql
Normal file
61
internal/storage/mysql/migrations/000002_task_api.up.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- 保留业务 JSON 的原始数值文本;验证 JSON 结构但不使用原生 JSON 数值重编码。
|
||||
|
||||
ALTER TABLE pulse_tasks MODIFY COLUMN `payload` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE pulse_tasks ADD CONSTRAINT ck_pulse_tasks_payload_json CHECK (`payload` IS NULL OR JSON_VALID(`payload`));
|
||||
|
||||
ALTER TABLE pulse_tasks MODIFY COLUMN `result` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE pulse_tasks ADD CONSTRAINT ck_pulse_tasks_result_json CHECK (`result` IS NULL OR JSON_VALID(`result`));
|
||||
|
||||
ALTER TABLE orbit_idempotency_keys MODIFY COLUMN `response` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE orbit_idempotency_keys ADD CONSTRAINT ck_orbit_idempotency_keys_response_json CHECK (`response` IS NULL OR JSON_VALID(`response`));
|
||||
|
||||
ALTER TABLE orbit_events MODIFY COLUMN `payload` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE orbit_events ADD CONSTRAINT ck_orbit_events_payload_json CHECK (`payload` IS NULL OR JSON_VALID(`payload`));
|
||||
|
||||
ALTER TABLE pulse_attempts MODIFY COLUMN `result` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE pulse_attempts ADD CONSTRAINT ck_pulse_attempts_result_json CHECK (`result` IS NULL OR JSON_VALID(`result`));
|
||||
|
||||
ALTER TABLE pulse_attempt_receipts MODIFY COLUMN `response` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE pulse_attempt_receipts ADD CONSTRAINT ck_pulse_attempt_receipts_response_json CHECK (`response` IS NULL OR JSON_VALID(`response`));
|
||||
|
||||
ALTER TABLE weave_definition_versions MODIFY COLUMN `specification` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE weave_definition_versions ADD CONSTRAINT ck_weave_definition_versions_specification_json CHECK (`specification` IS NULL OR JSON_VALID(`specification`));
|
||||
|
||||
ALTER TABLE weave_instances MODIFY COLUMN `input` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE weave_instances ADD CONSTRAINT ck_weave_instances_input_json CHECK (`input` IS NULL OR JSON_VALID(`input`));
|
||||
|
||||
ALTER TABLE weave_instances MODIFY COLUMN `output` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE weave_instances ADD CONSTRAINT ck_weave_instances_output_json CHECK (`output` IS NULL OR JSON_VALID(`output`));
|
||||
|
||||
ALTER TABLE weave_activations MODIFY COLUMN `input` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE weave_activations ADD CONSTRAINT ck_weave_activations_input_json CHECK (`input` IS NULL OR JSON_VALID(`input`));
|
||||
|
||||
ALTER TABLE weave_activations MODIFY COLUMN `output` MEDIUMTEXT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE weave_activations ADD CONSTRAINT ck_weave_activations_output_json CHECK (`output` IS NULL OR JSON_VALID(`output`));
|
||||
|
||||
ALTER TABLE weave_signals MODIFY COLUMN `payload` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE weave_signals ADD CONSTRAINT ck_weave_signals_payload_json CHECK (`payload` IS NULL OR JSON_VALID(`payload`));
|
||||
|
||||
ALTER TABLE weave_pending_advances MODIFY COLUMN `checkpoint` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE weave_pending_advances ADD CONSTRAINT ck_weave_pending_advances_checkpoint_json CHECK (`checkpoint` IS NULL OR JSON_VALID(`checkpoint`));
|
||||
|
||||
ALTER TABLE pulse_schedule_versions MODIFY COLUMN `task_template` MEDIUMTEXT NOT NULL COMMENT '业务无关 JSON 文本,保留原始数值精度,由版本化契约解释';
|
||||
|
||||
ALTER TABLE pulse_schedule_versions ADD CONSTRAINT ck_pulse_schedule_versions_task_template_json CHECK (`task_template` IS NULL OR JSON_VALID(`task_template`));
|
||||
|
||||
ALTER TABLE pulse_tasks ADD KEY ix_task_created (namespace_id, queue_id, created_at, id);
|
||||
|
||||
ALTER TABLE pulse_tasks ADD KEY ix_task_state_created (namespace_id, queue_id, state, created_at, id);
|
||||
396
internal/storage/mysql/task_integration_test.go
Normal file
396
internal/storage/mysql/task_integration_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/transport/httpserver"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
)
|
||||
|
||||
type apiReply struct {
|
||||
status int
|
||||
body []byte
|
||||
data json.RawMessage
|
||||
code string
|
||||
}
|
||||
|
||||
func TestIntegrationTaskAPI(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, 24, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := func() core.ID {
|
||||
t.Helper()
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
bootstrap := func() BootstrapResult {
|
||||
t.Helper()
|
||||
var out BootstrapResult
|
||||
err := Bootstrap(ctx, db, BootstrapInput{Application: "app-" + string(id()), Namespace: "default", Environment: "test", Queue: "files"}, func(v BootstrapResult) error { out = v; return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
a, b := bootstrap(), bootstrap()
|
||||
svc := service.New(NewTaskStore(db))
|
||||
server := httptest.NewServer(httpserver.New("", func(context.Context) error { return nil }, svc).Handler)
|
||||
defer server.Close()
|
||||
call := func(method, path, token, key string, body []byte) (apiReply, error) {
|
||||
r, err := http.NewRequestWithContext(ctx, method, server.URL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return apiReply{}, err
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if key != "" {
|
||||
r.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
response, err := server.Client().Do(r)
|
||||
if err != nil {
|
||||
return apiReply{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return apiReply{}, err
|
||||
}
|
||||
var envelope struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return apiReply{}, fmt.Errorf("invalid response JSON: %s", raw)
|
||||
}
|
||||
if core.ID(envelope.RequestID).Validate() != nil || response.Header.Get("X-Request-ID") != envelope.RequestID {
|
||||
return apiReply{}, errors.New("missing request correlation")
|
||||
}
|
||||
return apiReply{response.StatusCode, raw, envelope.Data, envelope.Error.Code}, nil
|
||||
}
|
||||
must := func(method, path, token, key string, body []byte, status int) apiReply {
|
||||
t.Helper()
|
||||
out, err := call(method, path, token, key, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.status != status {
|
||||
t.Fatalf("%s %s: wanted %d got %d: %s", method, path, status, out.status, out.body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
marshal := func(v any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
input := service.SubmitInput{QueueID: a.QueueID, Handler: "file.convert", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{"sequence":9007199254740993,"decimal":12345678901234567890.1234567890123456789,"one":1}`)}
|
||||
raw := marshal(input)
|
||||
var accepted service.SubmitResult
|
||||
out := must("POST", "/v1/tasks", a.Token, "stable-submit", raw, 201)
|
||||
if err := json.Unmarshal(out.data, &accepted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(out.body, []byte("12345678901234567890.1234567890123456789")) || !bytes.Contains(out.body, []byte("9007199254740993")) {
|
||||
t.Fatal("payload precision lost")
|
||||
}
|
||||
taskID := accepted.Task.ID
|
||||
count := func(table, where string, args ...any) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE "+where, args...).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
execute := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Run("concurrent_duplicate_and_semantic_request_identity", func(t *testing.T) {
|
||||
// 模拟首次响应未被业务确认:重复原始请求,必须读到相同提交回执。
|
||||
again := must("POST", "/v1/tasks", a.Token, "stable-submit", raw, 200)
|
||||
var dupe service.SubmitResult
|
||||
if err := json.Unmarshal(again.data, &dupe); err != nil || dupe.Task.ID != taskID || !dupe.Deduplicated {
|
||||
t.Fatalf("duplicate: %s %v", again.body, err)
|
||||
}
|
||||
equivalent := input
|
||||
equivalent.Payload = json.RawMessage(`{"one":1.00,"decimal":12345678901234567890.123456789012345678900,"sequence":9007199254740993}`)
|
||||
must("POST", "/v1/tasks", a.Token, "stable-submit", marshal(equivalent), 200)
|
||||
equivalent.Payload = json.RawMessage(`{"one":2}`)
|
||||
reply := must("POST", "/v1/tasks", a.Token, "stable-submit", marshal(equivalent), 409)
|
||||
if reply.code != "IDEMPOTENCY_CONFLICT" {
|
||||
t.Fatal(reply.code)
|
||||
}
|
||||
const n = 16
|
||||
start := make(chan struct{})
|
||||
results := make(chan apiReply, n)
|
||||
errs := make(chan error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
out, err := call("POST", "/v1/tasks", a.Token, "concurrent-submit", raw)
|
||||
results <- out
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var shared core.ID
|
||||
created := 0
|
||||
for out := range results {
|
||||
if out.status != 200 && out.status != 201 {
|
||||
t.Fatalf("concurrent response %d: %s", out.status, out.body)
|
||||
}
|
||||
if out.status == 201 {
|
||||
created++
|
||||
}
|
||||
var v service.SubmitResult
|
||||
if err := json.Unmarshal(out.data, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shared == "" {
|
||||
shared = v.Task.ID
|
||||
}
|
||||
if v.Task.ID != shared {
|
||||
t.Fatal("duplicate task created")
|
||||
}
|
||||
}
|
||||
if created != 1 || count("orbit_events", "namespace_id=? AND aggregate_id=?", a.NamespaceID, shared) != 1 || count("orbit_audit_events", "namespace_id=? AND resource_id=? AND action='task.submit'", a.NamespaceID, shared) != 1 {
|
||||
t.Fatalf("creation/event count: %d", created)
|
||||
}
|
||||
})
|
||||
t.Run("authorization_scope_and_revocation", func(t *testing.T) {
|
||||
must("GET", "/v1/tasks/"+string(taskID), "", "", nil, 401)
|
||||
wrong := a.Token[:len(a.Token)-43] + strings.Repeat("A", 43)
|
||||
must("GET", "/v1/tasks/"+string(taskID), wrong, "", nil, 401)
|
||||
must("GET", "/v1/tasks/"+string(taskID), b.Token, "", nil, 404)
|
||||
must("POST", "/v1/tasks", b.Token, "cross-submit", raw, 404)
|
||||
credential := id()
|
||||
secret, digest, err := auth.Issue(a.NamespaceID, credential)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissions := auth.Permissions{Actions: []string{"tasks:read"}, Queues: []string{string(a.QueueID)}}
|
||||
execute("INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions) VALUES (?,?,?,?,?)", a.NamespaceID, credential, string(credential), digest, string(marshal(permissions)))
|
||||
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 200)
|
||||
must("POST", "/v1/tasks", secret, "read-only", raw, 403)
|
||||
execute("UPDATE orbit_credentials SET expires_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?", a.NamespaceID, credential)
|
||||
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 401)
|
||||
execute("UPDATE orbit_credentials SET expires_at=NULL,revoked_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?", a.NamespaceID, credential)
|
||||
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 401)
|
||||
execute("UPDATE orbit_credentials SET revoked_at=NULL,permissions=? WHERE namespace_id=? AND id=?", string(marshal(auth.Permissions{Actions: []string{"tasks:read"}, Queues: []string{string(b.QueueID)}})), a.NamespaceID, credential)
|
||||
must("GET", "/v1/tasks/"+string(taskID), secret, "", nil, 404)
|
||||
execute("UPDATE orbit_namespaces SET enabled=FALSE WHERE id=?", b.NamespaceID)
|
||||
must("GET", "/v1/tasks/"+string(taskID), b.Token, "", nil, 401)
|
||||
execute("UPDATE orbit_namespaces SET enabled=TRUE WHERE id=?", b.NamespaceID)
|
||||
execute("UPDATE orbit_applications SET enabled=FALSE WHERE id=?", b.ApplicationID)
|
||||
must("GET", "/v1/tasks/"+string(taskID), b.Token, "", nil, 401)
|
||||
execute("UPDATE orbit_applications SET enabled=TRUE WHERE id=?", b.ApplicationID)
|
||||
})
|
||||
t.Run("strict_input_and_failed_submission_rollback", func(t *testing.T) {
|
||||
for _, bad := range []string{`null`, `{"namespace_id":"override"}`, `{"handler":"one","handler":"two"}`, `{"Handler":"case-alias"}`, `{"payload":"\ud800"}`, `{"retry":{"max_attempts":null}}`} {
|
||||
must("POST", "/v1/tasks", a.Token, "invalid-body", []byte(bad), 400)
|
||||
}
|
||||
must("POST", "/v1/tasks", a.Token, "oversize", []byte(`{"payload":"`+strings.Repeat("x", 512<<10)+`"}`), 413)
|
||||
must("POST", "/v1/tasks", a.Token, "", raw, 400)
|
||||
invalid := input
|
||||
past := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond)
|
||||
invalid.AvailableAt = &past
|
||||
must("POST", "/v1/tasks", a.Token, "past", marshal(invalid), 400)
|
||||
hash := core.Digest(sha256.Sum256([]byte("past")))
|
||||
if count("orbit_idempotency_keys", "namespace_id=? AND key_digest=?", a.NamespaceID, hash) != 0 {
|
||||
t.Fatal("failed submission committed a reservation")
|
||||
}
|
||||
must("POST", "/v1/tasks", a.Token, "past", raw, 201)
|
||||
})
|
||||
t.Run("keyset_pagination_and_opaque_payload", func(t *testing.T) {
|
||||
seen := map[core.ID]bool{}
|
||||
path := "/v1/tasks?queue_id=" + string(a.QueueID) + "&limit=1"
|
||||
for i := 0; i < 10; i++ {
|
||||
reply := must("GET", path, a.Token, "", nil, 200)
|
||||
var page service.TaskPage
|
||||
if err := json.Unmarshal(reply.data, &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(reply.data, []byte(`"payload"`)) {
|
||||
t.Fatal("list included payload")
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if seen[item.ID] {
|
||||
t.Fatal("repeated list item")
|
||||
}
|
||||
seen[item.ID] = true
|
||||
}
|
||||
if page.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
path = "/v1/tasks?queue_id=" + string(a.QueueID) + "&limit=1&cursor=" + url.QueryEscape(page.NextCursor)
|
||||
must("GET", path+"&state=CANCELLED", a.Token, "", nil, 400)
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("list count %d", len(seen))
|
||||
}
|
||||
reply := must("GET", "/v1/tasks/"+string(taskID), a.Token, "", nil, 200)
|
||||
if !bytes.Contains(reply.data, []byte("12345678901234567890.1234567890123456789")) {
|
||||
t.Fatal("get changed number")
|
||||
}
|
||||
must("GET", "/v1/tasks?queue_id="+string(a.QueueID)+"&limit=101", a.Token, "", nil, 400)
|
||||
})
|
||||
t.Run("cancel_transaction_rolls_back_when_audit_fails", func(t *testing.T) {
|
||||
execute("CREATE TRIGGER orbit_test_reject_audit BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='integration injected audit failure'")
|
||||
defer execute("DROP TRIGGER orbit_test_reject_audit")
|
||||
in := marshal(service.CancelInput{Reason: "test rollback", ExpectedRevision: 1})
|
||||
reply := must("POST", "/v1/tasks/"+string(taskID)+":cancel", a.Token, "cancel-rollback", in, 503)
|
||||
if strings.Contains(string(reply.body), "injected") {
|
||||
t.Fatal("leaked internal failure")
|
||||
}
|
||||
var task service.TaskView
|
||||
reply = must("GET", "/v1/tasks/"+string(taskID), a.Token, "", nil, 200)
|
||||
if err := json.Unmarshal(reply.data, &task); err != nil || task.State != pulse.TaskWaiting || task.Revision != 1 {
|
||||
t.Fatalf("rollback state: %+v %v", task, err)
|
||||
}
|
||||
if count("orbit_events", "namespace_id=? AND aggregate_id=?", a.NamespaceID, taskID) != 1 {
|
||||
t.Fatal("event escaped rollback")
|
||||
}
|
||||
hash := core.Digest(sha256.Sum256([]byte("cancel-rollback")))
|
||||
if count("orbit_idempotency_keys", "namespace_id=? AND key_digest=?", a.NamespaceID, hash) != 0 {
|
||||
t.Fatal("reservation escaped rollback")
|
||||
}
|
||||
})
|
||||
t.Run("cancel_compare_and_swap_and_duplicate_receipt", func(t *testing.T) {
|
||||
in := marshal(service.CancelInput{Reason: "finish task", ExpectedRevision: 1})
|
||||
replies := make(chan apiReply, 2)
|
||||
errs := make(chan error, 2)
|
||||
for _, key := range []string{"cancel-a", "cancel-b"} {
|
||||
go func() {
|
||||
out, err := call("POST", "/v1/tasks/"+string(taskID)+":cancel", a.Token, key, in)
|
||||
replies <- out
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
successes, conflicts := 0, 0
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := <-replies
|
||||
switch out.status {
|
||||
case 200:
|
||||
successes++
|
||||
case 409:
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("cancel: %d %s", out.status, out.body)
|
||||
}
|
||||
}
|
||||
if successes != 1 || conflicts != 1 {
|
||||
t.Fatalf("cancel winners=%d conflicts=%d", successes, conflicts)
|
||||
}
|
||||
duplicate := 0
|
||||
for _, key := range []string{"cancel-a", "cancel-b"} {
|
||||
out, err := call("POST", "/v1/tasks/"+string(taskID)+":cancel", a.Token, key, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.status == 200 {
|
||||
var v service.CancelResult
|
||||
if err := json.Unmarshal(out.data, &v); err != nil || !v.Deduplicated || v.Task.State != pulse.TaskCancelled || v.Task.Revision != 2 {
|
||||
t.Fatalf("receipt: %s %v", out.body, err)
|
||||
}
|
||||
duplicate++
|
||||
}
|
||||
}
|
||||
if duplicate != 1 || count("orbit_audit_events", "namespace_id=? AND resource_id=? AND action='task.cancel'", a.NamespaceID, taskID) != 1 || count("orbit_events", "namespace_id=? AND aggregate_id=?", a.NamespaceID, taskID) != 2 {
|
||||
t.Fatal("cancel duplicated effects")
|
||||
}
|
||||
reply := must("POST", "/v1/tasks", a.Token, "stable-submit", raw, 200)
|
||||
var original service.SubmitResult
|
||||
if err := json.Unmarshal(reply.data, &original); err != nil || original.Task.State != pulse.TaskWaiting {
|
||||
t.Fatal("original receipt should remain immutable")
|
||||
}
|
||||
})
|
||||
t.Run("queue_management_and_bootstrap_rollback", func(t *testing.T) {
|
||||
body := marshal(service.QueueInput{Name: "second", MaxConcurrency: 8, RatePerSecond: 100})
|
||||
reply := must("POST", "/v1/queues", a.Token, "queue-second", body, 201)
|
||||
var v service.QueueResult
|
||||
if err := json.Unmarshal(reply.data, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
must("POST", "/v1/queues", a.Token, "queue-second", body, 200)
|
||||
must("POST", "/v1/queues", a.Token, "queue-name-conflict", body, 409)
|
||||
must("GET", "/v1/queues/"+string(v.Queue.ID), a.Token, "", nil, 200)
|
||||
must("GET", "/v1/queues/"+string(v.Queue.ID), b.Token, "", nil, 404)
|
||||
name := "failed-" + string(id())
|
||||
sentinel := errors.New("failed private output")
|
||||
err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "n", Environment: "test", Queue: "q"}, func(BootstrapResult) error { return sentinel })
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count("orbit_applications", "name=?", name) != 0 {
|
||||
t.Fatal("bootstrap escaped rollback")
|
||||
}
|
||||
})
|
||||
t.Run("cancelled_context_cannot_commit", func(t *testing.T) {
|
||||
token, err := auth.Parse(a.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cancelled, stop := context.WithCancel(ctx)
|
||||
stop()
|
||||
_, err = svc.Submit(cancelled, token, "cancelled-context", input)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
443
internal/storage/mysql/task_store.go
Normal file
443
internal/storage/mysql/task_store.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// TaskStore 在单个 MySQL 事务中执行权限、幂等与任务状态写入。
|
||||
type TaskStore struct{ db *sql.DB }
|
||||
|
||||
func NewTaskStore(db *sql.DB) *TaskStore { return &TaskStore{db: db} }
|
||||
|
||||
var _ service.Store = (*TaskStore)(nil)
|
||||
|
||||
func (s *TaskStore) transaction(ctx context.Context, token auth.Token, action string, fn func(*sql.Tx, auth.Permissions) error) error {
|
||||
return WithTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
p, err := authenticate(ctx, tx, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.Allows(action) {
|
||||
return fault.Forbidden
|
||||
}
|
||||
return fn(tx, p)
|
||||
})
|
||||
}
|
||||
func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permissions, error) {
|
||||
var digest core.Digest
|
||||
var raw []byte
|
||||
err := tx.QueryRowContext(ctx, `SELECT c.secret_digest,c.permissions FROM orbit_credentials c
|
||||
JOIN orbit_namespaces n ON n.id=c.namespace_id JOIN orbit_applications a ON a.id=n.application_id
|
||||
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
|
||||
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6)) AND n.enabled=TRUE AND a.enabled=TRUE FOR SHARE`, token.NamespaceID, token.CredentialID).Scan(&digest, &raw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return auth.Permissions{}, fault.Unauthorized
|
||||
}
|
||||
if err != nil {
|
||||
return auth.Permissions{}, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
|
||||
return auth.Permissions{}, fault.Unauthorized
|
||||
}
|
||||
var p auth.Permissions
|
||||
if json.Unmarshal(raw, &p) != nil || p.Validate() != nil {
|
||||
return p, fault.Unauthorized
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func requestDigest(value any) (core.Digest, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return core.Digest{}, err
|
||||
}
|
||||
raw, err = jsonvalue.Canonical(raw)
|
||||
if err != nil {
|
||||
return core.Digest{}, err
|
||||
}
|
||||
return core.Digest(sha256.Sum256(append([]byte("orbit-json-v1\x00"), raw...))), nil
|
||||
}
|
||||
|
||||
// reserveIdentity 通过唯一键和行锁串行化同一请求。占位记录、资源及最终响应一起提交。
|
||||
// 事务失败整体回滚;提交结果不明时调用方仍必须使用原键重报。
|
||||
func reserveIdentity(ctx context.Context, tx *sql.Tx, ns core.ID, operation, scope, key, kind string, resource core.ID, value any) (core.ID, []byte, error) {
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
digest, err := requestDigest(value)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
keyDigest := core.Digest(sha256.Sum256([]byte(key)))
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at)
|
||||
VALUES (?,?,?,?,?,?,?,?, '{}',DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY))
|
||||
ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, ns, id, operation, scope, keyDigest, digest, kind, resource)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var storedID core.ID
|
||||
var storedDigest core.Digest
|
||||
var response []byte
|
||||
err = tx.QueryRowContext(ctx, `SELECT id,request_digest,response FROM orbit_idempotency_keys
|
||||
WHERE namespace_id=? AND operation=? AND scope_key=? AND key_digest=? FOR UPDATE`, ns, operation, scope, keyDigest).Scan(&storedID, &storedDigest, &response)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(digest[:], storedDigest[:]) != 1 {
|
||||
return "", nil, fault.IdempotencyConflict
|
||||
}
|
||||
if storedID == id {
|
||||
return id, nil, nil
|
||||
}
|
||||
return storedID, response, nil
|
||||
}
|
||||
func saveIdentity(ctx context.Context, tx *sql.Tx, ns, id core.ID, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "UPDATE orbit_idempotency_keys SET response=?,revision=revision+1,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?", string(raw), ns, id)
|
||||
return err
|
||||
}
|
||||
func emitEvent(ctx context.Context, tx *sql.Tx, ns, resource core.ID, revision uint64, kind, event string, payload any) error {
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_events(namespace_id,id,aggregate_kind,aggregate_id,aggregate_revision,type,payload,occurred_at) VALUES (?,?,?,?,?,?,?,UTC_TIMESTAMP(6))`, ns, id, kind, resource, revision, event, string(raw))
|
||||
return err
|
||||
}
|
||||
func appendAudit(ctx context.Context, tx *sql.Tx, token auth.Token, operation, resource core.ID, kind, action, reason string, before, after any) error {
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
old, err := json.Marshal(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := json.Marshal(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,`before`,`after`) VALUES (?,?,?,?,?,?,?,?,?,?)", token.NamespaceID, id, operation, string(token.CredentialID), action, kind, resource, reason, string(old), string(current))
|
||||
return err
|
||||
}
|
||||
func dbNow(ctx context.Context, tx *sql.Tx) (time.Time, error) {
|
||||
var now time.Time
|
||||
err := tx.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now)
|
||||
return now, err
|
||||
}
|
||||
|
||||
func (s *TaskStore) Submit(ctx context.Context, token auth.Token, key string, in service.SubmitInput) (service.SubmitResult, error) {
|
||||
var out service.SubmitResult
|
||||
err := s.transaction(ctx, token, "tasks:submit", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if !p.AllowsQueue(in.QueueID) {
|
||||
return fault.Forbidden
|
||||
}
|
||||
taskID, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "task.submit", "", key, "task", taskID, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
var queue core.ID
|
||||
if err := tx.QueryRowContext(ctx, "SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE", token.NamespaceID, in.QueueID).Scan(&queue); errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
available := now
|
||||
if in.AvailableAt != nil {
|
||||
available = *in.AvailableAt
|
||||
}
|
||||
deadline := now.Add(time.Duration(in.TaskTimeoutMS) * time.Millisecond)
|
||||
if available.Before(now.Add(-time.Second)) || !available.Before(deadline) {
|
||||
return fault.Invalid("available_at must be current/future and before the task deadline")
|
||||
}
|
||||
if in.StartDeadlineAt != nil && (!in.StartDeadlineAt.After(now) || in.StartDeadlineAt.Before(available) || in.StartDeadlineAt.After(deadline)) {
|
||||
return fault.Invalid("start_deadline_at must be after now, at or after available_at, and no later than the task deadline")
|
||||
}
|
||||
policy, err := json.Marshal(in.Retry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO pulse_tasks(namespace_id,id,queue_id,handler,handler_version,payload_version,payload,policy_snapshot,recovery_safety,business_key,available_at,start_deadline_at,deadline_at,attempt_timeout_ms,max_attempts,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,NULLIF(?,''),?,?,?,?,?,?,?)`, token.NamespaceID, taskID, in.QueueID, in.Handler, in.HandlerVersion, in.PayloadVersion, string(in.Payload), string(policy), in.RecoverySafety, in.BusinessKey, available, in.StartDeadlineAt, deadline, in.Retry.AttemptTimeoutMS, in.Retry.MaxAttempts, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Task, err = readTask(ctx, tx, token.NamespaceID, taskID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := emitEvent(ctx, tx, token.NamespaceID, taskID, 1, "task", "task.accepted", map[string]any{"task_id": taskID, "state": pulse.TaskWaiting}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAudit(ctx, tx, token, identity, taskID, "task", "task.submit", "submit task", nil, map[string]any{"state": pulse.TaskWaiting, "queue_id": in.QueueID, "handler": in.Handler, "handler_version": in.HandlerVersion}); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
const taskColumns = `id,namespace_id,queue_id,state,revision,handler,handler_version,payload_version,payload,result,COALESCE(business_key,''),available_at,start_deadline_at,deadline_at,recovery_safety,policy_snapshot,attempt_count,cancel_requested_at,completed_at,created_at,updated_at`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanTask(row scanner) (service.TaskView, error) {
|
||||
var v service.TaskView
|
||||
var policy, payload, result []byte
|
||||
err := row.Scan(&v.ID, &v.NamespaceID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.PayloadVersion, &payload, &result, &v.BusinessKey, &v.AvailableAt, &v.StartDeadlineAt, &v.DeadlineAt, &v.RecoverySafety, &policy, &v.AttemptCount, &v.CancelRequestedAt, &v.CompletedAt, &v.CreatedAt, &v.UpdatedAt)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Payload = payload
|
||||
v.Result = result
|
||||
err = json.Unmarshal(policy, &v.Retry)
|
||||
return v, err
|
||||
}
|
||||
func readTask(ctx context.Context, tx *sql.Tx, ns, id core.ID, lock bool) (service.TaskView, error) {
|
||||
q := "SELECT " + taskColumns + " FROM pulse_tasks WHERE namespace_id=? AND id=?"
|
||||
if lock {
|
||||
q += " FOR UPDATE"
|
||||
}
|
||||
v, err := scanTask(tx.QueryRowContext(ctx, q, ns, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
func (s *TaskStore) GetTask(ctx context.Context, token auth.Token, id core.ID) (service.TaskView, error) {
|
||||
var out service.TaskView
|
||||
err := s.transaction(ctx, token, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
var err error
|
||||
out, err = readTask(ctx, tx, token.NamespaceID, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.AllowsQueue(out.QueueID) {
|
||||
return fault.NotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
type taskCursor struct {
|
||||
Namespace core.ID `json:"n"`
|
||||
Queue core.ID `json:"q"`
|
||||
State pulse.TaskState `json:"s"`
|
||||
Created time.Time `json:"t"`
|
||||
ID core.ID `json:"i"`
|
||||
}
|
||||
|
||||
func (s *TaskStore) ListTasks(ctx context.Context, token auth.Token, in service.ListInput) (service.TaskPage, error) {
|
||||
out := service.TaskPage{Items: []service.TaskSummary{}}
|
||||
err := s.transaction(ctx, token, "tasks:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if !p.AllowsQueue(in.QueueID) {
|
||||
return fault.Forbidden
|
||||
}
|
||||
q := `SELECT id,queue_id,state,revision,handler,handler_version,COALESCE(business_key,''),available_at,deadline_at,attempt_count,created_at,updated_at FROM pulse_tasks WHERE namespace_id=? AND queue_id=?`
|
||||
args := []any{token.NamespaceID, in.QueueID}
|
||||
if in.State != "" {
|
||||
q += " AND state=?"
|
||||
args = append(args, in.State)
|
||||
}
|
||||
if in.Cursor != "" {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(in.Cursor)
|
||||
var c taskCursor
|
||||
if err != nil || json.Unmarshal(raw, &c) != nil || c.Namespace != token.NamespaceID || c.Queue != in.QueueID || c.State != in.State || c.Created.IsZero() || c.ID.Validate() != nil {
|
||||
return fault.Invalid("cursor does not match this query")
|
||||
}
|
||||
q += " AND (created_at < ? OR (created_at=? AND id<?))"
|
||||
args = append(args, c.Created, c.Created, c.ID)
|
||||
}
|
||||
q += " ORDER BY created_at DESC,id DESC LIMIT ?"
|
||||
args = append(args, in.Limit+1)
|
||||
rows, err := tx.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v service.TaskSummary
|
||||
if err := rows.Scan(&v.ID, &v.QueueID, &v.State, &v.Revision, &v.Handler, &v.HandlerVersion, &v.BusinessKey, &v.AvailableAt, &v.DeadlineAt, &v.AttemptCount, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Items = append(out.Items, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.Items) > in.Limit {
|
||||
out.Items = out.Items[:in.Limit]
|
||||
last := out.Items[len(out.Items)-1]
|
||||
raw, err := json.Marshal(taskCursor{token.NamespaceID, in.QueueID, in.State, last.CreatedAt, last.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.NextCursor = base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) CancelTask(ctx context.Context, token auth.Token, id core.ID, key string, in service.CancelInput) (service.CancelResult, error) {
|
||||
var out service.CancelResult
|
||||
err := s.transaction(ctx, token, "tasks:cancel", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
// 先以查询权限范围核对队列,再锁幂等记录,统一所有写操作的锁定顺序。
|
||||
task, err := readTask(ctx, tx, token.NamespaceID, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.AllowsQueue(task.QueueID) {
|
||||
return fault.NotFound
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "task.cancel", string(id), key, "task", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
task, err = readTask(ctx, tx, token.NamespaceID, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Revision != in.ExpectedRevision {
|
||||
return fault.Conflict
|
||||
}
|
||||
if task.State != pulse.TaskWaiting {
|
||||
return fault.Conflict
|
||||
}
|
||||
var owner sql.NullString
|
||||
if err := tx.QueryRowContext(ctx, "SELECT owner_kind FROM pulse_tasks WHERE namespace_id=? AND id=?", token.NamespaceID, id).Scan(&owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if owner.Valid {
|
||||
return fault.Conflict
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE pulse_tasks SET state='CANCELLED',cancel_requested_at=UTC_TIMESTAMP(6),completed_at=UTC_TIMESTAMP(6),updated_at=UTC_TIMESTAMP(6),revision=revision+1 WHERE namespace_id=? AND id=? AND revision=? AND state='WAITING'`, token.NamespaceID, id, in.ExpectedRevision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return fault.Conflict
|
||||
}
|
||||
out.Task, err = readTask(ctx, tx, token.NamespaceID, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := emitEvent(ctx, tx, token.NamespaceID, id, out.Task.Revision, "task", "task.cancelled", map[string]any{"task_id": id, "state": out.Task.State}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAudit(ctx, tx, token, identity, id, "task", "task.cancel", in.Reason, map[string]any{"state": task.State, "revision": fmt.Sprint(task.Revision)}, map[string]any{"state": out.Task.State, "revision": fmt.Sprint(out.Task.Revision)}); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TaskStore) CreateQueue(ctx context.Context, token auth.Token, key string, in service.QueueInput) (service.QueueResult, error) {
|
||||
var out service.QueueResult
|
||||
err := s.transaction(ctx, token, "queues:manage", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if !p.AllowsQueue("") {
|
||||
return fault.Forbidden
|
||||
}
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, token.NamespaceID, "queue.create", "", key, "queue", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,?,?,'{}')", token.NamespaceID, id, in.Name, in.MaxConcurrency, in.RatePerSecond)
|
||||
var dbErr *driver.MySQLError
|
||||
if errors.As(err, &dbErr) && dbErr.Number == 1062 {
|
||||
return fault.Conflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Queue, err = readQueue(ctx, tx, token.NamespaceID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAudit(ctx, tx, token, identity, id, "queue", "queue.create", "create queue", nil, out.Queue); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, token.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func readQueue(ctx context.Context, tx *sql.Tx, ns, id core.ID) (service.QueueView, error) {
|
||||
var q service.QueueView
|
||||
err := tx.QueryRowContext(ctx, "SELECT id,namespace_id,name,paused,max_concurrency,rate_per_second,revision FROM pulse_queues WHERE namespace_id=? AND id=?", ns, id).Scan(&q.ID, &q.NamespaceID, &q.Name, &q.Paused, &q.MaxConcurrency, &q.RatePerSecond, &q.Revision)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
return q, err
|
||||
}
|
||||
func (s *TaskStore) GetQueue(ctx context.Context, token auth.Token, id core.ID) (service.QueueView, error) {
|
||||
var out service.QueueView
|
||||
err := s.transaction(ctx, token, "queues:read", func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if !p.AllowsQueue(id) {
|
||||
return fault.NotFound
|
||||
}
|
||||
var err error
|
||||
out, err = readQueue(ctx, tx, token.NamespaceID, id)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
Reference in New Issue
Block a user